diff --git a/.gitignore b/.gitignore index f8285e5..31b6f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -68,3 +68,4 @@ jspm_packages/ # Stores VSCode versions used for testing VSCode extensions .vscode-test +/.agent-shell/ diff --git a/experiments/ghostwright/README.md b/experiments/ghostwright/README.md index 2617331..3333d98 100644 --- a/experiments/ghostwright/README.md +++ b/experiments/ghostwright/README.md @@ -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). @@ -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` diff --git a/experiments/ghostwright/docs/agent-quickstart.md b/experiments/ghostwright/docs/agent-quickstart.md index 693430e..fb28807 100644 --- a/experiments/ghostwright/docs/agent-quickstart.md +++ b/experiments/ghostwright/docs/agent-quickstart.md @@ -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) diff --git a/experiments/ghostwright/docs/choosing-assertions.md b/experiments/ghostwright/docs/choosing-assertions.md index ed98150..466471d 100644 --- a/experiments/ghostwright/docs/choosing-assertions.md +++ b/experiments/ghostwright/docs/choosing-assertions.md @@ -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 @@ -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: diff --git a/experiments/ghostwright/docs/interaction-recipes.md b/experiments/ghostwright/docs/interaction-recipes.md index b519ec8..6a1af67 100644 --- a/experiments/ghostwright/docs/interaction-recipes.md +++ b/experiments/ghostwright/docs/interaction-recipes.md @@ -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: diff --git a/experiments/ghostwright/package.json b/experiments/ghostwright/package.json index 32e1a1b..1035f3d 100644 --- a/experiments/ghostwright/package.json +++ b/experiments/ghostwright/package.json @@ -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", @@ -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" diff --git a/experiments/ghostwright/scripts/build-ghostty-vt.ts b/experiments/ghostwright/scripts/build-ghostty-vt.ts index 943aace..82d5ac3 100644 --- a/experiments/ghostwright/scripts/build-ghostty-vt.ts +++ b/experiments/ghostwright/scripts/build-ghostty-vt.ts @@ -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', @@ -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`; diff --git a/experiments/ghostwright/scripts/update-manifest.ts b/experiments/ghostwright/scripts/update-manifest.ts index 84f00c7..dbf2c49 100644 --- a/experiments/ghostwright/scripts/update-manifest.ts +++ b/experiments/ghostwright/scripts/update-manifest.ts @@ -1,116 +1,74 @@ 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.toSorted()) { - artifacts[`artifacts/${name}`] = { - sha256: createHash('sha256') - .update(await readFile(new URL(name, root))) - .digest('hex'), - }; +import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; + +/** + * Refreshes the `artifacts` checksum map in ghostty.lock.json. + * + * This script used to rebuild the entire lockfile from a hardcoded copy, which + * silently discarded any field that copy did not know about. Because the + * hardcoded copy drifted from the committed lockfile, a single `build:artifacts` + * run would downgrade `bindingVersion` 2 -> 1, delete the `graphics` block, and + * drop the Kitty graphics entries from `requiredWasmExports`. That made the + * build non-idempotent: the next `build:ghostty-vt` failed with + * GW_PATCH_CHECKSUM because it reads `graphics.freestandingPatchSha256`. + * + * The lockfile is now the source of truth. Only checksums are rewritten, and + * only for artifacts this machine actually produced. Targets that were not + * built locally (for example the Linux hosts on a macOS dev machine) keep their + * previously recorded checksums rather than being dropped from the manifest. + */ +const root = new URL('../', import.meta.url), + artifactsRoot = new URL('artifacts/', root), + lockUrl = new URL('ghostty.lock.json', root), + lock = JSON.parse(await readFile(lockUrl, 'utf8')); + +const built: string[] = []; +for (const name of await readdir(artifactsRoot)) + if (name === 'ghostty-vt.wasm' || name.startsWith('pty-host-')) built.push(name); +for (const name of ['terminfo/67/ghostty', 'terminfo/78/xterm-ghostty']) + try { + await stat(new URL(name, artifactsRoot)); + built.push(name); + } catch { + // terminfo entry not compiled on this machine; keep any recorded checksum. + } + +const artifacts: Record = { ...lock.artifacts }, + refreshed: string[] = []; +for (const name of built.toSorted()) { + const key = `artifacts/${name}`, + sha256 = createHash('sha256') + .update(await readFile(new URL(name, artifactsRoot))) + .digest('hex'); + if (artifacts[key]?.sha256 !== sha256) refreshed.push(key); + artifacts[key] = { sha256 }; } -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, -}; -await writeFile( - new URL('../ghostty.lock.json', import.meta.url), - JSON.stringify(lock, null, 2) + '\n', + +const preserved = Object.keys(artifacts).filter( + (key) => !built.some((name) => `artifacts/${name}` === key), +); +lock.artifacts = Object.fromEntries( + Object.entries(artifacts).toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)), +); +await writeFile(lockUrl, JSON.stringify(lock, null, '\t') + '\n'); + +// JSON.stringify expands short arrays that the committed lockfile keeps inline. +// Normalize through the repo formatter so a no-op build produces no diff. This +// is best effort: the lockfile is valid JSON either way. +for (let dir = new URL('./', lockUrl); ; dir = new URL('../', dir)) { + const bsh = new URL('node_modules/.bin/bsh', dir); + if (existsSync(bsh)) { + spawnSync(bsh.pathname, ['format', lockUrl.pathname], { stdio: 'ignore' }); + break; + } + if (dir.pathname === '/') break; +} + +// oxlint-disable-next-line no-console -- build script +console.log( + `manifest: ${refreshed.length} checksum(s) updated, ${built.length - refreshed.length} unchanged, ${preserved.length} preserved for targets not built here${ + preserved.length ? ` (${preserved.join(', ')})` : '' + }`, ); diff --git a/experiments/ghostwright/scripts/verify-artifacts.ts b/experiments/ghostwright/scripts/verify-artifacts.ts index c8b22c6..bedfc12 100644 --- a/experiments/ghostwright/scripts/verify-artifacts.ts +++ b/experiments/ghostwright/scripts/verify-artifacts.ts @@ -8,27 +8,48 @@ if (lock.protocolVersion !== 1 || lock.bindingVersion !== 2) code: 'GW_VERSION_MISMATCH', message: 'protocol or binding version mismatch', }); +// The manifest records every release target, but a developer machine only +// builds its own platform. Absent artifacts are skipped and reported; artifacts +// that are present are always verified strictly. +const absent = new Set(), + read = async (artifactPath: string): Promise => { + try { + return await readFile(new URL(artifactPath, root)); + } catch (error: any) { + if (error?.code !== 'ENOENT') throw error; + absent.add(artifactPath); + return undefined; + } + }; +let verified = 0; for (const [artifactPath, entry] of Object.entries(lock.artifacts) as [ string, { sha256: string }, ][]) { - const actual = createHash('sha256') - .update(await readFile(new URL(artifactPath, root))) - .digest('hex'); + const contents = await read(artifactPath); + if (!contents) continue; + const actual = createHash('sha256').update(contents).digest('hex'); if (actual !== entry.sha256) throw new GhostwrightError({ code: 'GW_CHECKSUM_MISMATCH', message: `${artifactPath}: checksum mismatch (expected ${entry.sha256}, got ${actual})`, }); + verified++; } for (const artifactPath of Object.keys(lock.artifacts).filter((p) => p.includes('pty-host-'))) { - const binary = await readFile(new URL(artifactPath, root)); + const binary = await read(artifactPath); + if (!binary) continue; if (!binary.includes(Buffer.from(`GWPT_PROTOCOL_VERSION=${lock.protocolVersion}`))) throw new GhostwrightError({ code: 'GW_PROTOCOL_MARKER', message: `${artifactPath}: protocol marker mismatch`, }); } +if (verified === 0) + throw new GhostwrightError({ + code: 'GW_NO_ARTIFACTS', + message: 'no Ghostwright artifacts found; run `bun run build:artifacts` first', + }); 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)); @@ -65,5 +86,7 @@ if (lock.graphics?.kittyGraphics) { } // 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`, + `verified ${verified} Ghostwright artifacts and ${Object.keys(lock.abi.structSizes).length} ABI layouts${ + absent.size ? `; skipped ${absent.size} not built here (${[...absent].join(', ')})` : '' + }`, ); diff --git a/experiments/ghostwright/src/assertions/index.ts b/experiments/ghostwright/src/assertions/index.ts index e980461..0232192 100644 --- a/experiments/ghostwright/src/assertions/index.ts +++ b/experiments/ghostwright/src/assertions/index.ts @@ -5,10 +5,43 @@ import type { ScreenRevision, ScreenSnapshot, StableAssertionOptions, + StyleQuery, TransientAssertionOptions, } from '../types.ts'; +import { DEFAULT_ASSERTION_TIMEOUT_MS } from '../types.ts'; +import { cellsMatchStyle, describeColor } from '../styles.ts'; import { Locator } from '../terminal/session.ts'; import type { TerminalSession } from '../terminal/session.ts'; + +/** + * Wrap a user predicate so it can be evaluated against any screen revision. + * + * Predicates run against every revision, including the blank frames before the + * application has painted anything. A predicate that reads a not yet rendered + * layout would otherwise throw and abort the whole assertion, surfacing as an + * unrelated failure. Throwing is treated as "not satisfied", and the most + * recent error is reported in the diagnostic if the assertion never converges. + */ +function safePredicate(predicate: (snapshot: ScreenSnapshot) => boolean): { + test: (snapshot: ScreenSnapshot) => boolean; + note: () => string; +} { + let lastError: unknown; + return { + test: (snapshot) => { + try { + return predicate(snapshot); + } catch (error) { + lastError = error; + return false; + } + }, + note: () => + lastError === undefined + ? '' + : `\npredicate threw (treated as unsatisfied): ${lastError instanceof Error ? lastError.message : String(lastError)}`, + }; +} // oxlint-disable-next-line max-params -- diagnostic needs all four params for failure reporting function diagnostic( session: TerminalSession, @@ -54,7 +87,10 @@ async function wait( class LocatorExpectation implements AsyncLocatorExpectation { constructor(readonly locator: Locator) {} async toBePresent(options: AssertionOptions = {}): Promise { - const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? 5000; + const timeout = + options.timeoutMs ?? + this.locator.session.options.assertionTimeoutMs ?? + DEFAULT_ASSERTION_TIMEOUT_MS; try { return await this.locator.unique(timeout); } catch (cause) { @@ -70,7 +106,10 @@ class LocatorExpectation implements AsyncLocatorExpectation { } } async toBeStable(options: StableAssertionOptions = {}): Promise { - const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? 5000, + const timeout = + options.timeoutMs ?? + this.locator.session.options.assertionTimeoutMs ?? + DEFAULT_ASSERTION_TIMEOUT_MS, settle = options.settleMs ?? this.locator.session.options.settleMs ?? 100, start = performance.now(); let match = await this.toBePresent({ timeoutMs: timeout }); @@ -112,7 +151,10 @@ class LocatorExpectation implements AsyncLocatorExpectation { } } async toBeAbsent(options: StableAssertionOptions = {}): Promise { - const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? 5000, + const timeout = + options.timeoutMs ?? + this.locator.session.options.assertionTimeoutMs ?? + DEFAULT_ASSERTION_TIMEOUT_MS, settle = options.settleMs ?? this.locator.session.options.settleMs ?? 100, start = performance.now(); for (;;) { @@ -162,6 +204,71 @@ class LocatorExpectation implements AsyncLocatorExpectation { ); } } + async toHaveStyle(style: StyleQuery, options: AssertionOptions = {}): Promise { + const timeout = + options.timeoutMs ?? + this.locator.session.options.assertionTimeoutMs ?? + DEFAULT_ASSERTION_TIMEOUT_MS, + start = performance.now(), + satisfied = () => { + const m = this.locator.matches(); + return m.length === 1 && cellsMatchStyle(m[0].cells, style); + }; + await this.toBePresent({ timeoutMs: timeout }); + if (!satisfied()) + await wait( + this.locator.session, + satisfied, + Math.max(1, timeout - (performance.now() - start)), + () => { + const m = this.locator.matches(), + actual = m[0]?.cells.find((cell) => !cell.continuation)?.style; + return `${diagnostic( + this.locator.session, + `${JSON.stringify(this.locator.query)} to have style ${JSON.stringify(style)}`, + timeout, + )}\nactual style: ${ + actual + ? `foreground=${describeColor(actual.foreground)} background=${describeColor(actual.background)} bold=${actual.bold} inverse=${actual.inverse} underline=${actual.underline}` + : 'no match' + }`; + }, + ); + return this.locator.matches()[0]; + } + async toContainCursor(options: AssertionOptions = {}): Promise { + const timeout = + options.timeoutMs ?? + this.locator.session.options.assertionTimeoutMs ?? + DEFAULT_ASSERTION_TIMEOUT_MS, + start = performance.now(), + satisfied = () => { + const m = this.locator.matches(); + if (m.length !== 1) return false; + const { range } = m[0], + { cursor } = this.locator.session.screen.current(); + return ( + cursor.row >= range.row && + cursor.row < range.row + range.height && + cursor.column >= range.column && + cursor.column < range.column + range.width + ); + }; + await this.toBePresent({ timeoutMs: timeout }); + if (!satisfied()) + await wait( + this.locator.session, + satisfied, + Math.max(1, timeout - (performance.now() - start)), + () => + diagnostic( + this.locator.session, + `${JSON.stringify(this.locator.query)} to contain the cursor`, + timeout, + ), + ); + return this.locator.matches()[0]; + } } class TerminalExpectation implements AsyncTerminalExpectation { constructor(readonly session: TerminalSession) {} @@ -169,17 +276,21 @@ class TerminalExpectation implements AsyncTerminalExpectation { predicate: (snapshot: ScreenSnapshot) => boolean, options: StableAssertionOptions = {}, ): Promise { - const timeout = options.timeoutMs ?? this.session.options.assertionTimeoutMs ?? 5000, + const timeout = + options.timeoutMs ?? + this.session.options.assertionTimeoutMs ?? + DEFAULT_ASSERTION_TIMEOUT_MS, settle = options.settleMs ?? this.session.options.settleMs ?? 100, - started = performance.now(); + started = performance.now(), + safe = safePredicate(predicate); for (;;) { const snapshot = this.session.screen.current(); - if (predicate(snapshot)) { + if (safe.test(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), + diagnostic(this.session, 'screen predicate to converge', timeout, settle) + safe.note(), ); await new Promise((resolve) => { const unsubscribe = this.session.subscribe(() => { @@ -196,9 +307,10 @@ class TerminalExpectation implements AsyncTerminalExpectation { } await wait( this.session, - () => predicate(this.session.screen.current()), + () => safe.test(this.session.screen.current()), Math.max(1, timeout - (performance.now() - started)), - () => diagnostic(this.session, 'screen predicate to converge', timeout, settle), + () => + diagnostic(this.session, 'screen predicate to converge', timeout, settle) + safe.note(), ); } } @@ -206,22 +318,28 @@ class TerminalExpectation implements AsyncTerminalExpectation { predicate: (snapshot: ScreenSnapshot) => boolean, options: TransientAssertionOptions = {}, ): Promise { - const timeout = options.timeoutMs ?? this.session.options.assertionTimeoutMs ?? 5000, + const timeout = + options.timeoutMs ?? + this.session.options.assertionTimeoutMs ?? + DEFAULT_ASSERTION_TIMEOUT_MS, 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)); + const safe = safePredicate(predicate), + find = () => + this.session.revisionsSince(baseline).find((revision) => safe.test(revision.snapshot)); let result = find(); if (!result) await wait( this.session, () => !!(result = find()), timeout, - () => diagnostic(this.session, `screen predicate since revision ${baseline}`, timeout), + () => + diagnostic(this.session, `screen predicate since revision ${baseline}`, timeout) + + safe.note(), ); return result as ScreenRevision; } diff --git a/experiments/ghostwright/src/assertions/types-internal.ts b/experiments/ghostwright/src/assertions/types-internal.ts index 67902b7..ee6bf9c 100644 --- a/experiments/ghostwright/src/assertions/types-internal.ts +++ b/experiments/ghostwright/src/assertions/types-internal.ts @@ -4,12 +4,15 @@ import type { ScreenRevision, ScreenSnapshot, StableAssertionOptions, + StyleQuery, TransientAssertionOptions, } from '../types.ts'; export interface AsyncLocatorExpectation { toBePresent(options?: AssertionOptions): Promise; toBeAbsent(options?: StableAssertionOptions): Promise; toBeStable(options?: StableAssertionOptions): Promise; + toHaveStyle(style: StyleQuery, options?: AssertionOptions): Promise; + toContainCursor(options?: AssertionOptions): Promise; } export interface AsyncTerminalExpectation { toSatisfy( diff --git a/experiments/ghostwright/src/effection/index.ts b/experiments/ghostwright/src/effection/index.ts index 74c3e99..aa4d57a 100644 --- a/experiments/ghostwright/src/effection/index.ts +++ b/experiments/ghostwright/src/effection/index.ts @@ -16,6 +16,7 @@ import type { StableAssertionOptions, ScreenRevision, ScreenSnapshot, + StyleQuery, TerminalLaunchOptions, TextLocatorOptions, TraceableInputOptions, @@ -141,6 +142,8 @@ export interface EffectionLocatorExpectation { toBePresent(options?: AssertionOptions): Operation; toBeAbsent(options?: StableAssertionOptions): Operation; toBeStable(options?: StableAssertionOptions): Operation; + toHaveStyle(style: StyleQuery, options?: AssertionOptions): Operation; + toContainCursor(options?: AssertionOptions): Operation; } /** Effection terminal assertion expectation. */ export interface EffectionTerminalExpectation { @@ -164,6 +167,8 @@ export function expectOperation( toBePresent: (o?: AssertionOptions) => op(() => e.toBePresent(o)), toBeAbsent: (o?: StableAssertionOptions) => op(() => e.toBeAbsent(o)), toBeStable: (o?: StableAssertionOptions) => op(() => e.toBeStable(o)), + toHaveStyle: (style: StyleQuery, o?: AssertionOptions) => op(() => e.toHaveStyle(style, o)), + toContainCursor: (o?: AssertionOptions) => op(() => e.toContainCursor(o)), }; } const e = expectAsync(target.inner); diff --git a/experiments/ghostwright/src/errors.ts b/experiments/ghostwright/src/errors.ts index dd5fbc8..44a16f9 100644 --- a/experiments/ghostwright/src/errors.ts +++ b/experiments/ghostwright/src/errors.ts @@ -37,6 +37,16 @@ export class ReservedEnvironmentError extends errorType( export class LaunchError extends errorType('LaunchError', 'GW_LAUNCH') {} /** Error for protocol violations. */ export class ProtocolError extends errorType('ProtocolError', 'GW_PROTOCOL') {} +/** Error for conflicting extension registrations. */ +export class ExtensionDuplicateError extends errorType( + 'ExtensionDuplicateError', + 'GW_EXTENSION_DUPLICATE', +) {} +/** Error for a registered OSC sequence exceeding its bounded buffer. */ +export class ExtensionOscLimitError extends errorType( + 'ExtensionOscLimitError', + 'GW_EXTENSION_OSC_LIMIT', +) {} /** Error when host command exceeds timeout. */ export class HostCommandTimeoutError extends errorType( 'HostCommandTimeoutError', @@ -49,6 +59,7 @@ export class ProcessExitedError extends errorType('ProcessExitedError', 'GW_PROC /** Error when session is already closed. */ export class SessionClosedError extends errorType('SessionClosedError', 'GW_SESSION_CLOSED') {} /** Error for coordinate out-of-range. */ +/** Error for coordinates outside the viewport. */ export class CoordinateRangeError extends errorType( 'CoordinateRangeError', 'GW_COORDINATE_RANGE', @@ -63,5 +74,7 @@ export class HistoryEvictedError extends errorType('HistoryEvictedError', 'GW_HI export class HistoryChangedError extends errorType('HistoryChangedError', 'GW_HISTORY_CHANGED') {} /** Error writing trace files. */ export class TraceWriteError extends errorType('TraceWriteError', 'GW_TRACE_WRITE') {} +/** Error for a key name the encoder cannot represent. */ +export class InvalidKeyError extends errorType('InvalidKeyError', 'GW_INVALID_KEY') {} /** 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 db52f36..8c315e3 100644 --- a/experiments/ghostwright/src/index.ts +++ b/experiments/ghostwright/src/index.ts @@ -1,5 +1,7 @@ export * from './types.ts'; export * from './errors.ts'; +export { isValidKeyName, parseKey } from './keys.ts'; +export { styleMatches, cellsMatchStyle, describeColor } from './styles.ts'; export { withTerminalAsync } from './async.ts'; export { withTerminal } from './effection/index.ts'; export { replayTrace, type ReplayResult } from './tracing/replay.ts'; diff --git a/experiments/ghostwright/src/keys.ts b/experiments/ghostwright/src/keys.ts new file mode 100644 index 0000000..4d5908d --- /dev/null +++ b/experiments/ghostwright/src/keys.ts @@ -0,0 +1,86 @@ +import { InvalidKeyError } from './errors.ts'; +import type { KeyName, KeyPress } from './types.ts'; + +/** + * Functional keys the Kitty encoder understands, mapped to their key codes. + * Shared with the WASM encoder so validation and encoding cannot drift apart. + */ +export const FUNCTIONAL_KEYS: Readonly> = Object.freeze({ + 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, +}); + +const MODIFIERS: Readonly>> = Object.freeze({ + shift: 'shift', + ctrl: 'control', + control: 'control', + alt: 'alt', + option: 'alt', + cmd: 'super', + command: 'super', + meta: 'super', + super: 'super', +}); + +/** True when `name` is a key the encoder can actually turn into bytes. */ +export function isValidKeyName(name: string): boolean { + if (name in FUNCTIONAL_KEYS) return true; + const fn = /^F(\d+)$/.exec(name); + if (fn) return Number(fn[1]) >= 1 && Number(fn[1]) <= 25; + return [...name].length === 1; +} + +function describeValidKeys(): string { + return `${Object.keys(FUNCTIONAL_KEYS).join(', ')}, F1-F25, or a single character`; +} + +/** + * Normalize a key argument into a validated {@link KeyPress}. + * + * Accepts the combination syntax people reach for first (`'Shift+Tab'`, + * `'Ctrl+A'`, `'Cmd+K'`) in addition to the object form. Unknown key names are + * rejected here rather than silently encoding to nothing: `KeyName` widens to + * `string`, so TypeScript cannot catch a typo, and an unencodable key used to + * surface only as an assertion timeout much later. + */ +export function parseKey(input: KeyName | KeyPress): KeyPress { + if (typeof input !== 'string') { + if (!input || typeof input.key !== 'string') + throw new InvalidKeyError('Key press requires a string `key` property'); + if (!isValidKeyName(input.key)) + throw new InvalidKeyError( + `Unknown key ${JSON.stringify(input.key)}. Expected ${describeValidKeys()}.`, + ); + return input; + } + + const press: KeyPress = { key: input }; + let rest = input; + for (;;) { + // Strip one leading `Modifier+` at a time so `Ctrl++` keeps `+` as the key. + const match = /^([A-Za-z]+)\+(?=.)/.exec(rest); + if (!match) break; + const modifier = MODIFIERS[match[1].toLowerCase()]; + if (!modifier) break; + press[modifier] = true; + rest = rest.slice(match[0].length); + } + press.key = rest; + + if (!isValidKeyName(press.key)) + throw new InvalidKeyError( + `Unknown key ${JSON.stringify(input)}. Expected ${describeValidKeys()}, optionally prefixed with Shift+, Ctrl+, Alt+, or Cmd+.`, + ); + return press; +} diff --git a/experiments/ghostwright/src/styles.ts b/experiments/ghostwright/src/styles.ts new file mode 100644 index 0000000..f069255 --- /dev/null +++ b/experiments/ghostwright/src/styles.ts @@ -0,0 +1,62 @@ +import type { CellStyle, ColorQuery, ScreenCell, StyleQuery, TerminalColor } from './types.ts'; + +function parseColor(query: ColorQuery): TerminalColor | undefined { + if (typeof query !== 'string') return query; + const text = query.trim().toLowerCase(); + if (text === 'default') return { kind: 'default' }; + const palette = /^palette:(\d+)$/.exec(text); + if (palette) return { kind: 'palette', index: Number(palette[1]) }; + const hex = /^#?([0-9a-f]{6})$/.exec(text); + if (hex) + return { + kind: 'rgb', + red: Number.parseInt(hex[1].slice(0, 2), 16), + green: Number.parseInt(hex[1].slice(2, 4), 16), + blue: Number.parseInt(hex[1].slice(4, 6), 16), + }; + const rgb = /^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/.exec(text); + if (rgb) return { kind: 'rgb', red: Number(rgb[1]), green: Number(rgb[2]), blue: Number(rgb[3]) }; + return undefined; +} + +function colorMatches(actual: TerminalColor | undefined, query: ColorQuery): boolean { + const expected = parseColor(query); + if (!expected || !actual) return false; + if (expected.kind !== actual.kind) return false; + if (expected.kind === 'palette' && actual.kind === 'palette') + return expected.index === actual.index; + if (expected.kind === 'rgb' && actual.kind === 'rgb') + return ( + expected.red === actual.red && + expected.green === actual.green && + expected.blue === actual.blue + ); + return true; +} + +/** True when `style` satisfies every field named in `query`. */ +export function styleMatches(style: CellStyle, query: StyleQuery): boolean { + for (const [key, expected] of Object.entries(query)) { + if (expected === undefined) continue; + if (key === 'foreground' || key === 'background') { + if (!colorMatches(style[key], expected as ColorQuery)) return false; + continue; + } + if (style[key as keyof CellStyle] !== expected) return false; + } + return true; +} + +/** True when every non-continuation cell satisfies `query`. */ +export function cellsMatchStyle(cells: readonly ScreenCell[], query: StyleQuery): boolean { + const relevant = cells.filter((cell) => !cell.continuation); + return relevant.length > 0 && relevant.every((cell) => styleMatches(cell.style, query)); +} + +/** Human-readable colour, for assertion diagnostics. */ +export function describeColor(color: TerminalColor | undefined): string { + if (!color) return 'none'; + if (color.kind === 'rgb') return `rgb(${color.red},${color.green},${color.blue})`; + if (color.kind === 'palette') return `palette:${color.index}`; + return 'default'; +} diff --git a/experiments/ghostwright/src/terminal/extensions.ts b/experiments/ghostwright/src/terminal/extensions.ts new file mode 100644 index 0000000..8471592 --- /dev/null +++ b/experiments/ghostwright/src/terminal/extensions.ts @@ -0,0 +1,134 @@ +import { ExtensionOscLimitError } from '../errors.ts'; +import type { OscRegistration, RegisteredOscMessage } from '../types.ts'; + +export interface OscEvent { + registration: OscRegistration; + message: RegisteredOscMessage; +} + +export type OscStreamItem = + | { kind: 'ordinary'; bytes: Uint8Array } + | { kind: 'event'; event: OscEvent } + | { kind: 'error'; error: Error }; + +export interface OscStreamResult { + items: readonly OscStreamItem[]; +} + +function bytes(parts: readonly number[]): Uint8Array { + return Uint8Array.from(parts); +} + +/** + * Ordered byte-stream parser for registered OSC messages. A possible escape + * sequence stays in the current ordinary host-frame buffer until it is proven + * to be a registered OSC, so installing an extension cannot subdivide normal + * CSI/unregistered-OSC output into additional terminal revisions. + */ +export class RegisteredOscStream { + #state: 'normal' | 'escape' | 'osc' | 'discarding' = 'normal'; + #candidate: number[] = []; + #discardPreviousEscape = false; + + constructor(readonly registrations: readonly OscRegistration[]) {} + + push(input: Uint8Array): OscStreamResult { + const items: OscStreamItem[] = []; + let ordinary: number[] = []; + const flush = () => { + if (ordinary.length) items.push({ kind: 'ordinary', bytes: bytes(ordinary) }); + ordinary = []; + }; + const releaseCandidate = () => { + ordinary.push(...this.#candidate); + this.#candidate = []; + this.#state = 'normal'; + }; + for (const byte of input) { + if (this.#state === 'discarding') { + if (byte === 0x07 || (this.#discardPreviousEscape && byte === 0x5c)) { + this.#state = 'normal'; + this.#discardPreviousEscape = false; + } else { + this.#discardPreviousEscape = byte === 0x1b; + } + continue; + } + if (this.#state === 'normal') { + if (byte === 0x1b) { + this.#candidate = [byte]; + this.#state = 'escape'; + } else { + ordinary.push(byte); + } + continue; + } + if (this.#state === 'escape') { + this.#candidate.push(byte); + if (byte === 0x5d) this.#state = 'osc'; + else releaseCandidate(); + continue; + } + + this.#candidate.push(byte); + const candidateText = Buffer.from(this.#candidate).toString('latin1'); + const possible = this.registrations.some((registration) => + `\u001b]${registration.number};${registration.namespace};`.startsWith(candidateText), + ); + const registration = this.registrations.find((entry) => + candidateText.startsWith(`\u001b]${entry.number};${entry.namespace};`), + ); + if (!registration && !possible) { + releaseCandidate(); + continue; + } + if (!registration) continue; + if (this.#candidate.length > registration.maxBufferedBytes) { + // Do not return to ordinary parsing here: every byte through the OSC + // terminator belongs to the rejected registered sequence. + flush(); + items.push({ + kind: 'error', + error: new ExtensionOscLimitError( + `Registered OSC ${registration.number};${registration.namespace} exceeded ${registration.maxBufferedBytes} buffered bytes`, + ), + }); + this.#candidate = []; + this.#state = 'discarding'; + this.#discardPreviousEscape = false; + continue; + } + const length = this.#candidate.length; + const st = + length >= 2 && this.#candidate[length - 2] === 0x1b && this.#candidate[length - 1] === 0x5c; + const bel = byte === 0x07; + if (!st && !bel) continue; + const prefix = `\u001b]${registration.number};${registration.namespace};`; + const body = Buffer.from( + this.#candidate.slice(prefix.length, length - (st ? 2 : 1)), + ).toString('latin1'); + const parts = body.split(';'); + const payload = parts.pop() ?? ''; + // This is the one intentional split: preceding visual bytes must be + // committed before the semantic extension event at this exact boundary. + flush(); + items.push({ + kind: 'event', + event: { + registration, + message: Object.freeze({ + number: registration.number, + namespace: registration.namespace, + parameters: Object.freeze(parts), + payload: Buffer.from(payload, 'latin1'), + terminator: st ? 'ST' : 'BEL', + }), + }, + }); + this.#candidate = []; + this.#state = 'normal'; + } + flush(); + return { items }; + } +} diff --git a/experiments/ghostwright/src/terminal/session.ts b/experiments/ghostwright/src/terminal/session.ts index 1b28ac2..a86e007 100644 --- a/experiments/ghostwright/src/terminal/session.ts +++ b/experiments/ghostwright/src/terminal/session.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path'; import { CoordinateRangeError, DenoPermissionError, + ExtensionDuplicateError, GhostwrightError, HistoryChangedError, HistoryEvictedError, @@ -22,6 +23,9 @@ import { import { FrameKind } from '../pty/protocol.ts'; import { SidecarClient } from '../pty/client.ts'; import { SessionTrace } from '../tracing/trace.ts'; +import { parseKey } from '../keys.ts'; +import { cellsMatchStyle } from '../styles.ts'; +import { DEFAULT_ASSERTION_TIMEOUT_MS } from '../types.ts'; import type { ActionReceipt, AsyncLocator, @@ -50,10 +54,16 @@ import type { ScreenSnapshot, TerminalLaunchOptions, TextLocatorOptions, + TerminalExtensionDefinition, + ExtensionCommit, + ExtensionRevision, + ExtensionSessionContext, + RegisteredOscMessage, TraceableInputOptions, Viewport, WheelOptions, } from '../types.ts'; +import { RegisteredOscStream } from './extensions.ts'; import { GhosttyWasmTerminal } from './wasm.ts'; function concatBytes(parts: readonly Uint8Array[]) { const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); @@ -99,9 +109,42 @@ export class TerminalSession implements AsyncTerminal { #trace: SessionTrace; #viewport; #fatalError?: Error; + #extensions = new Map< + string, + { + definition: TerminalExtensionDefinition; + session: unknown; + revisions: ExtensionRevision[]; + sequence: number; + } + >(); + #osc?: RegisteredOscStream; #exitResolve!: (s: ProcessStatus) => void; #exitPromise: Promise; private constructor(readonly options: TerminalLaunchOptions) { + const extensions = options.extensions ?? []; + const identities = new Set(); + for (const definition of extensions) { + const identity = `${definition.id}:${definition.osc?.number ?? ''}:${definition.osc?.namespace ?? ''}`; + if (this.#extensions.has(definition.id) || identities.has(identity)) + throw new ExtensionDuplicateError(`Duplicate extension registration ${definition.id}`); + identities.add(identity); + this.#extensions.set(definition.id, { + definition, + session: undefined, + revisions: [], + sequence: 0, + }); + } + const registrations = extensions.flatMap((definition) => + definition.osc ? [definition.osc] : [], + ); + const oscKeys = new Set( + registrations.map((registration) => `${registration.number};${registration.namespace}`), + ); + if (oscKeys.size !== registrations.length) + throw new ExtensionDuplicateError('Duplicate registered OSC number and namespace'); + this.#osc = registrations.length ? new RegisteredOscStream(registrations) : undefined; this.#viewport = normalizeViewport(options.viewport); const t = typeof options.trace === 'string' @@ -167,6 +210,7 @@ export class TerminalSession implements AsyncTerminal { options.graphics?.storageLimitBytes ?? 64 * 1024 * 1024, ); self.#snapshot = self.#engine.snapshot(); + self.#initializeExtensions(); self.#trace.add('kitty-capability', { supported: self.#snapshot.graphics.supported, storageLimitBytes: self.#snapshot.graphics.storageLimitBytes, @@ -253,6 +297,87 @@ export class TerminalSession implements AsyncTerminal { get lastAction() { return this.#lastAction; } + extension(definition: TerminalExtensionDefinition): T { + const registered = this.#extensions.get(definition.id); + if (!registered || registered.definition !== definition) + throw new GhostwrightError({ + code: 'GW_EXTENSION_NOT_REGISTERED', + message: `Extension ${definition.id} was not registered for this terminal`, + }); + return registered.session as T; + } + #initializeExtensions() { + for (const [id, record] of this.#extensions) { + const context = this.#extensionContext(id); + record.session = record.definition.createSession(context); + } + } + #extensionContext(id: string): ExtensionSessionContext { + return Object.freeze({ + terminal: this, + screen: this.screen, + publish: (commit: ExtensionCommit) => this.#publishExtension(id, commit), + diagnostic: (error: GhostwrightError) => { + this.#trace.add('extension-diagnostic', { + extensionId: id, + code: error.code, + message: error.message.slice(0, 1024), + }); + }, + }); + } + #publishExtension(id: string, commit: ExtensionCommit): ExtensionRevision { + const record = this.#extensions.get(id); + if (!record) + throw new GhostwrightError({ + code: 'GW_EXTENSION_NOT_REGISTERED', + message: `Unknown extension ${id}`, + }); + const revision = Object.freeze({ + sequence: ++record.sequence, + timestamp: this.#engine.now(), + extensionId: id, + protocolFrame: commit.protocolFrame, + screenSequence: this.#snapshot.sequence, + value: commit.value, + }); + record.revisions.push(revision); + this.#trace.add('extension-revision', { + extensionId: id, + sequence: revision.sequence, + protocolFrame: revision.protocolFrame, + screenSequence: revision.screenSequence, + }); + this.#notify(); + return revision; + } + #acceptOsc( + registration: TerminalExtensionDefinition['osc'], + message: RegisteredOscMessage, + ) { + if (!registration) return; + const record = [...this.#extensions.values()].find( + (candidate) => candidate.definition.osc === registration, + ); + if (!record) return; + const context = this.#extensionContext(record.definition.id); + try { + const commit = registration.decode(message); + record.definition.accept?.(record.session, commit, context); + } catch (cause) { + const error = + cause instanceof GhostwrightError + ? cause + : new GhostwrightError({ + code: 'GW_EXTENSION_OSC', + message: + cause instanceof Error + ? cause.message.slice(0, 1024) + : 'Extension OSC decode failed', + }); + context.diagnostic(error); + } + } now() { return this.#engine.now(); } @@ -269,11 +394,24 @@ export class TerminalSession implements AsyncTerminal { 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); + const parsed = this.#osc?.push(bytes) ?? { items: [{ kind: 'ordinary' as const, bytes }] }; + for (const item of parsed.items) { + if (item.kind === 'ordinary') { + if (!item.bytes.length) continue; + this.#engine.write(item.bytes); + // Publish before a following OSC commit so its screen association is the + // exact state produced by preceding bytes in the same PTY host frame. + this.#terminalHistoryGeneration++; + this.#publish('pty-output', sourceFrameSequence); + } else if (item.kind === 'event') { + this.#acceptOsc(item.event.registration, item.event.message); + } else { + this.#trace.add('extension-diagnostic', { + code: item.error instanceof GhostwrightError ? item.error.code : 'GW_EXTENSION_OSC', + message: item.error.message.slice(0, 1024), + }); + } + } for (const effect of this.#engine.takeEffects()) { this.#trace.add('terminal-effect', { effect: effect.type, @@ -419,7 +557,7 @@ export class TerminalSession implements AsyncTerminal { return receipt; } keyboard = { - press: async (key: KeyName | KeyPress) => this.#write(this.#engine.encodeKey(key)), + press: async (key: KeyName | KeyPress) => this.#write(this.#engine.encodeKey(parseKey(key))), type: async (text: string, options?: TraceableInputOptions) => this.#write( concatBytes(Array.from(text, (key) => this.#engine.encodeKey(key))), @@ -501,7 +639,7 @@ export class TerminalSession implements AsyncTerminal { waitForExit: async (options?: { timeoutMs?: number }) => this.#timeout( this.#exitPromise, - options?.timeoutMs ?? this.options.assertionTimeoutMs ?? 5000, + options?.timeoutMs ?? this.options.assertionTimeoutMs ?? DEFAULT_ASSERTION_TIMEOUT_MS, () => new ProcessExitedError('Timed out waiting for process exit'), ), }; @@ -681,7 +819,8 @@ export class TerminalSession implements AsyncTerminal { const baseline = this.#baselineSequence(options.since), max = options.maxRevisions ?? 1000, configuredMax = this.options.history?.maxRevisions ?? 1000, - timeout = options.timeoutMs ?? this.options.assertionTimeoutMs ?? 5000, + timeout = + options.timeoutMs ?? this.options.assertionTimeoutMs ?? DEFAULT_ASSERTION_TIMEOUT_MS, startedAt = this.now(); if (!Number.isSafeInteger(max) || max <= 0 || max > configuredMax) throw new CoordinateRangeError( @@ -852,10 +991,19 @@ export class TerminalSession implements AsyncTerminal { } screen: ScreenReader = { current: () => this.#snapshot, + snapshot: () => this.#snapshot, getCell: (p: Point) => { this.#point(p); return this.#snapshot.lines[p.row].cells[p.column]; }, + getCells: (r: Rect) => { + this.#rect(r); + return Object.freeze( + this.#snapshot.lines + .slice(r.row, r.row + r.height) + .flatMap((line) => line.cells.slice(r.column, r.column + r.width)), + ); + }, getText: (r?: Rect) => { if (!r) return this.#snapshot.lines.map((l) => l.text).join('\n'); this.#rect(r); @@ -948,22 +1096,39 @@ export class Locator implements AsyncLocator { 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 }; }; + // Cells backing a match, so callers can inspect styles without + // re-deriving geometry from the raw snapshot. + const cellsFor = (from: number, to: number): readonly ScreenCell[] => + Object.freeze( + segments + .filter((segment) => from < segment.end && to > segment.start) + .map((segment) => segment.cell), + ); + const accept = (cells: readonly ScreenCell[]): boolean => + !this.options.style || cellsMatchStyle(cells, this.options.style); if (this.options.exact) { const trimmed = row.replace(/ +$/g, ''); - if (trimmed === this.query) - out.push({ - text: trimmed, - rowText: row, - range: rangeFor(0, trimmed.length), - }); + if (trimmed === this.query) { + const cells = cellsFor(0, trimmed.length); + if (accept(cells)) + out.push({ + text: trimmed, + rowText: row, + range: rangeFor(0, trimmed.length), + cells, + }); + } } 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), - }); + const cells = cellsFor(at, at + this.query.length); + if (accept(cells)) + out.push({ + text: this.query, + rowText: row, + range: rangeFor(at, at + this.query.length), + cells, + }); at += Math.max(1, this.query.length); } } @@ -971,7 +1136,7 @@ export class Locator implements AsyncLocator { const chosen = this.index === undefined ? out : out[this.index] ? [out[this.index]] : []; return Object.freeze(chosen); } - async unique(timeout = this.session.options.assertionTimeoutMs ?? 5000) { + async unique(timeout = this.session.options.assertionTimeoutMs ?? DEFAULT_ASSERTION_TIMEOUT_MS) { const get = () => this.matches(); let m = get(); if (m.length > 1) diff --git a/experiments/ghostwright/src/terminal/wasm.ts b/experiments/ghostwright/src/terminal/wasm.ts index 1a0760d..8de4a3e 100644 --- a/experiments/ghostwright/src/terminal/wasm.ts +++ b/experiments/ghostwright/src/terminal/wasm.ts @@ -1,5 +1,6 @@ import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; +import { FUNCTIONAL_KEYS } from '../keys.ts'; import type { CellStyle, KittyGraphicsSnapshot, @@ -1217,21 +1218,7 @@ export class GhosttyWasmTerminal { 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, - }; + functional: Record = FUNCTIONAL_KEYS; let key = functional[name] ?? 0, text = ''; const functionMatch = /^F(\d+)$/.exec(name); diff --git a/experiments/ghostwright/src/types.ts b/experiments/ghostwright/src/types.ts index c179993..b2eefe2 100644 --- a/experiments/ghostwright/src/types.ts +++ b/experiments/ghostwright/src/types.ts @@ -1,4 +1,5 @@ import type { Operation } from 'effection'; +import type { GhostwrightError } from './errors.ts'; export interface Viewport { columns: number; @@ -27,6 +28,51 @@ export interface TraceOptions { directory?: string; redactArgumentIndexes?: readonly number[]; } +export interface RegisteredOscMessage { + number: number; + namespace: string; + parameters: readonly string[]; + payload: Uint8Array; + terminator: 'ST' | 'BEL'; +} + +/** A framework-neutral ordered OSC extension registration. */ +export interface OscRegistration { + number: number; + namespace: string; + /** Maximum bytes retained for an incomplete registered sequence. */ + maxBufferedBytes: number; + decode(message: RegisteredOscMessage): TCommit; +} + +export interface ExtensionRevision { + sequence: number; + timestamp: number; + extensionId: string; + protocolFrame: number; + screenSequence: number; + value: T; +} + +export interface ExtensionCommit { + protocolFrame: number; + value: T; +} + +export interface ExtensionSessionContext { + readonly terminal: AsyncTerminal; + readonly screen: ScreenReader; + publish(commit: ExtensionCommit): ExtensionRevision; + diagnostic(error: GhostwrightError): void; +} + +export interface TerminalExtensionDefinition { + readonly id: string; + readonly osc?: OscRegistration; + createSession(context: ExtensionSessionContext): TSession; + accept?(session: TSession, commit: TCommit, context: ExtensionSessionContext): void; +} + export interface TerminalLaunchOptions { command: string; args?: readonly string[]; @@ -41,6 +87,8 @@ export interface TerminalLaunchOptions { graphics?: GraphicsOptions; trace?: TracePolicy | TraceOptions; name?: string; + /** Optional framework-specific extensions receiving ordered in-band OSC commits. */ + extensions?: readonly TerminalExtensionDefinition[]; } export interface Point { column: number; @@ -57,6 +105,14 @@ export interface ActionReceipt { deliveredToChild: boolean; bytesWritten: number; } +/** + * Default assertion timeout. + * + * Deliberately below the 5000 ms default of Bun, Jest, and Vitest: if the two + * are equal the runner's timeout wins the race and reports a bare "timed out" + * instead of Ghostwright's screen diagnostic. + */ +export const DEFAULT_ASSERTION_TIMEOUT_MS = 4000; export type KeyName = | 'Enter' | 'Tab' @@ -105,11 +161,38 @@ export interface TransientAssertionOptions extends AssertionOptions { } export interface TextLocatorOptions { exact?: boolean; + /** + * Only match text whose cells all satisfy this style. Useful for + * disambiguating the same string rendered in different states, such as a + * focused versus unfocused label. + */ + style?: StyleQuery; +} +/** + * A colour to match against. Accepts the structured {@link TerminalColor} form + * or the shorthands `'#rrggbb'`, `'rgb(r,g,b)'`, `'default'`, and `'palette:N'`. + */ +export type ColorQuery = TerminalColor | string; +/** A partial {@link CellStyle} to match against. Omitted fields are ignored. */ +export interface StyleQuery { + bold?: boolean; + italic?: boolean; + faint?: boolean; + blink?: boolean; + inverse?: boolean; + invisible?: boolean; + strikethrough?: boolean; + overline?: boolean; + underline?: number; + foreground?: ColorQuery; + background?: ColorQuery; } export interface LocatorMatch { text: string; range: Rect; rowText: string; + /** The cells backing this match, in column order, for style inspection. */ + cells: readonly ScreenCell[]; } export interface ProcessStatus { state: 'starting' | 'running' | 'exited' | 'closed' | 'failed'; @@ -282,7 +365,11 @@ export interface CellChange { } export interface ScreenReader { current(): ScreenSnapshot; + /** Alias of {@link ScreenReader.current}, matching `AsyncRegion.snapshot()`. */ + snapshot(): ScreenSnapshot; getCell(point: Point): ScreenCell; + /** Every cell inside `rect`, row-major, for style and border inspection. */ + getCells(rect: Rect): readonly ScreenCell[]; getText(rect?: Rect): string; changedCells(since: ScreenSnapshot | number): readonly CellChange[]; rawOutput(): Uint8Array; @@ -337,6 +424,8 @@ export interface OperationRegion { snapshot(): ScreenSnapshot; } export interface AsyncTerminal { + /** Return the session instance for a registered extension definition. */ + extension(definition: TerminalExtensionDefinition): T; readonly keyboard: { press(key: KeyName | KeyPress): Promise; type(text: string, options?: TraceableInputOptions): Promise; @@ -403,11 +492,19 @@ export interface OperationLocatorExpectation { toBePresent(options?: AssertionOptions): Operation; toBeAbsent(options?: StableAssertionOptions): Operation; toBeStable(options?: StableAssertionOptions): Operation; + /** Every cell of the match must satisfy `style`. */ + toHaveStyle(style: StyleQuery, options?: AssertionOptions): Operation; + /** The terminal cursor must sit inside the match's range. */ + toContainCursor(options?: AssertionOptions): Operation; } export interface AsyncLocatorExpectation { toBePresent(options?: AssertionOptions): Promise; toBeAbsent(options?: StableAssertionOptions): Promise; toBeStable(options?: StableAssertionOptions): Promise; + /** Every cell of the match must satisfy `style`. */ + toHaveStyle(style: StyleQuery, options?: AssertionOptions): Promise; + /** The terminal cursor must sit inside the match's range. */ + toContainCursor(options?: AssertionOptions): Promise; } export interface OperationTerminalExpectation { toSatisfy( diff --git a/experiments/ghostwright/test/extensions.test.ts b/experiments/ghostwright/test/extensions.test.ts new file mode 100644 index 0000000..24111b6 --- /dev/null +++ b/experiments/ghostwright/test/extensions.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from 'bun:test'; +import { RegisteredOscStream } from '../src/terminal/extensions.ts'; +import type { OscRegistration } from '../src/types.ts'; + +const registration: OscRegistration = { + number: 7777, + namespace: 'test.semantic', + maxBufferedBytes: 1024, + decode: (message) => new TextDecoder().decode(message.payload), +}; +const frame = new TextEncoder().encode('\u001b]7777;test.semantic;v=1;payload\u001b\\'); + +function events(items: ReturnType['items']) { + return items.filter((item) => item.kind === 'event'); +} + +test('registered OSC accepts every PTY split boundary without partial commits', () => { + for (let split = 1; split < frame.length; split++) { + const stream = new RegisteredOscStream([registration]); + expect(events(stream.push(frame.slice(0, split)).items)).toHaveLength(0); + const committed = events(stream.push(frame.slice(split)).items); + expect(committed).toHaveLength(1); + const event = committed[0]; + if (event?.kind === 'event') { + expect(event.event.message.parameters).toEqual(['v=1']); + expect(new TextDecoder().decode(event.event.message.payload)).toBe('payload'); + } + } +}); + +test('registered OSC retains visual/commit ordering inside one PTY host frame', () => { + const stream = new RegisteredOscStream([registration]); + const input = new TextEncoder().encode(`before${new TextDecoder().decode(frame)}after`); + const items = stream.push(input).items; + expect(items.map((item) => item.kind)).toEqual(['ordinary', 'event', 'ordinary']); + expect(new TextDecoder().decode((items[0] as { bytes: Uint8Array }).bytes)).toBe('before'); + expect(new TextDecoder().decode((items[2] as { bytes: Uint8Array }).bytes)).toBe('after'); +}); + +test('oversized registered OSC discards its complete payload through ST', () => { + const stream = new RegisteredOscStream([{ ...registration, maxBufferedBytes: 24 }]); + const items = stream.push( + new TextEncoder().encode( + '\u001b]7777;test.semantic;v=1;THIS_SHOULD_NOT_REACH_TERMINAL\u001b\\VISIBLE', + ), + ).items; + expect(items.map((item) => item.kind)).toEqual(['error', 'ordinary']); + expect(new TextDecoder().decode((items[1] as { bytes: Uint8Array }).bytes)).toBe('VISIBLE'); +}); + +test('ordinary ANSI output remains one ordinary host-frame item', () => { + const stream = new RegisteredOscStream([registration]); + const items = stream.push(new TextEncoder().encode('a\u001b[31mb')).items; + expect(items).toHaveLength(1); + expect(items[0]?.kind).toBe('ordinary'); + if (items[0]?.kind === 'ordinary') + expect(new TextDecoder().decode(items[0].bytes)).toBe('a\u001b[31mb'); +}); diff --git a/experiments/ghostwright/test/keys.test.ts b/experiments/ghostwright/test/keys.test.ts new file mode 100644 index 0000000..1876973 --- /dev/null +++ b/experiments/ghostwright/test/keys.test.ts @@ -0,0 +1,68 @@ +import { expect, test } from 'bun:test'; +import { InvalidKeyError, isValidKeyName, parseKey } from '../src/index.ts'; + +test('parseKey accepts functional key names', () => { + expect(parseKey('Tab')).toEqual({ key: 'Tab' }); + expect(parseKey('Enter')).toEqual({ key: 'Enter' }); + expect(parseKey('ArrowLeft')).toEqual({ key: 'ArrowLeft' }); +}); + +test('parseKey accepts single characters and function keys', () => { + expect(parseKey('a')).toEqual({ key: 'a' }); + expect(parseKey('+')).toEqual({ key: '+' }); + expect(parseKey('F12')).toEqual({ key: 'F12' }); +}); + +test('parseKey understands modifier combinations', () => { + expect(parseKey('Shift+Tab')).toEqual({ key: 'Tab', shift: true }); + expect(parseKey('Ctrl+A')).toEqual({ key: 'A', control: true }); + expect(parseKey('Control+A')).toEqual({ key: 'A', control: true }); + expect(parseKey('Alt+x')).toEqual({ key: 'x', alt: true }); + expect(parseKey('Cmd+k')).toEqual({ key: 'k', super: true }); + expect(parseKey('Ctrl+Shift+Home')).toEqual({ key: 'Home', control: true, shift: true }); +}); + +test('parseKey is case insensitive for modifiers only', () => { + expect(parseKey('shift+Tab')).toEqual({ key: 'Tab', shift: true }); + // The key itself keeps its case, since case is significant for characters. + expect(parseKey('Shift+a')).toEqual({ key: 'a', shift: true }); +}); + +test('parseKey keeps a trailing plus as the key', () => { + expect(parseKey('Ctrl++')).toEqual({ key: '+', control: true }); +}); + +test('parseKey rejects unknown key names instead of silently encoding nothing', () => { + expect(() => parseKey('Retrun')).toThrow(InvalidKeyError); + expect(() => parseKey('Shift+Nope')).toThrow(InvalidKeyError); + expect(() => parseKey('F99')).toThrow(InvalidKeyError); +}); + +test('parseKey error names the valid options', () => { + try { + parseKey('Retrun'); + expect.unreachable('should have thrown'); + } catch (error) { + expect(error).toBeInstanceOf(InvalidKeyError); + expect((error as InvalidKeyError).code).toBe('GW_INVALID_KEY'); + expect((error as Error).message).toContain('Enter'); + expect((error as Error).message).toContain('single character'); + } +}); + +test('parseKey passes through and validates the object form', () => { + expect(parseKey({ key: 'Tab', shift: true })).toEqual({ key: 'Tab', shift: true }); + expect(() => parseKey({ key: 'Nope' })).toThrow(InvalidKeyError); + expect(() => parseKey({} as never)).toThrow(InvalidKeyError); +}); + +test('isValidKeyName covers the encodable set', () => { + expect(isValidKeyName('Tab')).toBe(true); + expect(isValidKeyName('F1')).toBe(true); + expect(isValidKeyName('F25')).toBe(true); + expect(isValidKeyName('F26')).toBe(false); + expect(isValidKeyName('F0')).toBe(false); + expect(isValidKeyName('é')).toBe(true); + expect(isValidKeyName('ab')).toBe(false); + expect(isValidKeyName('')).toBe(false); +}); diff --git a/experiments/ghostwright/test/locator-style.test.ts b/experiments/ghostwright/test/locator-style.test.ts new file mode 100644 index 0000000..ed8c4d7 --- /dev/null +++ b/experiments/ghostwright/test/locator-style.test.ts @@ -0,0 +1,153 @@ +import { expect, test } from 'bun:test'; +import { + DEFAULT_ASSERTION_TIMEOUT_MS, + expectTerminal, + InvalidKeyError, + TerminalAssertionError, + withTerminalAsync, +} from '../src/index.ts'; + +/** Emits red "ALERT", plain "READY", then parks the cursor on a known cell. */ +const coloured = { + command: '/bin/sh', + args: [ + '-c', + `printf '\\033[38;2;255;0;0mALERT\\033[0m\\r\\nREADY\\r\\n'; printf '\\033[1;1H'; sleep 30`, + ], + viewport: { columns: 40, rows: 6 }, + trace: 'off' as const, +}; + +test('toHaveStyle matches a foreground colour', async () => { + await withTerminalAsync(coloured, async (terminal) => { + await expectTerminal(terminal.getByText('ALERT')).toHaveStyle({ foreground: 'rgb(255,0,0)' }); + await expectTerminal(terminal.getByText('ALERT')).toHaveStyle({ foreground: '#ff0000' }); + }); +}); + +test('toHaveStyle fails when the colour differs', async () => { + await withTerminalAsync(coloured, async (terminal) => { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + let error: unknown; + try { + await expectTerminal(terminal.getByText('READY')).toHaveStyle( + { foreground: 'rgb(255,0,0)' }, + { timeoutMs: 400 }, + ); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(TerminalAssertionError); + // The diagnostic reports what the style actually was. + expect((error as Error).message).toContain('actual style:'); + expect((error as Error).message).toContain('foreground='); + }); +}); + +test('style-filtered locators disambiguate identical text', async () => { + await withTerminalAsync( + { + command: '/bin/sh', + args: ['-c', `printf '\\033[38;2;255;0;0mSAVE\\033[0m\\r\\nSAVE\\r\\n'; sleep 30`], + viewport: { columns: 40, rows: 6 }, + trace: 'off' as const, + }, + async (terminal) => { + // Unfiltered the locator is ambiguous; the colour filter makes it unique. + const red = terminal.getByText('SAVE', { style: { foreground: 'rgb(255,0,0)' } }); + const match = await expectTerminal(red).toBePresent(); + expect(match.range.row).toBe(0); + expect(terminal.getByText('SAVE').matches().length).toBe(2); + expect(red.matches().length).toBe(1); + }, + ); +}); + +test('toContainCursor tracks where the terminal cursor sits', async () => { + await withTerminalAsync(coloured, async (terminal) => { + // The trailing escape parks the cursor at row 0, column 0, inside "ALERT". + await expectTerminal(terminal.getByText('ALERT')).toContainCursor(); + + let error: unknown; + try { + await expectTerminal(terminal.getByText('READY')).toContainCursor({ timeoutMs: 400 }); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(TerminalAssertionError); + expect((error as Error).message).toContain('to contain the cursor'); + }); +}); + +test('locator matches expose their backing cells', async () => { + await withTerminalAsync(coloured, async (terminal) => { + const match = await expectTerminal(terminal.getByText('ALERT')).toBePresent(); + expect(match.cells.length).toBe(5); + expect(match.cells.map((cell) => cell.text).join('')).toBe('ALERT'); + expect(match.cells[0].style.foreground).toEqual({ kind: 'rgb', red: 255, green: 0, blue: 0 }); + }); +}); + +test('screen.getCells returns a rectangle of cells', async () => { + await withTerminalAsync(coloured, async (terminal) => { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + const cells = terminal.screen.getCells({ column: 0, row: 0, width: 5, height: 1 }); + expect(cells.map((cell) => cell.text).join('')).toBe('ALERT'); + expect(terminal.screen.getCells({ column: 0, row: 0, width: 5, height: 2 }).length).toBe(10); + }); +}); + +test('screen.snapshot aliases screen.current', async () => { + await withTerminalAsync(coloured, async (terminal) => { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + expect(terminal.screen.snapshot()).toBe(terminal.screen.current()); + }); +}); + +test('a throwing predicate counts as unsatisfied and is reported', async () => { + // oxlint-disable bombshell-dev/no-generic-error -- throwing a plain Error is the behaviour under test + await withTerminalAsync(coloured, async (terminal) => { + // Converges even though early revisions make the predicate throw. + await expectTerminal(terminal).toSatisfy((snapshot) => { + if (!snapshot.lines.some((line) => line.text.includes('READY'))) + throw new Error('not painted yet'); + return true; + }); + + let error: unknown; + try { + await expectTerminal(terminal).toSatisfy( + () => { + throw new Error('always broken'); + }, + { timeoutMs: 400 }, + ); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(TerminalAssertionError); + expect((error as Error).message).toContain('predicate threw'); + expect((error as Error).message).toContain('always broken'); + }); + // oxlint-enable bombshell-dev/no-generic-error +}); + +test('unknown key names fail fast instead of timing out', async () => { + await withTerminalAsync(coloured, async (terminal) => { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + expect(terminal.keyboard.press('Retrun')).rejects.toBeInstanceOf(InvalidKeyError); + }); +}); + +test('modifier combinations are accepted by press', async () => { + await withTerminalAsync(coloured, async (terminal) => { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + // Shift+Tab used to encode nothing at all and surface as a timeout. + const receipt = await terminal.keyboard.press('Shift+Tab'); + expect(receipt.bytesWritten).toBeGreaterThan(0); + }); +}); + +test('the default assertion timeout stays below common runner defaults', () => { + expect(DEFAULT_ASSERTION_TIMEOUT_MS).toBeLessThan(5000); +}); diff --git a/experiments/ghostwright/test/styles.test.ts b/experiments/ghostwright/test/styles.test.ts new file mode 100644 index 0000000..2de156f --- /dev/null +++ b/experiments/ghostwright/test/styles.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from 'bun:test'; +import { cellsMatchStyle, describeColor, styleMatches } from '../src/index.ts'; +import type { CellStyle, ScreenCell } from '../src/index.ts'; + +const style = (overrides: Partial = {}): CellStyle => ({ + bold: false, + italic: false, + faint: false, + blink: false, + inverse: false, + invisible: false, + strikethrough: false, + overline: false, + underline: 0, + foreground: { kind: 'rgb', red: 255, green: 255, blue: 255 }, + background: { kind: 'default' }, + ...overrides, +}); + +const cell = (overrides: Partial = {}): ScreenCell => ({ + column: 0, + text: 'x', + width: 1, + continuation: false, + style: style(), + selected: false, + ...overrides, +}); + +test('styleMatches ignores fields the query omits', () => { + expect(styleMatches(style({ bold: true }), {})).toBe(true); + expect(styleMatches(style({ bold: true }), { bold: true })).toBe(true); + expect(styleMatches(style({ bold: true }), { bold: false })).toBe(false); +}); + +test('styleMatches compares boolean and numeric attributes', () => { + expect(styleMatches(style({ inverse: true }), { inverse: true })).toBe(true); + expect(styleMatches(style({ underline: 2 }), { underline: 2 })).toBe(true); + expect(styleMatches(style({ underline: 2 }), { underline: 1 })).toBe(false); +}); + +test('styleMatches accepts structured colours', () => { + expect( + styleMatches(style(), { foreground: { kind: 'rgb', red: 255, green: 255, blue: 255 } }), + ).toBe(true); + expect(styleMatches(style(), { foreground: { kind: 'rgb', red: 1, green: 2, blue: 3 } })).toBe( + false, + ); +}); + +test('styleMatches accepts colour shorthands', () => { + expect(styleMatches(style(), { foreground: '#ffffff' })).toBe(true); + expect(styleMatches(style(), { foreground: 'ffffff' })).toBe(true); + expect(styleMatches(style(), { foreground: 'rgb(255,255,255)' })).toBe(true); + expect(styleMatches(style(), { foreground: 'rgb(255, 255, 255)' })).toBe(true); + expect(styleMatches(style(), { foreground: '#000000' })).toBe(false); + expect(styleMatches(style(), { background: 'default' })).toBe(true); +}); + +test('styleMatches handles palette colours', () => { + const paletted = style({ foreground: { kind: 'palette', index: 4 } }); + expect(styleMatches(paletted, { foreground: 'palette:4' })).toBe(true); + expect(styleMatches(paletted, { foreground: 'palette:5' })).toBe(false); + expect(styleMatches(paletted, { foreground: '#ffffff' })).toBe(false); +}); + +test('styleMatches rejects unparseable colour queries rather than matching loosely', () => { + expect(styleMatches(style(), { foreground: 'chartreuse' })).toBe(false); +}); + +test('cellsMatchStyle requires every cell to match', () => { + const white = cell(), + black = cell({ style: style({ foreground: { kind: 'rgb', red: 0, green: 0, blue: 0 } }) }); + expect(cellsMatchStyle([white, white], { foreground: '#ffffff' })).toBe(true); + expect(cellsMatchStyle([white, black], { foreground: '#ffffff' })).toBe(false); +}); + +test('cellsMatchStyle ignores continuation cells but needs at least one real cell', () => { + const wide = cell({ width: 2 }), + continuation = cell({ continuation: true, style: style({ bold: true }) }); + expect(cellsMatchStyle([wide, continuation], { bold: false })).toBe(true); + expect(cellsMatchStyle([continuation], { bold: true })).toBe(false); + expect(cellsMatchStyle([], { bold: false })).toBe(false); +}); + +test('describeColor renders each colour kind', () => { + expect(describeColor({ kind: 'rgb', red: 1, green: 2, blue: 3 })).toBe('rgb(1,2,3)'); + expect(describeColor({ kind: 'palette', index: 7 })).toBe('palette:7'); + expect(describeColor({ kind: 'default' })).toBe('default'); + expect(describeColor(undefined)).toBe('none'); +}); diff --git a/package.json b/package.json index b7ba1a7..9ef9b30 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "format": "bsh format", "format:check": "bsh format --check", "lint": "bsh lint .", - "test": "bsh test --exclude 'experiments/ghostwright/**'" + "test": "bsh test --exclude 'experiments/ghostwright/**'", + "demo": "node scripts/demo.mjs" }, "devDependencies": { "@bomb.sh/args": "catalog:", diff --git a/packages/demo/package.json b/packages/demo/package.json new file mode 100644 index 0000000..8601d1f --- /dev/null +++ b/packages/demo/package.json @@ -0,0 +1,56 @@ +{ + "name": "focus-demo", + "version": "0.0.0", + "private": true, + "type": "module", + "license": "MIT", + "author": { + "name": "Bombshell", + "email": "oss@bomb.sh", + "url": "https://bomb.sh" + }, + "exports": { + ".": { + "import": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "dev": "bsh dev", + "build": "bsh build", + "format": "bsh format", + "lint": "bsh lint", + "test": "bsh test" + }, + "dependencies": { + "@bomb.sh/freedom": "workspace:*", + "@bomb.sh/input": "workspace:^", + "@ghostwright/freedom-tty": "workspace:*", + "@bomb.sh/tty": "https://pkg.pr.new/@bomb.sh/tty@103", + "@types/node": "^26.0.0", + "effection": "4.1.0-alpha.9" + }, + "devDependencies": { + "@bomb.sh/tools": "latest", + "css-select": "^7.0.0", + "css-what": "^8.0.0", + "ghostwright": "workspace:*" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/bombshell-dev/playground.git", + "directory": "packages/demo" + }, + "devEngines": { + "runtime": { + "name": "node", + "version": "22.14.0", + "onFail": "error" + }, + "packageManager": { + "name": "pnpm", + "version": "10.7.0", + "onFail": "error" + } + } +} diff --git a/packages/demo/src/clay-geometry-spike.ts b/packages/demo/src/clay-geometry-spike.ts new file mode 100644 index 0000000..c877599 --- /dev/null +++ b/packages/demo/src/clay-geometry-spike.ts @@ -0,0 +1,142 @@ +import { close, createTerm, fixed, grow, open, rgba, text, type Op } from '@bomb.sh/tty'; +import { stdout } from 'node:process'; + +const mode = process.argv[2] ?? 'offset'; + +interface ElementMetadata { + id: string; + rect: [x: number, y: number, width: number, height: number]; +} + +class GeometrySpikeError extends Error { + readonly code = 'CLAY_GEOMETRY_SPIKE'; +} + +function osc(payload: unknown): Uint8Array { + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url'); + return Buffer.from(`\u001b]7777;ghostwright.clay;v=1;${encoded}\u001b\\`); +} + +function metadata( + info: { + get( + id: string, + ): { bounds: { x: number; y: number; width: number; height: number } } | undefined; + }, + ids: string[], +): ElementMetadata[] { + return ids.map((id) => { + const element = info.get(id); + if (!element) throw new GeometrySpikeError(`Clay did not retain ${id}`); + const { x, y, width, height } = element.bounds; + return { id, rect: [x, y, width, height] }; + }); +} + +let width: number, + height: number, + renderRow = 1, + ids: string[], + ops: Op[]; + +switch (mode) { + case 'offset': + width = 20; + height = 5; + renderRow = 6; + ids = ['offset-target']; + ops = [ + open('root', { + layout: { width: grow(), height: grow(), alignX: 'center', alignY: 'center' }, + }), + open('offset-target', { + border: { color: rgba(255, 255, 255), top: 1, right: 1, bottom: 1, left: 1 }, + layout: { + width: fixed(10), + height: fixed(3), + padding: { left: 1, right: 1 }, + }, + }), + text('Offset'), + close(), + close(), + ]; + break; + case 'fractional': + width = 20; + height = 10; + ids = ['fractional-target']; + ops = [ + open('root', { + layout: { width: grow(), height: grow(), alignX: 'center', alignY: 'center' }, + }), + open('fractional-target', { + bg: rgba(0, 255, 0), + layout: { width: fixed(5), height: fixed(3) }, + }), + close(), + close(), + ]; + break; + case 'clipped': + width = 30; + height = 10; + ids = ['clipper', 'oversized-target']; + ops = [ + open('root', { + layout: { width: grow(), height: grow(), alignX: 'center', alignY: 'center' }, + }), + open('clipper', { + clip: { horizontal: true, vertical: true }, + layout: { width: fixed(12), height: fixed(5) }, + }), + open('oversized-target', { + bg: rgba(255, 0, 0), + layout: { width: fixed(20), height: fixed(7) }, + }), + close(), + close(), + close(), + ]; + break; + case 'offscreen': + width = 30; + height = 10; + ids = ['offscreen-target']; + ops = [ + open('root', { + layout: { width: grow(), height: grow(), alignX: 'center', alignY: 'center' }, + }), + open('offscreen-target', { + bg: rgba(0, 0, 255), + floating: { + x: -5, + y: 3.5, + attachTo: 'root', + attachPoints: { element: 'left-top', parent: 'left-top' }, + zIndex: 1, + }, + layout: { width: fixed(40), height: fixed(3) }, + }), + close(), + close(), + ]; + break; + default: + throw new GeometrySpikeError(`Unknown geometry mode ${JSON.stringify(mode)}`); +} + +const term = await createTerm({ width, height }), + result = term.render(ops, { row: renderRow }), + frame = { + version: 1, + frame: 1, + mode, + renderSurface: { width, height, row: renderRow }, + elements: metadata(result.info, ids), + }; + +// Metadata describes the following visual bytes. This remains correct when the +// PTY splits the logical write into multiple host frames. +stdout.write(Buffer.concat([Buffer.from(osc(frame)), Buffer.from(result.output)])); +await new Promise(() => {}); diff --git a/packages/demo/src/clay-osc-spike.ts b/packages/demo/src/clay-osc-spike.ts new file mode 100644 index 0000000..203c59e --- /dev/null +++ b/packages/demo/src/clay-osc-spike.ts @@ -0,0 +1,77 @@ +import { close, createTerm, fixed, grow, open, rgba, text } from '@bomb.sh/tty'; +import { stdout } from 'node:process'; + +/** + * Experimental protocol number for the spike only. This is not a proposed + * permanent OSC allocation. + */ +const OSC = 7777, + NAMESPACE = 'ghostwright.clay', + VERSION = 1; + +interface ClayElementMetadata { + id: string; + rect: [x: number, y: number, width: number, height: number]; +} + +interface ClayFrameMetadata { + version: number; + frame: number; + elements: ClayElementMetadata[]; +} + +class ClayOscSpikeError extends Error { + readonly code = 'CLAY_OSC_SPIKE'; +} + +function encodeMetadata(frame: ClayFrameMetadata): Uint8Array { + const payload = Buffer.from(JSON.stringify(frame)).toString('base64url'); + return Buffer.from(`\u001b]${OSC};${NAMESPACE};v=${VERSION};${payload}\u001b\\`); +} + +const width = stdout.columns || 60, + height = stdout.rows || 15, + term = await createTerm({ width, height }), + result = term.render([ + open('screen', { + layout: { width: grow(), height: grow(), alignX: 'center', alignY: 'center' }, + }), + open('semantic-target', { + border: { + color: rgba(255, 255, 255), + top: 1, + right: 1, + bottom: 1, + left: 1, + }, + layout: { + width: fixed(30), + height: fixed(5), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + text('Clay semantic target'), + close(), + close(), + ]), + info = result.info.get('semantic-target'); + +if (!info) throw new ClayOscSpikeError('Clay did not retain the semantic-target element'); + +const metadata: ClayFrameMetadata = { + version: VERSION, + frame: 1, + elements: [ + { + id: 'semantic-target', + rect: [info.bounds.x, info.bounds.y, info.bounds.width, info.bounds.height], + }, + ], +}; + +// Keep visual bytes and their metadata adjacent in one logical stdout write. +// A streaming receiver must still tolerate the PTY splitting or coalescing it. +stdout.write(Buffer.concat([Buffer.from(result.output), Buffer.from(encodeMetadata(metadata))])); + +// Stay alive like a real TUI; Ghostwright owns cleanup of the process group. +await new Promise(() => {}); diff --git a/packages/demo/src/freedom-focus-text-input.ts b/packages/demo/src/freedom-focus-text-input.ts new file mode 100644 index 0000000..0ac14de --- /dev/null +++ b/packages/demo/src/freedom-focus-text-input.ts @@ -0,0 +1,231 @@ +// oxlint-disable bombshell-dev/no-generic-error +import { each, ensure, main, spawn, until } from "effection"; +import { + advance, + createNodeData, + createRoot, + type Node, + retreat, + useFocus, +} from "@bomb.sh/freedom"; +import { + initInput, + KeyboardApi, + useInput, + useReadlineKeymap, +} from "@bomb.sh/input"; +import { + alternateBuffer, + close, + createTerm, + cursor, + fit, + grow, + type Op, + open, + percent, + rgba, + settings, + text, +} from "@bomb.sh/tty"; +import { stdin, stdout } from "node:process"; +import { useInput as decodeBytes } from "./use-input.ts"; +import { useStdin } from "./use-stdin.ts"; + +const GRAY = rgba(100, 100, 100); + +interface LayoutOptions { + node: Node; + children: Iterable; +} + +const layoutKey = createNodeData<(options: LayoutOptions) => Op[]>( + "demo:layout", + () => [], +); + +function layout(node: Node, body: (options: LayoutOptions) => Op[]): void { + node.data.set(layoutKey, body); +} + +// A bordered text input. Editing, caret, and focus state come from +// `@bomb.sh/input` (`makeInput`); the demo owns only how it's drawn. The focused +// input passes its `caret` (a code-point offset) to `text()`, so tty positions +// the terminal's native cursor there rather than drawing a glyph. +function textInput(node: Node): void { + initInput(node); + layout(node, () => { + const focused = node.props.focused; + const value = String(node.props.value ?? ""); + const caret = Math.min(Number(node.props.caret ?? 0), [...value].length); + const color = focused ? rgba(255, 255, 255) : GRAY; + const border = { color, top: 1, right: 1, bottom: 1, left: 1 }; + return [ + open(node.id, { + border, + layout: { + height: fit(3), + width: percent(0.3), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + // An empty focused field has no cell for the native cursor, so render a + // single space to give the caret at 0 somewhere to sit. + focused ? text(value || " ", { caret }) : text(value), + close(), + ]; + }); +} + +function screenBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + layout: { + height: grow(), + width: grow(), + direction: "ttb", + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + border: { + color: rgba(255, 255, 255), + top: 1, + right: 1, + bottom: 1, + left: 1, + }, + }), + ...children, + close(), + ]; +} + +function containerBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + border: { color: 0xFFF, top: 1, right: 1, bottom: 1, left: 1 }, + layout: { + height: fit(), + width: grow(), + direction: "ttb", + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + ...children, + close(), + ]; +} + +await main(function* () { + if (!stdin.isTTY) { + throw new Error("freedom demo requires an interactive TTY"); + } + + const root = createRoot(); + + // Route keyboard events to the focused input's editing behavior. + useInput(root); + useReadlineKeymap(root.node); + + // Tab/Backtab move focus between inputs. Installed at the root scope so it + // wraps the focused input's editing behavior (root is an ancestor of where + // `KeyboardApi` is invoked): Tab/Backtab are consumed here, every other key + // falls through via `next` to the input. + root.node.scope.around(KeyboardApi, { + keydown([node, event], next) { + if (event.code === "Tab") { + advance(root.node); + } else if (event.code === "Backtab") { + retreat(root.node); + } else { + next(node, event); + } + }, + keyrepeat([node, event], next) { + if (event.code === "Tab") { + advance(root.node); + } else if (event.code === "Backtab") { + retreat(root.node); + } else { + next(node, event); + } + }, + }); + + layout(root.node, screenBody); + + const container = root.node.createChild("input-1"); + layout(container, containerBody); + + textInput(container.createChild("input-1-1")); + textInput(container.createChild("input-1-2")); + textInput(root.node.createChild("input-2")); + + useFocus(root.node); // seed focus now that focusable inputs exist (input-1-1) + + const { columns, rows } = stdout.isTTY + ? { columns: stdout.columns, rows: stdout.rows } + : { columns: 80, rows: 24 }; + + stdin.setRawMode(true); + yield* ensure(() => { + stdin.setRawMode(false); + stdin.pause(); + }); + + const bytes = yield* useStdin(); + const stream = decodeBytes(bytes); + + let term = yield* until(createTerm({ height: rows, width: columns })); + + const events = yield* spawn(function* () { + for (const event of yield* each(stream)) { + if (event.type === "keydown" && event.ctrl && event.code === "c") { + break; + } + if (event.type === "resize") { + term = yield* until(createTerm({ + height: event.height, + width: event.width, + })); + render(); + } + + root.dispatch(event); + + yield* each.next(); + } + }); + + function render(): void { + const ops = walk(root.node); + const { output } = term.render(ops); + stdout.write(output); + } + + const tty = settings(cursor(true), alternateBuffer()); + + try { + stdout.write(tty.apply); + + render(); + yield* spawn(function* () { + for (const _ of yield* each(root)) { + render(); + yield* each.next(); + } + }); + + yield* events; + } finally { + stdout.write(tty.revert); + } +}); + +function walk(node: Node): Op[] { + const children: Op[] = []; + for (const child of node.children) { + children.push(...walk(child)); + } + const body = node.data.get(layoutKey); + return body ? body({ node, children }) : children; +} diff --git a/packages/demo/src/freedom-tty-inspector.ts b/packages/demo/src/freedom-tty-inspector.ts new file mode 100644 index 0000000..fef5f66 --- /dev/null +++ b/packages/demo/src/freedom-tty-inspector.ts @@ -0,0 +1,103 @@ +import { inspectFocus, type Node } from '@bomb.sh/freedom'; +import type { RenderInfo } from '@bomb.sh/tty'; + +/** Experimental protocol number; not a proposed permanent OSC allocation. */ +export const INSPECTOR_OSC = 7777, + INSPECTOR_NAMESPACE = 'ghostwright.freedom-tty', + INSPECTOR_VERSION = 1; + +export interface FreedomTtyNodeMetadata { + key: string; + name: string; + parent: string | null; + order: number; + rect?: [x: number, y: number, width: number, height: number]; + attributes: { + role?: string; + label?: string; + input?: boolean; + focusable: boolean; + }; + states: { + focused: boolean; + focusRoot: boolean; + }; +} + +export interface FreedomTtyFrameMetadata { + version: number; + frame: number; + focusStack: string[]; + nodes: FreedomTtyNodeMetadata[]; +} + +function safeString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined; +} + +/** + * Join Freedom's semantic tree and focus state to tty/Clay's computed geometry. + * + * Application values are deliberately excluded. In particular, `value` and + * `caret` are never serialized; input contents may contain passwords, card + * numbers, tokens, or other data that must not leak into traces. + */ +export function inspectFreedomTty(options: { + root: Node; + info: RenderInfo; + frame: number; +}): FreedomTtyFrameMetadata { + const { root, info, frame } = options, + focus = inspectFocus(root), + nodes: FreedomTtyNodeMetadata[] = []; + + function visit(node: Node, position: { parent: Node | undefined; order: number }): void { + const { parent, order } = position, + element = info.get(node.id), + props = node.props; + nodes.push({ + key: node.id, + name: node.name, + parent: parent?.id ?? null, + order, + ...(element + ? { + rect: [ + element.bounds.x, + element.bounds.y, + element.bounds.width, + element.bounds.height, + ] as [number, number, number, number], + } + : {}), + attributes: { + role: safeString(props.role), + label: safeString(props.label), + input: props.input === true ? true : undefined, + focusable: 'focused' in props, + }, + states: { + focused: focus.focused?.id === node.id, + focusRoot: focus.activeRoot.id === node.id, + }, + }); + let childOrder = 0; + for (const child of node.children) visit(child, { parent: node, order: childOrder++ }); + } + + visit(root, { parent: undefined, order: 0 }); + return { + version: INSPECTOR_VERSION, + frame, + focusStack: focus.stack.map((entry) => entry.root.id), + nodes, + }; +} + +/** Encode one complete semantic frame as an invisible, namespaced OSC. */ +export function encodeFreedomTtyFrame(frame: FreedomTtyFrameMetadata): Uint8Array { + const payload = Buffer.from(JSON.stringify(frame)).toString('base64url'); + return Buffer.from( + `\u001b]${INSPECTOR_OSC};${INSPECTOR_NAMESPACE};v=${INSPECTOR_VERSION};${payload}\u001b\\`, + ); +} diff --git a/packages/demo/src/pizza.ts b/packages/demo/src/pizza.ts new file mode 100644 index 0000000..d1e4fa2 --- /dev/null +++ b/packages/demo/src/pizza.ts @@ -0,0 +1,346 @@ +// oxlint-disable bombshell-dev/no-generic-error +import { each, ensure, main, spawn, until } from 'effection'; +import { + advance, + createNodeData, + createRoot, + focusable, + focusPush, + type Node, + retreat, + type Root, + useFocus, +} from '@bomb.sh/freedom'; +import { + initInput, + KeyboardApi, + useInput as installInput, + useReadlineKeymap, +} from '@bomb.sh/input'; +import { + alternateBuffer, + close, + createTerm, + fit, + grow, + type KeyEvent, + type Op, + open, + rgba, + settings, + text, +} from '@bomb.sh/tty'; +import { env, stdin, stdout } from 'node:process'; +import { pathToFileURL } from 'node:url'; +import { encodeFreedomTtyFrame, inspectFreedomTty } from '@ghostwright/freedom-tty/producer'; +import { useInput } from './use-input.ts'; +import { useStdin } from './use-stdin.ts'; + +const WHITE = rgba(255, 255, 255); // all text, and the focused border +const GRAY = rgba(100, 100, 100); // unfocused border only + +interface LayoutOptions { + node: Node; + children: Iterable; +} + +const layoutKey = createNodeData<(options: LayoutOptions) => Op[]>('demo:layout', () => []); + +function layout(node: Node, body: (options: LayoutOptions) => Op[]): void { + node.data.set(layoutKey, body); +} + +// The centering screen: holds the form box in the middle of the terminal. +function screenBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + layout: { width: grow(), height: grow(), alignX: 'center', alignY: 'center' }, + }), + ...children, + close(), + ]; +} + +// The titled, bordered form panel. +function formBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 }, + layout: { + direction: 'ttb', + width: fit(48), + height: fit(), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + gap: 1, + }, + }), + text('Pizza Delivery', { color: WHITE }), + ...children, + close(), + ]; +} + +// A labelled text input. Readline editing is provided by @bomb.sh/input; the +// focused field renders a native cursor via the value text's `caret`. +function makeField(node: Node, label: string): void { + initInput(node); // focusable + input:true + value:"" + caret:0 + node.set('role', 'textbox'); + node.set('label', label); + layout(node, () => { + const focused = node.props.focused === true; + const border = focused ? WHITE : GRAY; + return [ + open(`${node.id}-field`, { + layout: { direction: 'ttb', width: grow(), padding: { left: 1, right: 1 } }, + }), + text(label, { color: WHITE }), + open(node.id, { + border: { color: border, top: 1, right: 1, bottom: 1, left: 1 }, + layout: { + width: grow(), + height: fit(3), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }, + }), + text(String(node.props.value ?? ''), { + color: WHITE, + caret: focused ? Number(node.props.caret ?? 0) : undefined, + }), + close(), + close(), + ]; + }); +} + +// A single-line activatable control (link/button). Focused shows `› text ‹` +// over a background that eases in. +function makeControl(node: Node, label: string): void { + focusable(node); + node.set('role', 'button'); + node.set('label', label); + layout(node, () => { + const focused = node.props.focused === true; + const caption = String(node.props.label ?? ''); + // Reserve the caret columns in both states so the label never shifts. + const content = focused ? `› ${caption} ‹` : ` ${caption} `; + return [ + open(node.id, { + layout: { width: grow(), padding: { left: 1, right: 1 } }, + }), + text(content, { color: WHITE }), + close(), + ]; + }); +} + +// Fire `onActivate` when the focused control receives Enter or Space; other +// keys bubble so Tab/Backtab still navigate. +function activatable(node: Node, onActivate: () => void): void { + node.scope.around(KeyboardApi, { + keydown([n, event], next) { + if (event.code === 'Enter' || event.code === ' ') { + onActivate(); + } else { + next(n, event); + } + }, + }); +} + +// The credit-card modal: a floating panel centered over (and occluding) the +// form, on top via zIndex. Its own focus root traps Tab/Backtab (§12). +function cardModalBody({ node, children }: LayoutOptions): Op[] { + return [ + open(node.id, { + border: { color: WHITE, top: 1, right: 1, bottom: 1, left: 1 }, + bg: rgba(0, 0, 0), + floating: { + attachTo: 'root', + attachPoints: { element: 'center-center', parent: 'center-center' }, + zIndex: 10, + }, + layout: { + direction: 'ttb', + width: fit(40), + height: fit(), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + gap: 1, + }, + }), + text('Card details', { color: WHITE }), + ...children, + close(), + ]; +} + +function isConfirmed(value: unknown): value is { last4: string } { + return !!value && typeof value === 'object' && 'last4' in value; +} + +// Open the card modal: build its subtree, push it as the focus root, and wire +// Cancel/Confirm to pop with a result the push callback acts on. +function openCardModal(root: Root, card: Node): void { + const modal = root.node.createChild('card-modal'); + modal.set('role', 'dialog'); + modal.set('label', 'Card details'); + layout(modal, cardModalBody); + const number = modal.createChild('card-number'); + makeField(number, 'Card number'); + makeField(modal.createChild('expiry'), 'Expiry'); + makeField(modal.createChild('cvc'), 'CVC'); + const cancel = modal.createChild('cancel'); + makeControl(cancel, 'Cancel'); + const confirm = modal.createChild('confirm'); + makeControl(confirm, 'Confirm'); + + const pop = focusPush(modal, (value) => { + if (isConfirmed(value)) { + card.set('label', `Edit card •••• ${value.last4}`); + } + void modal.remove(); // focus already restored to the card link + }); + + activatable(cancel, () => pop({ cancelled: true })); + activatable(confirm, () => { + const digits = String(number.props.value ?? '').replace(/\D/g, ''); + pop({ last4: digits.slice(-4) || '????' }); + }); +} + +// Build the pizza form's node tree: a centered panel holding two text fields +// and two activatable controls, with Tab/Backtab focus navigation installed. +export function buildPizza(): Root { + const root = createRoot(); + + // Demux + readline editing (insert-at-caret, Backspace/Delete, arrows, + // Home/End) from @bomb.sh/input, plus its emacs Ctrl-A/E/F/B/D keymap. + installInput(root); + useReadlineKeymap(root.node); + + // Tab/Backtab navigation, bubbled up from nodes that don't consume the key. + function tab([node, event]: [Node, KeyEvent], next: (node: Node, event: KeyEvent) => void): void { + if (event.code === 'Tab') { + advance(root.node); + } else if (event.code === 'Backtab') { + retreat(root.node); + } else { + next(node, event); + } + } + root.node.scope.around(KeyboardApi, { keydown: tab }); + + layout(root.node, screenBody); + + const panel = root.node.createChild('form'); + panel.set('role', 'form'); + panel.set('label', 'Pizza Delivery'); + layout(panel, formBody); + + makeField(panel.createChild('name'), 'Name'); + makeField(panel.createChild('address'), 'Address'); + const card = panel.createChild('card'); + makeControl(card, 'Add card'); + activatable(card, () => openCardModal(root, card)); + makeControl(panel.createChild('submit'), 'Submit'); + + useFocus(root.node); // seed focus now that focusable controls exist (name) + + return root; +} + +export function walk(node: Node): Op[] { + const children: Op[] = []; + for (const child of node.children) { + children.push(...walk(child)); + } + const body = node.data.get(layoutKey); + return body ? body({ node, children }) : children; +} + +function* run() { + if (!stdin.isTTY) { + throw new Error('pizza demo requires an interactive TTY'); + } + + const root = buildPizza(); + + const initialViewport = stdout.isTTY + ? { columns: stdout.columns, rows: stdout.rows } + : { columns: 80, rows: 24 }; + let { columns, rows } = initialViewport; + + stdin.setRawMode(true); + yield* ensure(() => { + stdin.setRawMode(false); + stdin.pause(); + }); + + const bytes = yield* useStdin(); + const input = useInput(bytes); + + let term = yield* until(createTerm({ height: rows, width: columns })); + const inspectionEnabled = env.GHOSTWRIGHT_FREEDOM_TTY === '1'; + let inspectionFrame = 0; + + function render(): void { + const result = term.render(walk(root.node), { deltaTime: 0 }); + if (inspectionEnabled) { + const metadata = inspectFreedomTty({ + root: root.node, + info: result.info, + frame: ++inspectionFrame, + renderSurface: { columns, rows }, + }); + // The OSC follows its visual bytes. Its completion is the exact ordered + // commit boundary consumed by the Ghostwright extension. + stdout.write( + Buffer.concat([Buffer.from(result.output), Buffer.from(encodeFreedomTtyFrame(metadata))]), + ); + } else if (result.output.length > 0) { + stdout.write(result.output); + } + } + + const tty = settings(alternateBuffer()); // native cursor shown via text carets + + try { + stdout.write(tty.apply); + + // Event-driven: paint once, then only when the tree changes. Rendering + // every frame would re-emit the cursor position each tick and reset the + // terminal's blink timer, so an idle cursor would never blink. + render(); + yield* spawn(function* () { + for (const _ of yield* each(root)) { + render(); + yield* each.next(); + } + }); + + for (const event of yield* each(input)) { + if (event.type === 'keydown' && event.ctrl && event.code === 'c') { + break; + } + if (event.type === 'resize') { + rows = event.height; + columns = event.width; + term = yield* until( + createTerm({ + height: rows, + width: columns, + }), + ); + render(); + } + root.dispatch(event); + yield* each.next(); + } + } finally { + stdout.write(tty.revert); + } +} + +// Run the IO loop only when executed directly, not when imported for testing. +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + await main(run); +} diff --git a/packages/demo/src/use-input.ts b/packages/demo/src/use-input.ts new file mode 100644 index 0000000..2606c56 --- /dev/null +++ b/packages/demo/src/use-input.ts @@ -0,0 +1,70 @@ +import { + call, + createChannel, + each, + type Operation, + race, + resource, + sleep, + spawn, + type Stream, + suspend, + until, +} from "effection"; +import { + createInput, + type InputEvent, + type InputOptions, +} from "@bomb.sh/tty"; + +function nothing() { + return suspend() as unknown as Operation< + IteratorResult + >; +} + +// Parse a raw byte Stream into a Stream of decoded terminal InputEvents. +export function useInput( + stream: Stream, + options?: InputOptions, +): Stream { + return resource(function* (provide) { + const input = yield* until(createInput(options)); + const subscription = yield* stream; + + let pending = nothing(); + + const events = createChannel(); + + yield* spawn(function* () { + let next = yield* subscription.next(); + while (!next.done) { + const result = input.scan(next.value); + pending = result.pending ? rescan(result.pending.delay) : nothing(); + for (const event of result.events) { + yield* events.send(event); + } + next = yield* race([subscription.next(), pending]); + } + yield* events.close(); + }); + + yield* race([provide(yield* events), drain(events)]); + }); +} + +function rescan(delay: number): ReturnType { + return call(function* (): Operation> { + yield* sleep(delay); + return { + done: false, + value: new Uint8Array(), + }; + }); +} + +function* drain(stream: Stream): Operation { + for (const _ of yield* each(stream)) { + yield* each.next(); + } +} diff --git a/packages/demo/src/use-stdin.ts b/packages/demo/src/use-stdin.ts new file mode 100644 index 0000000..eb2c9ca --- /dev/null +++ b/packages/demo/src/use-stdin.ts @@ -0,0 +1,37 @@ +import { + createChannel, + each, + type Operation, + race, + resource, + spawn, + type Stream, + until, +} from "effection"; +import { stdin } from "node:process"; + +// Bridge Node's process.stdin (raw bytes) into an Effection Stream. +export function useStdin(): Operation> { + return resource(function* (provide) { + const channel = createChannel(); + + const iterator = stdin[Symbol.asyncIterator](); + + yield* spawn(function* () { + let next = yield* until(iterator.next()); + while (!next.done) { + yield* channel.send(next.value); + next = yield* until(iterator.next()); + } + yield* channel.close(); + }); + + yield* race([provide(channel), drain(channel)]); + }); +} + +function* drain(stream: Stream): Operation { + for (const _ of yield* each(stream)) { + yield* each.next(); + } +} diff --git a/packages/demo/test/clay-geometry-spike.test.ts b/packages/demo/test/clay-geometry-spike.test.ts new file mode 100644 index 0000000..9df57e6 --- /dev/null +++ b/packages/demo/test/clay-geometry-spike.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from 'bun:test'; +import { + expectTerminal, + withTerminalAsync, + type AsyncTerminal, + type Rect, + type TerminalLaunchOptions, +} from 'ghostwright'; +import { ClayMetadataExtension } from './support/clay-metadata-extension.ts'; + +type GeometryMode = 'offset' | 'fractional' | 'clipped' | 'offscreen'; + +const app = (mode: GeometryMode): TerminalLaunchOptions => ({ + command: 'bun', + args: ['src/clay-geometry-spike.ts', mode], + cwd: import.meta.dir + '/..', + viewport: { columns: 30, rows: 20 }, + trace: 'off' as const, +}); + +function colourBounds( + terminal: AsyncTerminal, + colour: { red: number; green: number; blue: number }, +): Rect | undefined { + const points = terminal.screen + .snapshot() + .lines.flatMap((line) => + line.cells + .filter( + (cell) => + cell.style.background.kind === 'rgb' && + cell.style.background.red === colour.red && + cell.style.background.green === colour.green && + cell.style.background.blue === colour.blue, + ) + .map((cell) => ({ column: cell.column, row: line.row })), + ); + if (points.length === 0) return undefined; + const columns = points.map((point) => point.column), + rows = points.map((point) => point.row), + left = Math.min(...columns), + right = Math.max(...columns), + top = Math.min(...rows), + bottom = Math.max(...rows); + return { column: left, row: top, width: right - left + 1, height: bottom - top + 1 }; +} + +test('RenderOptions.row shifts visible cells but not Clay layout bounds', async () => { + await withTerminalAsync(app('offset'), async (terminal) => { + const clay = new ClayMetadataExtension(terminal); + await expectTerminal(terminal).toSatisfy( + () => + clay.current()?.mode === 'offset' && + terminal.screen.getCell({ column: 5, row: 6 }).text === '┌', + ); + const frame = clay.current(), + target = clay.getById('offset-target'); + + expect(frame?.renderSurface).toEqual({ width: 20, height: 5, row: 6 }); + expect(target?.bounds).toEqual({ column: 5, row: 1, width: 10, height: 3 }); + + // Clay reports coordinates local to its 20x5 render surface. The actual + // terminal rows are shifted by the 1-based RenderOptions.row origin. + expect(terminal.screen.getText(target!.bounds)).not.toContain('┌'); + const terminalBounds = { + ...target!.bounds, + row: target!.bounds.row + frame!.renderSurface!.row - 1, + }; + expect(terminal.screen.getText(terminalBounds)).toContain('┌'); + expect( + terminal.screen.getCell({ column: terminalBounds.column, row: terminalBounds.row }).text, + ).toBe('┌'); + }); +}); + +test('fractional Clay coordinates are rasterized by truncating their edges', async () => { + await withTerminalAsync(app('fractional'), async (terminal) => { + const clay = new ClayMetadataExtension(terminal); + await expectTerminal(terminal).toSatisfy( + () => + clay.current()?.mode === 'fractional' && + colourBounds(terminal, { red: 0, green: 255, blue: 0 }) !== undefined, + ); + const target = clay.getById('fractional-target'), + visible = colourBounds(terminal, { red: 0, green: 255, blue: 0 }); + + // Centering an odd 5x3 rectangle in an even 20x10 surface yields half-cell + // Clay coordinates. clayterm.c casts each edge to int before painting. + expect(target?.metadata.rect).toEqual([7.5, 3.5, 5, 3]); + expect(visible).toEqual({ column: 7, row: 3, width: 5, height: 3 }); + expect(target?.bounds).toEqual(visible); + }); +}); + +test('Clay layout bounds remain oversized while clipping reduces visible bounds', async () => { + await withTerminalAsync(app('clipped'), async (terminal) => { + const clay = new ClayMetadataExtension(terminal); + await expectTerminal(terminal).toSatisfy( + () => + clay.current()?.mode === 'clipped' && + colourBounds(terminal, { red: 255, green: 0, blue: 0 }) !== undefined, + ); + const clipper = clay.getById('clipper'), + target = clay.getById('oversized-target'), + visible = colourBounds(terminal, { red: 255, green: 0, blue: 0 }); + + expect(clipper?.bounds).toEqual({ column: 9, row: 2, width: 12, height: 5 }); + expect(target?.bounds).toEqual({ column: 9, row: 2, width: 20, height: 7 }); + expect(visible).toEqual(clipper?.bounds); + expect(visible?.width).toBeLessThan(target!.bounds.width); + expect(visible?.height).toBeLessThan(target!.bounds.height); + }); +}); + +test('viewport intersection clips negative and oversized layout bounds', async () => { + await withTerminalAsync(app('offscreen'), async (terminal) => { + const clay = new ClayMetadataExtension(terminal); + await expectTerminal(terminal).toSatisfy( + () => + clay.current()?.mode === 'offscreen' && + colourBounds(terminal, { red: 0, green: 0, blue: 255 }) !== undefined, + ); + const target = clay.getById('offscreen-target'), + visible = colourBounds(terminal, { red: 0, green: 0, blue: 255 }); + + // The centered 40-cell element extends five cells beyond each side of a + // 30-cell surface. Clay retains its logical box; clayterm's viewport clips + // cell writes to columns 0..29. + expect(target?.metadata.rect).toEqual([-5, 3.5, 40, 3]); + expect(visible).toEqual({ column: 0, row: 3, width: 30, height: 3 }); + expect(target?.bounds.column).toBe(-5); + expect(target?.bounds.width).toBe(40); + }); +}); diff --git a/packages/demo/test/clay-osc-spike.test.ts b/packages/demo/test/clay-osc-spike.test.ts new file mode 100644 index 0000000..07464c0 --- /dev/null +++ b/packages/demo/test/clay-osc-spike.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from 'bun:test'; +import { expectTerminal, withTerminalAsync } from 'ghostwright'; +import { ClayMetadataExtension, parseClayFrames } from './support/clay-metadata-extension.ts'; + +const app = { + command: 'bun', + args: ['src/clay-osc-spike.ts'], + cwd: import.meta.dir + '/..', + viewport: { columns: 60, rows: 15 }, + trace: 'off' as const, +}; + +test('a custom OSC locates a tty element by its Clay ID', async () => { + await withTerminalAsync(app, async (terminal) => { + await expectTerminal(terminal.getByText('Clay semantic target')).toBeStable(); + + const clay = new ClayMetadataExtension(terminal), + frame = clay.current(), + target = clay.getById('semantic-target'); + + expect(frame?.version).toBe(1); + expect(frame?.frame).toBe(1); + expect(target).toBeDefined(); + expect(target?.id).toBe('semantic-target'); + + // Clay centered a 30x5 element in the 60x15 terminal. These coordinates + // come from tty's result.info.get(id), not from screen text archaeology. + expect(target?.bounds).toEqual({ column: 15, row: 5, width: 30, height: 5 }); + + // The returned bounds compose with Ghostwright's existing locator API. + await expectTerminal(target!.getByText('Clay semantic target')).toBePresent(); + + // And they correspond to the real visible box, not merely a plausible + // metadata payload. + const { column, row, width, height } = target!.bounds; + expect(terminal.screen.getCell({ column, row }).text).toBe('┌'); + expect(terminal.screen.getCell({ column: column + width - 1, row }).text).toBe('┐'); + expect(terminal.screen.getCell({ column, row: row + height - 1 }).text).toBe('└'); + expect( + terminal.screen.getCell({ column: column + width - 1, row: row + height - 1 }).text, + ).toBe('┘'); + + // The OSC is retained in raw PTY bytes for the extension, but Ghostty + // ignores it and it never appears in the terminal grid. + const raw = Buffer.from(terminal.screen.rawOutput()).toString('latin1'); + expect(raw).toContain('\u001b]7777;ghostwright.clay;v=1;'); + expect(terminal.screen.getText()).not.toContain('ghostwright.clay'); + expect(terminal.screen.getText()).not.toContain('semantic-target'); + }); +}); + +test('the spike parser tolerates an OSC split before its terminator', () => { + const frame = { version: 1, frame: 7, elements: [] }, + payload = Buffer.from(JSON.stringify(frame)).toString('base64url'), + incomplete = Buffer.from(`\u001b]7777;ghostwright.clay;v=1;${payload}`), + complete = Buffer.concat([incomplete, Buffer.from('\u001b\\')]); + + expect(parseClayFrames(incomplete)).toEqual([]); + expect(parseClayFrames(complete)).toEqual([frame]); +}); diff --git a/packages/demo/test/freedom-tty-modal-spike.test.ts b/packages/demo/test/freedom-tty-modal-spike.test.ts new file mode 100644 index 0000000..ccb6a2d --- /dev/null +++ b/packages/demo/test/freedom-tty-modal-spike.test.ts @@ -0,0 +1,126 @@ +import { expect, test } from 'bun:test'; +import { expectTerminal, withTerminalAsync, type AsyncTerminal } from 'ghostwright'; +import { freedomTtyExtension, type FreedomTtySession } from '@ghostwright/freedom-tty'; + +const freedomExtension = freedomTtyExtension(); + +const pizza = { + command: 'bun', + args: ['src/pizza.ts'], + cwd: import.meta.dir + '/..', + env: { GHOSTWRIGHT_FREEDOM_TTY: '1' }, + viewport: { columns: 80, rows: 24 }, + trace: 'off' as const, + extensions: [freedomExtension], +}; + +/** Bind semantic-count assertions to one terminal session. */ +function semanticCountExpectation(terminal: AsyncTerminal) { + return async ( + selector: ReturnType, + count: number, + ): Promise => { + await expectTerminal(terminal).toSatisfy(() => selector.matches().length === count, { + // A semantic commit is immutable; waiting for visual settlement would + // unnecessarily add 100 ms to every focus transition. + settleMs: 0, + }); + }; +} + +test('Freedom semantics expose modal focus capture through CSS-like selectors', async () => { + await withTerminalAsync(pizza, async (terminal) => { + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + + const freedom = terminal.extension(freedomExtension), + expectCount = semanticCountExpectation(terminal), + modal = freedom.locator('card-modal:focus-root'), + focused = freedom.locator(':focus'); + + // The locator is created before the modal exists. It is lazy and resolves + // against each newest semantic frame rather than retaining stale geometry. + expect(modal.matches()).toHaveLength(0); + await expectCount(freedom.locator(':root:focus-root'), 1); + await expectCount(freedom.locator('form > name:focus'), 1); + expect(focused.matches().map((node) => node.name)).toEqual(['name']); + + // Name -> Address -> Add card, then activate the control. + await terminal.keyboard.press('Tab'); + await terminal.keyboard.press('Tab'); + await expectCount(freedom.locator('form > card:focus'), 1); + await terminal.keyboard.press('Enter'); + + await expectCount(modal, 1); + await expectCount(freedom.locator('card-modal:focus-root > card-number:focus'), 1); + expect(freedom.locator('card-modal:focus-root > *').matches()).toHaveLength(5); + expect(freedom.locator('card-modal > [role=textbox]').matches()).toHaveLength(3); + expect(freedom.locator('[role=dialog]:focus-root').matches()).toHaveLength(1); + expect(freedom.locator('card-modal:has(> card-number:focus)').matches()).toHaveLength(1); + expect(freedom.locator('card-modal > :nth-child(1):focus').matches()).toHaveLength(1); + expect(freedom.locator('card-number + expiry').matches()).toHaveLength(1); + expect(freedom.locator('card-number ~ confirm').matches()).toHaveLength(1); + expect(freedom.locator('card-number, expiry').matches()).toHaveLength(2); + expect(freedom.locator('card-modal [role^=text]:not(cvc)').matches()).toHaveLength(2); + expect(freedom.locator(`#${modal.unique().key}`).matches()).toHaveLength(1); + expect(freedom.current()?.focusStack).toEqual([modal.unique().key]); + expect(freedom.locator('form :focus').matches()).toHaveLength(0); + expect(() => freedom.locator('card-modal::before')).toThrow( + 'Pseudo-elements are not supported', + ); + expect(() => freedom.locator('card-modal:contains(Card)')).toThrow( + 'Pseudo-class :contains is not supported', + ); + expect(() => freedom.locator('card-modal < :focus')).toThrow( + 'Selector traversal parent is not supported', + ); + expect(() => freedom.locator('x'.repeat(4097))).toThrow('Selector exceeds 4096 bytes'); + expect(() => freedom.locator('root:has(root:has(root:has(root)))')).toThrow( + 'Nested :has() exceeds depth 2', + ); + expect(focused.matches().map((node) => node.name)).toEqual(['card-number']); + + // Geometry stays available for diagnostics. visibleBounds is deliberately + // omitted until tty supplies authoritative clipping intersections. + expect(modal.layoutBounds()).toBeDefined(); + expect(modal.terminalBounds()).toBeDefined(); + expect(modal.visibleBounds()).toBeUndefined(); + + // Input values are intentionally not part of the semantic payload. This + // must remain true even after sensitive-looking data enters the model. + await terminal.keyboard.type('4111111111111111'); + await expectTerminal(terminal.getByText('4111111111111111')).toBeStable(); + expect(JSON.stringify(freedom.current())).not.toContain('4111111111111111'); + expect(JSON.stringify(freedom.current())).not.toContain('"value"'); + expect(JSON.stringify(freedom.current())).not.toContain('"caret"'); + + // Tab is trapped in the active modal focus root and wraps after Confirm. + for (const name of ['expiry', 'cvc', 'cancel', 'confirm', 'card-number']) { + await terminal.keyboard.press('Tab'); + await expectCount(freedom.locator(`card-modal:focus-root > ${name}:focus`), 1); + expect(freedom.locator('form :focus').matches()).toHaveLength(0); + expect(focused.matches().map((node) => node.name)).toEqual([name]); + } + + // Move to Cancel and close. The pre-existing lazy locator becomes absent, + // and Freedom restores focus to the control that opened the modal. + for (const name of ['expiry', 'cvc', 'cancel']) { + await terminal.keyboard.press('Tab'); + await expectCount(freedom.locator(`card-modal > ${name}:focus`), 1); + } + await terminal.keyboard.press('Enter'); + + await expectCount(modal, 0); + await expectCount(freedom.locator('form > card:focus'), 1); + expect(focused.matches().map((node) => node.name)).toEqual(['card']); + expect(freedom.current()?.focusStack).toEqual([]); + + // Every live semantic node in the final frame has corresponding Clay + // geometry. The removed modal nodes are absent rather than stale. + expect(freedom.current()?.nodes.every((node) => node.geometry !== undefined)).toBe(true); + expect(freedom.locator('card-modal *').matches()).toHaveLength(0); + const frameNumbers = freedom.frames().map((frame) => frame.frame); + expect( + frameNumbers.every((frame, index) => index === 0 || frame > frameNumbers[index - 1]), + ).toBe(true); + }); +}); diff --git a/packages/demo/test/pizza.focus.test.ts b/packages/demo/test/pizza.focus.test.ts new file mode 100644 index 0000000..f6c92d5 --- /dev/null +++ b/packages/demo/test/pizza.focus.test.ts @@ -0,0 +1,155 @@ +/** + * Acceptance: keyboard focus traversal between the pizza form's text fields. + * + * Focus is asserted purely from visible terminal state, with no application + * instrumentation. pizza.ts expresses text-field focus two independent ways + * (see makeField in src/pizza.ts): + * + * 1. the field's box border is drawn WHITE when focused and GRAY otherwise + * 2. only the focused field renders a caret, so the terminal cursor sits + * inside that field's box + * + * Both are checked, so the tests fail if either indicator regresses. + */ +import { expect, test } from 'bun:test'; +import { cellsMatchStyle, expectTerminal, withTerminalAsync } from 'ghostwright'; +import type { AsyncTerminal, Rect, ScreenSnapshot, StyleQuery } from 'ghostwright'; + +const pizza = { + command: 'bun', + args: ['src/pizza.ts'], + cwd: import.meta.dir + '/..', + viewport: { columns: 80, rows: 24 }, + trace: 'off' as const, +}; + +const FOCUSED = { foreground: 'rgb(255,255,255)' }, // WHITE in src/pizza.ts + UNFOCUSED = { foreground: 'rgb(100,100,100)' }; // GRAY in src/pizza.ts + +interface FocusProbe { + /** True when the field's border is drawn entirely in `style`. */ + hasBorder(label: string, style: StyleQuery): boolean; + /** True when the terminal cursor (the focused field's caret) is inside the box. */ + cursorInBox(snapshot: ScreenSnapshot, label: string): boolean; +} + +/** + * Focus probes bound to one terminal. Bundled into a factory so each helper + * closes over the session instead of taking it as a parameter. + */ +function focusProbe(terminal: AsyncTerminal): FocusProbe { + /** + * The box drawn under a field's label. Located from the label's own match + * geometry rather than hardcoded rows, so layout changes above it are fine. + */ + const fieldBox = (label: string): Rect | undefined => { + const [match] = terminal.getByText(label).matches(); + if (!match) return undefined; + const snapshot = terminal.screen.snapshot(), + top = snapshot.lines.find( + (line) => line.row > match.range.row && line.text.includes('┌'), + )?.row, + bottom = snapshot.lines.find((line) => line.row > (top ?? 0) && line.text.includes('└'))?.row; + if (top === undefined || bottom === undefined) return undefined; + const columns = snapshot.lines[top].cells.flatMap((cell) => + /[┌┐]/.test(cell.text) ? [cell.column] : [], + ); + if (columns.length < 2) return undefined; + return { + column: columns[0], + row: top, + width: columns.at(-1)! - columns[0] + 1, + height: bottom - top + 1, + }; + }; + + return { + hasBorder(label: string, style: StyleQuery): boolean { + const box = fieldBox(label); + if (!box) return false; + return cellsMatchStyle( + terminal.screen.getCells(box).filter((cell) => /[┌┐└┘─│]/.test(cell.text)), + style, + ); + }, + cursorInBox(snapshot: ScreenSnapshot, label: string): boolean { + const box = fieldBox(label); + if (!box) return false; + return snapshot.cursor.row > box.row && snapshot.cursor.row < box.row + box.height - 1; + }, + }; +} + +test('Tab moves focus from the first text field to the second', async () => { + await withTerminalAsync(pizza, async (terminal) => { + const { hasBorder, cursorInBox } = focusProbe(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBePresent(); + + // useFocus() seeds focus on the first focusable control, so "Name" is + // focused on startup without any input. + const initial = await expectTerminal(terminal).toSatisfy( + () => hasBorder('Name', FOCUSED) && hasBorder('Address', UNFOCUSED), + { settleMs: 150 }, + ); + expect(cursorInBox(initial, 'Name')).toBe(true); + expect(cursorInBox(initial, 'Address')).toBe(false); + + await terminal.keyboard.press('Tab'); + + // Focus advances to "Address": its border turns white, "Name" goes gray, + // and the caret moves into the second box. + const afterTab = await expectTerminal(terminal).toSatisfy( + () => hasBorder('Address', FOCUSED) && hasBorder('Name', UNFOCUSED), + { settleMs: 150 }, + ); + expect(cursorInBox(afterTab, 'Address')).toBe(true); + expect(cursorInBox(afterTab, 'Name')).toBe(false); + + // Focus moved rather than merely being added somewhere. + expect(afterTab.cursor.row).toBeGreaterThan(initial.cursor.row); + }); +}); + +test('Backtab returns focus to the first text field', async () => { + await withTerminalAsync(pizza, async (terminal) => { + const { hasBorder, cursorInBox } = focusProbe(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBePresent(); + await expectTerminal(terminal).toSatisfy(() => hasBorder('Name', FOCUSED), { + settleMs: 150, + }); + + await terminal.keyboard.press('Tab'); + await expectTerminal(terminal).toSatisfy(() => hasBorder('Address', FOCUSED), { + settleMs: 150, + }); + + await terminal.keyboard.press('Shift+Tab'); + const back = await expectTerminal(terminal).toSatisfy( + () => hasBorder('Name', FOCUSED) && hasBorder('Address', UNFOCUSED), + { settleMs: 150 }, + ); + expect(cursorInBox(back, 'Name')).toBe(true); + }); +}); + +test('typing lands in the focused field, and Tab carries focus past it', async () => { + await withTerminalAsync(pizza, async (terminal) => { + const { hasBorder } = focusProbe(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBePresent(); + await expectTerminal(terminal).toSatisfy(() => hasBorder('Name', FOCUSED), { + settleMs: 150, + }); + + await terminal.keyboard.type('Ada'); + await expectTerminal(terminal.getByText('Ada')).toBeStable(); + + await terminal.keyboard.press('Tab'); + await expectTerminal(terminal).toSatisfy( + () => hasBorder('Address', FOCUSED) && hasBorder('Name', UNFOCUSED), + { settleMs: 150 }, + ); + + // The typed value stays visible in the now-unfocused first field. + await expectTerminal(terminal.getByText('Ada')).toBePresent(); + }); +}); diff --git a/packages/demo/test/pizza.smoke.test.ts b/packages/demo/test/pizza.smoke.test.ts new file mode 100644 index 0000000..b4d3f65 --- /dev/null +++ b/packages/demo/test/pizza.smoke.test.ts @@ -0,0 +1,42 @@ +/** + * Smoke test proving the Ghostwright <-> pizza demo loop works end to end. + * + * This is scaffolding, not real acceptance coverage. It asserts only that the + * demo boots under a real PTY and paints its initial frame. Replace or extend + * with actual acceptance tests. + * + * Run: bun test test/pizza.smoke.test.ts + */ +import { test } from 'bun:test'; +import { expectTerminal, withTerminalAsync } from 'ghostwright'; + +const pizza = { + command: 'bun', + args: ['src/pizza.ts'], + cwd: import.meta.dir + '/..', + viewport: { columns: 80, rows: 24 }, + trace: 'off' as const, +}; + +test('pizza demo boots under a PTY and renders its form', async () => { + await withTerminalAsync(pizza, async (terminal) => { + // Panel title. + await expectTerminal(terminal.getByText('Pizza Delivery')).toBePresent(); + + // The two labelled fields and the two controls built by buildPizza(). + await expectTerminal(terminal.getByText('Name')).toBePresent(); + await expectTerminal(terminal.getByText('Address')).toBePresent(); + await expectTerminal(terminal.getByText('Add card')).toBePresent(); + await expectTerminal(terminal.getByText('Submit')).toBePresent(); + }); +}); + +test('typing into the focused Name field echoes to the terminal', async () => { + await withTerminalAsync(pizza, async (terminal) => { + await expectTerminal(terminal.getByText('Pizza Delivery')).toBePresent(); + + // useFocus() seeds focus on the first focusable control ("name"). + await terminal.keyboard.type('Ada'); + await expectTerminal(terminal.getByText('Ada')).toBeStable(); + }); +}); diff --git a/packages/demo/test/support/clay-metadata-extension.ts b/packages/demo/test/support/clay-metadata-extension.ts new file mode 100644 index 0000000..57e0aa3 --- /dev/null +++ b/packages/demo/test/support/clay-metadata-extension.ts @@ -0,0 +1,126 @@ +// oxlint-disable bombshell-dev/exported-function-async -- this spike exposes pure synchronous readers +import type { + AsyncLocator, + AsyncRegion, + AsyncTerminal, + Rect, + ScreenCell, + TextLocatorOptions, +} from 'ghostwright'; + +const OSC = 7777, + NAMESPACE = 'ghostwright.clay', + PREFIX = `\u001b]${OSC};${NAMESPACE};v=`; + +export interface ClayElementMetadata { + id: string; + rect: [x: number, y: number, width: number, height: number]; +} + +export interface ClayFrameMetadata { + version: number; + frame: number; + mode?: string; + renderSurface?: { width: number; height: number; row: number }; + elements: ClayElementMetadata[]; +} + +/** Protocol or geometry error reported by the experimental Clay extension. */ +export class ClayMetadataError extends Error { + readonly code = 'CLAY_METADATA'; +} + +function asRect([x, y, width, height]: ClayElementMetadata['rect']): Rect { + if (![x, y, width, height].every(Number.isFinite)) + throw new ClayMetadataError('Clay metadata rectangle must contain finite numbers'); + return { + column: Math.floor(x), + row: Math.floor(y), + width: Math.ceil(width), + height: Math.ceil(height), + }; +} + +/** + * Streaming-safe in production because it parses the accumulated raw byte + * stream rather than assuming one OSC per PTY read. For the spike, reparsing is + * intentionally simple; an actual extension would maintain an incremental + * parser and attach completed frames to screen revisions. + */ +export function parseClayFrames(bytes: Uint8Array): ClayFrameMetadata[] { + const raw = Buffer.from(bytes).toString('latin1'), + frames: ClayFrameMetadata[] = []; + let offset = 0; + for (;;) { + const start = raw.indexOf(PREFIX, offset); + if (start < 0) break; + const bodyStart = start + PREFIX.length, + st = raw.indexOf('\u001b\\', bodyStart), + bel = raw.indexOf('\u0007', bodyStart), + end = st < 0 ? bel : bel < 0 ? st : Math.min(st, bel); + if (end < 0) break; // Incomplete OSC at the end of the accumulated stream. + const body = raw.slice(bodyStart, end), + separator = body.indexOf(';'); + if (separator > 0) { + const envelopeVersion = Number(body.slice(0, separator)), + payload = body.slice(separator + 1); + try { + const frame = JSON.parse( + Buffer.from(payload, 'base64url').toString('utf8'), + ) as ClayFrameMetadata; + if (frame.version === envelopeVersion && Array.isArray(frame.elements)) frames.push(frame); + } catch { + // Ignore malformed extension data. A production extension should retain + // a protocol diagnostic and expose it with test failures. + } + } + offset = end + (end === st ? 2 : 1); + } + return frames; +} + +/** ID-based element locator contributed by the experimental Clay extension. */ +export class ClayElementLocator { + constructor( + readonly terminal: AsyncTerminal, + readonly metadata: ClayElementMetadata, + ) {} + + get id(): string { + return this.metadata.id; + } + + get bounds(): Rect { + return asRect(this.metadata.rect); + } + + region(): AsyncRegion { + return this.terminal.region(this.bounds); + } + + getByText(text: string, options?: TextLocatorOptions): AsyncLocator { + return this.region().getByText(text, options); + } + + cells(): readonly ScreenCell[] { + return this.terminal.screen.getCells(this.bounds); + } +} + +/** Minimal stand-in for a future Ghostwright extension instance. */ +export class ClayMetadataExtension { + constructor(readonly terminal: AsyncTerminal) {} + + frames(): readonly ClayFrameMetadata[] { + return parseClayFrames(this.terminal.screen.rawOutput()); + } + + current(): ClayFrameMetadata | undefined { + return this.frames().at(-1); + } + + getById(id: string): ClayElementLocator | undefined { + const metadata = this.current()?.elements.find((element) => element.id === id); + return metadata ? new ClayElementLocator(this.terminal, metadata) : undefined; + } +} diff --git a/packages/demo/test/support/freedom-tty-extension.ts b/packages/demo/test/support/freedom-tty-extension.ts new file mode 100644 index 0000000..7bb7997 --- /dev/null +++ b/packages/demo/test/support/freedom-tty-extension.ts @@ -0,0 +1,355 @@ +// oxlint-disable bombshell-dev/exported-function-async -- pure synchronous snapshot queries +import { compile, type Options } from 'css-select'; +import { AttributeAction, parse, SelectorType, type Selector } from 'css-what'; +import type { + AsyncLocator, + AsyncRegion, + AsyncTerminal, + Rect, + TextLocatorOptions, +} from 'ghostwright'; + +const PREFIX = '\u001b]7777;ghostwright.freedom-tty;v=', + MAX_SELECTOR_BYTES = 4096, + MAX_SELECTOR_TOKENS = 256, + MAX_SELECTOR_LISTS = 32, + MAX_SELECTOR_DEPTH = 8, + MAX_HAS_DEPTH = 2; + +export interface FreedomTtyNodeMetadata { + key: string; + name: string; + parent: string | null; + order: number; + rect?: [x: number, y: number, width: number, height: number]; + attributes: { + role?: string; + label?: string; + input?: boolean; + focusable: boolean; + }; + states: { + focused: boolean; + focusRoot: boolean; + }; +} + +export interface FreedomTtyFrameMetadata { + version: number; + frame: number; + focusStack: string[]; + nodes: FreedomTtyNodeMetadata[]; +} + +/** Selector syntax, complexity, or strictness error from the spike extension. */ +export class FreedomTtyLocatorError extends Error { + readonly code = 'FREEDOM_TTY_LOCATOR'; +} + +function asRect(rect: FreedomTtyNodeMetadata['rect']): Rect | undefined { + if (!rect || !rect.every(Number.isFinite)) return undefined; + const [x, y, width, height] = rect; + return { + column: Math.floor(x), + row: Math.floor(y), + width: Math.ceil(width), + height: Math.ceil(height), + }; +} + +/** Parse every complete Freedom+tty metadata frame in an accumulated PTY stream. */ +export function parseFreedomTtyFrames(bytes: Uint8Array): FreedomTtyFrameMetadata[] { + const raw = Buffer.from(bytes).toString('latin1'), + frames: FreedomTtyFrameMetadata[] = []; + let offset = 0; + for (;;) { + const start = raw.indexOf(PREFIX, offset); + if (start < 0) break; + const bodyStart = start + PREFIX.length, + st = raw.indexOf('\u001b\\', bodyStart), + bel = raw.indexOf('\u0007', bodyStart), + end = st < 0 ? bel : bel < 0 ? st : Math.min(st, bel); + if (end < 0) break; + const body = raw.slice(bodyStart, end), + separator = body.indexOf(';'); + if (separator > 0) { + const envelopeVersion = Number(body.slice(0, separator)); + try { + const frame = JSON.parse( + Buffer.from(body.slice(separator + 1), 'base64url').toString('utf8'), + ) as FreedomTtyFrameMetadata; + if ( + frame.version === envelopeVersion && + Number.isInteger(frame.frame) && + Array.isArray(frame.nodes) + ) + frames.push(frame); + } catch { + // A production extension should retain a protocol diagnostic. The + // spike ignores malformed frames and keeps the latest complete one. + } + } + offset = end + (end === st ? 2 : 1); + } + return frames; +} + +interface SelectorNode extends FreedomTtyNodeMetadata { + parentNode: SelectorNode | null; + children: SelectorNode[]; +} + +interface SelectorDocument { + nodes: SelectorNode[]; + roots: SelectorNode[]; +} + +function materialize(frame: FreedomTtyFrameMetadata): SelectorDocument { + const nodes: SelectorNode[] = frame.nodes.map((node) => ({ + ...node, + parentNode: null, + children: [], + })); + const byKey = new Map(nodes.map((node) => [node.key, node])), + roots: SelectorNode[] = []; + for (const node of nodes) { + const parent = node.parent ? byKey.get(node.parent) : undefined; + if (parent) { + node.parentNode = parent; + parent.children.push(node); + } else { + roots.push(node); + } + } + for (const node of nodes) node.children.sort((left, right) => left.order - right.order); + return { nodes, roots }; +} + +function attributeValue(node: SelectorNode, name: string): string | undefined { + if (name === 'id') return node.key; + if (name === 'name') return node.name; + if (name === 'focused') return String(node.states.focused); + if (name === 'focus-root') return String(node.states.focusRoot); + const value = (node.attributes as Readonly>)[name]; + return value === undefined ? undefined : String(value); +} + +function textContent(node: SelectorNode): string { + return [node.attributes.label ?? '', ...node.children.map(textContent)].filter(Boolean).join(' '); +} + +const adapter: NonNullable['adapter']> = { + isTag: (node): node is SelectorNode => !!node, + getName: (node) => node.name || 'freedom-root', + getChildren: (node) => node.children, + getParent: (node) => node.parentNode, + getSiblings: (node) => node.parentNode?.children ?? [node], + prevElementSibling: (node) => { + const siblings = node.parentNode?.children ?? [node], + index = siblings.indexOf(node); + return index > 0 ? siblings[index - 1] : null; + }, + getAttributeValue: attributeValue, + hasAttrib: (node, name) => attributeValue(node, name) !== undefined, + getText: textContent, + removeSubsets: (nodes) => { + const selected = new Set(nodes); + return nodes.filter((node) => { + for (let parent = node.parentNode; parent; parent = parent.parentNode) + if (selected.has(parent)) return false; + return true; + }); + }, + equals: (left, right) => left.key === right.key, +}; + +const selectorOptions: Options = { + adapter, + xmlMode: true, + cacheResults: false, + pseudos: { + focus: (node) => node.states.focused, + 'focus-root': (node) => node.states.focusRoot, + visible: (node) => !!node.rect && node.rect[2] > 0 && node.rect[3] > 0, + }, +}; + +const allowedPseudos = new Set([ + 'not', + 'is', + 'where', + 'has', + 'root', + 'empty', + 'first-child', + 'last-child', + 'first-of-type', + 'last-of-type', + 'only-child', + 'only-of-type', + 'nth-child', + 'nth-last-child', + 'nth-of-type', + 'nth-last-of-type', + 'focus', + 'focus-root', + 'visible', +]); + +/** Parse and enforce the selector subset and complexity limits owned by this extension. */ +function validateSelector(source: string): Selector[][] { + if (Buffer.byteLength(source, 'utf8') > MAX_SELECTOR_BYTES) + throw new FreedomTtyLocatorError(`Selector exceeds ${MAX_SELECTOR_BYTES} bytes`); + let ast: Selector[][]; + try { + ast = parse(source); + } catch (error) { + throw new FreedomTtyLocatorError( + `Invalid selector ${JSON.stringify(source)}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + let tokens = 0, + lists = 0; + function visit(selectors: Selector[][], state: { depth: number; hasDepth: number }): void { + const { depth, hasDepth } = state; + if (depth > MAX_SELECTOR_DEPTH) + throw new FreedomTtyLocatorError(`Selector nesting exceeds ${MAX_SELECTOR_DEPTH}`); + lists += selectors.length; + if (lists > MAX_SELECTOR_LISTS) + throw new FreedomTtyLocatorError(`Selector lists exceed ${MAX_SELECTOR_LISTS}`); + for (const selector of selectors) { + for (const token of selector) { + if (++tokens > MAX_SELECTOR_TOKENS) + throw new FreedomTtyLocatorError(`Selector tokens exceed ${MAX_SELECTOR_TOKENS}`); + if (token.type === SelectorType.PseudoElement) + throw new FreedomTtyLocatorError('Pseudo-elements are not supported'); + if (token.type === SelectorType.Parent || token.type === SelectorType.ColumnCombinator) + throw new FreedomTtyLocatorError(`Selector traversal ${token.type} is not supported`); + if (token.type === SelectorType.Attribute && token.action === AttributeAction.Not) + throw new FreedomTtyLocatorError( + 'The nonstandard != attribute operator is not supported', + ); + if (token.type === SelectorType.Pseudo) { + if (!allowedPseudos.has(token.name)) + throw new FreedomTtyLocatorError(`Pseudo-class :${token.name} is not supported`); + if (token.name === 'has' && hasDepth >= MAX_HAS_DEPTH) + throw new FreedomTtyLocatorError(`Nested :has() exceeds depth ${MAX_HAS_DEPTH}`); + if (Array.isArray(token.data)) + visit(token.data, { + depth: depth + 1, + hasDepth: token.name === 'has' ? hasDepth + 1 : hasDepth, + }); + } + } + } + } + visit(ast, { depth: 0, hasDepth: 0 }); + return ast; +} + +/** Lazy CSS locator that re-runs a compiled selector against the newest semantic frame. */ +export class FreedomTtyLocator { + readonly selector: string; + readonly index: number | undefined; + readonly #predicate: (node: SelectorNode) => boolean; + + constructor( + readonly extension: FreedomTtyExtension, + options: { selector: string; index?: number }, + ) { + this.selector = options.selector; + this.index = options.index; + this.#predicate = compile( + validateSelector(this.selector), + selectorOptions, + ); + } + + matches(): readonly FreedomTtyNodeMetadata[] { + const document = this.extension.document(); + if (!document) return []; + const matches = document.nodes.filter(this.#predicate); + return this.index === undefined ? matches : matches[this.index] ? [matches[this.index]] : []; + } + + nth(index: number): FreedomTtyLocator { + if (!Number.isInteger(index) || index < 0) + throw new FreedomTtyLocatorError('Locator index must be a nonnegative integer'); + return new FreedomTtyLocator(this.extension, { selector: this.selector, index }); + } + + unique(): FreedomTtyNodeMetadata { + const matches = this.matches(); + if (matches.length !== 1) + throw new FreedomTtyLocatorError( + `Selector ${JSON.stringify(this.selector)} matched ${matches.length} semantic nodes`, + ); + return matches[0]; + } + + bounds(): Rect | undefined { + return asRect(this.unique().rect); + } + + region(): AsyncRegion { + const bounds = this.bounds(); + if (!bounds) + throw new FreedomTtyLocatorError( + `Selector ${JSON.stringify(this.selector)} matched a node without Clay geometry`, + ); + return this.extension.terminal.region(bounds); + } + + getByText(text: string, options?: TextLocatorOptions): AsyncLocator { + return this.region().getByText(text, options); + } + + containsCursor(): boolean { + const bounds = this.bounds(); + if (!bounds) return false; + const { cursor } = this.extension.terminal.screen.snapshot(); + return ( + cursor.column >= bounds.column && + cursor.column < bounds.column + bounds.width && + cursor.row >= bounds.row && + cursor.row < bounds.row + bounds.height + ); + } +} + +/** Minimal stand-in for a Ghostwright semantic-tree extension instance. */ +export class FreedomTtyExtension { + #rawLength = -1; + #frames: readonly FreedomTtyFrameMetadata[] = []; + #documentFrame = -1; + #document: SelectorDocument | undefined; + + constructor(readonly terminal: AsyncTerminal) {} + + frames(): readonly FreedomTtyFrameMetadata[] { + const raw = this.terminal.screen.rawOutput(); + if (raw.length !== this.#rawLength) { + this.#rawLength = raw.length; + this.#frames = parseFreedomTtyFrames(raw); + } + return this.#frames; + } + + current(): FreedomTtyFrameMetadata | undefined { + return this.frames().at(-1); + } + + document(): SelectorDocument | undefined { + const frame = this.current(); + if (!frame) return undefined; + if (frame.frame !== this.#documentFrame) { + this.#documentFrame = frame.frame; + this.#document = materialize(frame); + } + return this.#document; + } + + locator(selector: string): FreedomTtyLocator { + return new FreedomTtyLocator(this, { selector }); + } +} diff --git a/packages/freedom-react/examples/counter.tsx b/packages/freedom-react/examples/counter.tsx new file mode 100644 index 0000000..0a85807 --- /dev/null +++ b/packages/freedom-react/examples/counter.tsx @@ -0,0 +1,247 @@ +// oxlint-disable bombshell-dev/no-generic-error +// A counter rendered through the freedom-react reconciler to @bomb.sh/tty. +// +// Run it: pnpm --filter @bomb.sh/freedom-react counter +// +// JSX (//