From 607f16b6113376111bded65dfcc5250f718b0b45 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 2 Sep 2026 13:53:39 -0400 Subject: [PATCH] add Ghostwright semantic trees for clack/ui Add reusable terminal extension registration, key and style assertions, and artifact verification to Ghostwright. Add @ghostwright/clack-tty with its versioned protocol, render observer, CSS locators, package-declared activation, and outside-in hello-world journeys. Keep applications free of test instrumentation and remove structural tests that depended on the retired Freedom branch history. --- experiments/ghostwright/README.md | 53 ++- .../ghostwright/docs/agent-quickstart.md | 16 + .../ghostwright/docs/choosing-assertions.md | 30 ++ .../ghostwright/docs/interaction-recipes.md | 12 + experiments/ghostwright/package.json | 2 + .../ghostwright/scripts/build-ghostty-vt.ts | 18 +- .../ghostwright/scripts/update-manifest.ts | 184 +++---- .../ghostwright/scripts/verify-artifacts.ts | 33 +- .../ghostwright/src/assertions/index.ts | 143 +++++- .../src/assertions/types-internal.ts | 3 + .../ghostwright/src/effection/index.ts | 5 + experiments/ghostwright/src/errors.ts | 13 + experiments/ghostwright/src/index.ts | 2 + experiments/ghostwright/src/keys.ts | 86 ++++ experiments/ghostwright/src/styles.ts | 62 +++ .../ghostwright/src/terminal/extensions.ts | 134 ++++++ .../ghostwright/src/terminal/session.ts | 205 +++++++- experiments/ghostwright/src/terminal/wasm.ts | 17 +- experiments/ghostwright/src/types.ts | 97 ++++ .../ghostwright/test/extensions.test.ts | 58 +++ experiments/ghostwright/test/keys.test.ts | 68 +++ .../ghostwright/test/locator-style.test.ts | 153 ++++++ experiments/ghostwright/test/styles.test.ts | 91 ++++ package.json | 2 +- packages/clack-tty/package.json | 41 ++ packages/clack-tty/src/auto.ts | 32 ++ packages/clack-tty/src/expectations.ts | 42 ++ packages/clack-tty/src/extension.ts | 368 ++++++++++++++ packages/clack-tty/src/index.ts | 3 + packages/clack-tty/src/producer.ts | 232 +++++++++ packages/clack-tty/src/protocol.ts | 334 +++++++++++++ packages/clack-tty/test/e2e.test.ts | 155 ++++++ .../clack-tty/test/fixtures/no-semantic.ts | 34 ++ packages/clack-tty/test/locator.test.ts | 217 +++++++++ packages/clack-tty/test/protocol.test.ts | 245 ++++++++++ packages/clack-tty/test/structural.test.ts | 97 ++++ packages/clack-tty/tsconfig.json | 12 + packages/clack-tty/vitest.config.ts | 13 + packages/hello-world/package.json | 40 ++ packages/hello-world/src/hello-world.ts | 116 +++++ packages/hello-world/test/hello-world.test.ts | 104 ++++ packages/hello-world/vitest.config.ts | 11 + pnpm-lock.yaml | 447 +++++++++++++++++- 43 files changed, 3841 insertions(+), 189 deletions(-) create mode 100644 experiments/ghostwright/src/keys.ts create mode 100644 experiments/ghostwright/src/styles.ts create mode 100644 experiments/ghostwright/src/terminal/extensions.ts create mode 100644 experiments/ghostwright/test/extensions.test.ts create mode 100644 experiments/ghostwright/test/keys.test.ts create mode 100644 experiments/ghostwright/test/locator-style.test.ts create mode 100644 experiments/ghostwright/test/styles.test.ts create mode 100644 packages/clack-tty/package.json create mode 100644 packages/clack-tty/src/auto.ts create mode 100644 packages/clack-tty/src/expectations.ts create mode 100644 packages/clack-tty/src/extension.ts create mode 100644 packages/clack-tty/src/index.ts create mode 100644 packages/clack-tty/src/producer.ts create mode 100644 packages/clack-tty/src/protocol.ts create mode 100644 packages/clack-tty/test/e2e.test.ts create mode 100644 packages/clack-tty/test/fixtures/no-semantic.ts create mode 100644 packages/clack-tty/test/locator.test.ts create mode 100644 packages/clack-tty/test/protocol.test.ts create mode 100644 packages/clack-tty/test/structural.test.ts create mode 100644 packages/clack-tty/tsconfig.json create mode 100644 packages/clack-tty/vitest.config.ts create mode 100644 packages/hello-world/package.json create mode 100644 packages/hello-world/src/hello-world.ts create mode 100644 packages/hello-world/test/hello-world.test.ts create mode 100644 packages/hello-world/vitest.config.ts 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 db79eee..e750bbd 100644 --- a/experiments/ghostwright/src/assertions/index.ts +++ b/experiments/ghostwright/src/assertions/index.ts @@ -5,10 +5,42 @@ 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 bombshell-dev/max-params -- diagnostic needs all four params for failure reporting function diagnostic( session: TerminalSession, @@ -54,7 +86,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 +105,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 +150,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 +203,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 +275,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 +306,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 +317,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 504ed6f..61f1485 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 b5f416f..ff54463 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 4e77ccd..084d493 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 3e7d9da..6a7bd87 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "format": "bsh format", "format:check": "bsh format --check", "lint": "bsh lint .", - "test": "bsh test --exclude 'experiments/ghostwright/**'" + "test": "bsh test --exclude 'experiments/ghostwright/**' --exclude 'packages/**'" }, "devDependencies": { "@bomb.sh/args": "catalog:", diff --git a/packages/clack-tty/package.json b/packages/clack-tty/package.json new file mode 100644 index 0000000..6a864e5 --- /dev/null +++ b/packages/clack-tty/package.json @@ -0,0 +1,41 @@ +{ + "name": "@ghostwright/clack-tty", + "version": "0.1.0", + "description": "Semantic tree locator for clack/ui applications, tested with ghostwright", + "private": true, + "license": "MIT", + "type": "module", + "exports": { + ".": "./src/index.ts", + "./auto": "./src/auto.ts", + "./protocol": "./src/protocol.ts", + "./producer": "./src/producer.ts" + }, + "scripts": { + "test": "vitest run" + }, + "dependencies": { + "@bomb.sh/tty": "^0.8.0", + "@clack/ui": "workspace:*", + "css-select": "^7.0.0", + "css-what": "^8.0.0", + "ghostwright": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "tsx": "^4.19.0", + "vitest": "^4.1.9" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.7.0", + "onFail": "error" + }, + "runtime": { + "name": "node", + "version": "22.14.0", + "onFail": "error" + } + } +} diff --git a/packages/clack-tty/src/auto.ts b/packages/clack-tty/src/auto.ts new file mode 100644 index 0000000..bb1e901 --- /dev/null +++ b/packages/clack-tty/src/auto.ts @@ -0,0 +1,32 @@ +/** + * Activator entry: `@ghostwright/clack-tty/auto`. + * + * Declared in an application's package.json, husky-style: + * + * "@clack/ui": { "extensions": ["@ghostwright/clack-tty/auto"] } + * + * or handed to `createUI({ extensions: [semanticAuto] })` explicitly, or + * registered out-of-band by a launcher preload: + * + * import { registerUIExtension } from '@clack/ui/extensions'; + * import semanticAuto from '@ghostwright/clack-tty/auto'; + * registerUIExtension(semanticAuto); + * + * The extension installs the semantic producer only when the launcher sets + * `CLACK_UI_SEMANTIC=1`, so production runs of the same application load this + * package but never emit a frame. Exactly one activation channel should be + * used per application — mixing them double-installs the observer. + */ +import type { UIExtension } from '@clack/ui/extensions'; +import { useSemantic } from './producer.ts'; + +const semanticAuto: UIExtension = (context) => { + if (process.env.CLACK_UI_SEMANTIC !== '1') return; + useSemantic(context.host, { + // Read createUI's live surface at frame time. This honors both terminal + // resizes and explicit width/height options. + surface: () => ({ columns: context.width, rows: context.height }), + }); +}; + +export default semanticAuto; diff --git a/packages/clack-tty/src/expectations.ts b/packages/clack-tty/src/expectations.ts new file mode 100644 index 0000000..befaa9c --- /dev/null +++ b/packages/clack-tty/src/expectations.ts @@ -0,0 +1,42 @@ +/** + * Revision-driven assertion helpers for tree locators. Every wait re-arms on + * timeout because a wake-up can be lost when it races the subscribe window in + * ghostwright's `waitForChange`; no polling intervals, no sleeps. + */ +import { expectTerminal, type AsyncTerminal } from 'ghostwright'; +import type { ClackTtyLocator } from './extension.ts'; + +export async function expectTreeCondition( + terminal: AsyncTerminal, + condition: () => boolean, + description: string, + deadlineMs = 15000, +): Promise { + const deadline = Date.now() + deadlineMs; + for (;;) { + try { + return await expectTerminal(terminal).toSatisfy(condition, { + settleMs: 0, + timeoutMs: 1000, + }); + } catch { + if (Date.now() > deadline) { + throw new Error(`${description}: condition never converged`); + } + } + } +} + +export function expectFocused( + terminal: AsyncTerminal, + locator: ClackTtyLocator, +): Promise { + return expectTreeCondition( + terminal, + () => { + const matches = locator.matches(); + return matches.length === 1 && matches[0]!.states.focused; + }, + `${locator.source} to be focused`, + ); +} diff --git a/packages/clack-tty/src/extension.ts b/packages/clack-tty/src/extension.ts new file mode 100644 index 0000000..e51cd12 --- /dev/null +++ b/packages/clack-tty/src/extension.ts @@ -0,0 +1,368 @@ +/** + * Ghostwright terminal extension for the clack.ui semantic tree protocol, plus + * the tree-aware CSS locator (REQ-015..REQ-021). + * + * Architecture mirrors the retired freedom-tty consumer: strict decode with + * stable error codes, ordered revisions via the extension session context, a + * css-select evaluation over the materialized node tree, and the geometry -> + * screen-region bridge that scopes ghostwright's revision-driven assertions. + */ +import { compile, type Options } from 'css-select'; +import { AttributeAction, parse, SelectorType, type Selector } from 'css-what'; +import { GhostwrightError } from 'ghostwright'; +import type { + AsyncRegion, + AsyncTerminal, + ExtensionRevision, + ExtensionSessionContext, + Rect, + RegisteredOscMessage, + TerminalExtensionDefinition, + TextLocatorOptions, +} from 'ghostwright'; +import { + CLACK_TTY_NAMESPACE, + CLACK_TTY_OSC, + decodeFrame, + type ClackFrameV1, + type ClackNodeV1, + type Rect as ProtocolRect, +} from './protocol.ts'; + +export * from './protocol.ts'; + +const LIMITS = { + selectorBytes: 4096, + selectorTokens: 256, + selectorBranches: 32, + selectorDepth: 8, + hasDepth: 2, +} as const; +const utf8Bytes = (value: string) => new TextEncoder().encode(value).length; +const fail = (code: string, message: string): never => { + throw new GhostwrightError({ code, message: message.slice(0, 1024) }); +}; + +/** A resolved tree match: semantic data plus the bridge rect for screen scoping. */ +export interface TreeMatch extends ClackNodeV1 { + /** Cell rect used to scope screen assertions: `visible` when present, else `term`. */ + readonly range?: Rect; +} + +interface Element extends ClackNodeV1 { + parentNode: Element | null; + children: Element[]; +} + +function materialize(frame: ClackFrameV1): Element[] { + const nodes = frame.nodes.map((node) => ({ + ...node, + parentNode: null as Element | null, + children: [] as Element[], + })); + const byKey = new Map(nodes.map((node) => [node.key, node])); + for (const node of nodes) { + const parent = node.parent ? byKey.get(node.parent) : undefined; + if (parent) { + node.parentNode = parent; + parent.children.push(node); + } + } + for (const node of nodes) node.children.sort((a, b) => a.order - b.order); + return nodes; +} + +function attribute(node: Element, name: string): string | undefined { + if (name === 'id') return node.key; + // Boolean attributes follow CSS presence semantics: present only when true. + const boolean = + name === 'input' + ? node.attrs.input + : name === 'focusable' + ? node.attrs.focusable + : name === 'focused' + ? node.states.focused + : name === 'focus-root' + ? node.states.focusRoot + : undefined; + if (boolean !== undefined) return boolean ? 'true' : undefined; + const value = + name === 'role' + ? node.attrs.role + : name === 'label' + ? node.attrs.label + : name === 'type' + ? node.attrs.custom?.type + : name.startsWith('data-') + ? node.attrs.custom?.[name.slice(5)] + : undefined; + return value === undefined ? undefined : String(value); +} + +const adapter: NonNullable['adapter']> = { + isTag: (node): node is Element => !!node, + getName: (node) => node.name, + 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) : null; + }, + getAttributeValue: attribute, + hasAttrib: (node, name) => attribute(node, name) !== undefined, + getText: (node) => + [node.attrs.label ?? '', ...node.children.map((child) => adapter.getText(child))] + .filter(Boolean) + .join(' '), + removeSubsets: (nodes) => + nodes.filter( + (node) => + !nodes.some((candidate) => { + for (let parent = node.parentNode; parent; parent = parent.parentNode) + if (parent === candidate) return true; + return false; + }), + ), + equals: (left, right) => left.key === right.key, +}; + +const options: Options = { + adapter, + xmlMode: true, + cacheResults: false, + pseudos: { + focus: (node) => node.states.focused, + 'focus-root': (node) => node.states.focusRoot, + visible: (node) => !!node.geo?.visible, + }, +}; + +const allowedPseudos = new Set([ + 'not', + 'is', + 'where', + 'has', + 'root', + 'empty', + 'first-child', + 'last-child', + 'only-child', + 'first-of-type', + 'last-of-type', + 'nth-child', + 'nth-last-child', + 'nth-of-type', + 'nth-last-of-type', + 'focus', + 'focus-root', + 'visible', +]); + +/** Validate and compile a bounded selector (REQ-018). */ +function selector(source: string): Selector[][] { + if (utf8Bytes(source) > LIMITS.selectorBytes) + fail('GW_CLACK_SELECTOR_LIMIT', `Selector exceeds ${LIMITS.selectorBytes} bytes`); + let ast: Selector[][] = []; + try { + ast = parse(source); + } catch { + fail('GW_CLACK_SELECTOR_INVALID', 'Malformed semantic selector'); + } + let tokens = 0, + branches = 0; + const visit = (lists: Selector[][], depth: number, hasDepth: number) => { + if (depth > LIMITS.selectorDepth) + fail('GW_CLACK_SELECTOR_LIMIT', 'Selector nesting exceeds limit'); + branches += lists.length; + if (branches > LIMITS.selectorBranches) + fail('GW_CLACK_SELECTOR_LIMIT', 'Selector list exceeds limit'); + for (const list of lists) + for (const token of list) { + if (++tokens > LIMITS.selectorTokens) + fail('GW_CLACK_SELECTOR_LIMIT', 'Selector token limit exceeded'); + if (token.type === SelectorType.PseudoElement) + fail('GW_CLACK_SELECTOR_INVALID', 'Pseudo-elements are not supported'); + if (token.type === SelectorType.Parent || token.type === SelectorType.ColumnCombinator) + fail( + 'GW_CLACK_SELECTOR_INVALID', + `Selector traversal ${token.type} is not supported`, + ); + if (token.type === SelectorType.Attribute && token.action === AttributeAction.Not) + fail( + 'GW_CLACK_SELECTOR_INVALID', + 'The nonstandard != attribute operator is not supported', + ); + if (token.type === SelectorType.Pseudo) { + if (!allowedPseudos.has(token.name)) + fail( + 'GW_CLACK_SELECTOR_INVALID', + `Pseudo-class :${token.name} is not supported`, + ); + if (token.name === 'has' && hasDepth >= LIMITS.hasDepth) + fail('GW_CLACK_SELECTOR_LIMIT', `Nested :has() exceeds depth ${LIMITS.hasDepth}`); + if (Array.isArray(token.data)) + visit(token.data, depth + 1, token.name === 'has' ? hasDepth + 1 : hasDepth); + } + } + }; + visit(ast, 0, 0); + return ast; +} + +function bridgeRect(node: ClackNodeV1): ProtocolRect | undefined { + return node.geo?.visible ?? node.geo?.term; +} + +export class ClackTtyLocator { + readonly #predicate: (node: Element) => boolean; + readonly session: ClackTtySession; + readonly source: string; + readonly index: number | undefined; + constructor(session: ClackTtySession, source: string, index?: number) { + this.session = session; + this.source = source; + this.index = index; + this.#predicate = compile(selector(source), options); + } + /** Resolved tree matches, newest frame, document order (REQ-019). */ + matches(): readonly TreeMatch[] { + const nodes = this.session.document(); + const values = nodes.filter(this.#predicate); + const selected = + this.index === undefined ? values : values[this.index] ? [values[this.index]!] : []; + return Object.freeze( + selected.map((node) => { + const rect = bridgeRect(node); + return { + ...node, + ...(rect + ? { + range: { + column: rect.column, + row: rect.row, + width: rect.width, + height: rect.height, + } as Rect, + } + : {}), + } as TreeMatch; + }), + ); + } + unique(): TreeMatch { + const matches = this.matches(); + if (matches.length !== 1) + fail( + 'GW_CLACK_LOCATOR_STRICT', + `Selector ${JSON.stringify(this.source)} matched ${matches.length}: ${matches + .slice(0, 20) + .map((node) => `${node.key}/${node.name}`) + .join(', ')}`, + ); + return matches[0]!; + } + nth(index: number): ClackTtyLocator { + if (!Number.isSafeInteger(index) || index < 0) + fail('GW_CLACK_LOCATOR_RANGE', 'Locator index must be a nonnegative safe integer'); + return new ClackTtyLocator(this.session, this.source, index); + } + #regionBounds(): Rect { + const node = this.unique(); + const rect = node.range; + if (!rect) + fail( + 'GW_CLACK_NO_GEOMETRY', + `Selector ${JSON.stringify(this.source)} matched ${node.key}/${node.name} without geometry`, + ); + return rect; + } + /** Screen region scoped to the match's geometry (REQ-020). */ + region(): AsyncRegion { + return this.session.terminal.region(this.#regionBounds()); + } + /** Text assertion scoped to the match's on-screen rect (REQ-020). */ + getByText(textValue: string, textOptions?: TextLocatorOptions) { + return this.region().getByText(textValue, textOptions); + } +} + +export class ClackTtySession { + #current?: ClackFrameV1; + #revisions: ExtensionRevision[] = []; + #documentFrame = -1; + #document: Element[] = []; + readonly terminal: AsyncTerminal; + constructor(terminal: AsyncTerminal) { + this.terminal = terminal; + } + validateNext(frame: ClackFrameV1) { + if (this.#current && frame.frame !== this.#current.frame + 1) + fail( + 'GW_CLACK_FRAME', + `Semantic frame ${frame.frame} does not follow accepted frame ${this.#current.frame}`, + ); + } + setCurrent(frame: ClackFrameV1) { + this.#current = frame; + this.#documentFrame = -1; + } + record(revision: ExtensionRevision) { + this.#revisions.push(revision); + } + current() { + return this.#current; + } + frames() { + return Object.freeze(this.#revisions.map((revision) => revision.value)); + } + revisions() { + return Object.freeze([...this.#revisions]); + } + document(): readonly Element[] { + if (!this.#current) return []; + if (this.#documentFrame !== this.#current.frame) { + this.#document = materialize(this.#current); + this.#documentFrame = this.#current.frame; + } + return this.#document; + } + /** Tree-aware CSS locator against the newest accepted frame (REQ-017). */ + locator(source: string) { + return new ClackTtyLocator(this, source); + } +} + +/** Ghostwright extension definition for the clack.ui semantic tree (REQ-015). */ +export function clackTtyExtension(): TerminalExtensionDefinition< + ClackTtySession, + ClackFrameV1 +> { + return { + id: 'ghostwright.clack-tty', + osc: { + number: CLACK_TTY_OSC, + namespace: CLACK_TTY_NAMESPACE, + maxBufferedBytes: 1024 * 1024, + decode(message: RegisteredOscMessage) { + if (message.parameters.length !== 1 || message.parameters[0] !== 'v=1') + fail('GW_CLACK_VERSION', 'Unsupported semantic envelope version'); + return decodeFrame(message.payload); + }, + }, + createSession(context: ExtensionSessionContext) { + return new ClackTtySession(context.terminal); + }, + accept( + session: ClackTtySession, + frame: ClackFrameV1, + context: ExtensionSessionContext, + ) { + session.validateNext(frame); + session.setCurrent(frame); + const revision = context.publish({ protocolFrame: frame.frame, value: frame }); + session.record(revision); + }, + }; +} diff --git a/packages/clack-tty/src/index.ts b/packages/clack-tty/src/index.ts new file mode 100644 index 0000000..52e37f9 --- /dev/null +++ b/packages/clack-tty/src/index.ts @@ -0,0 +1,3 @@ +export { clackTtyExtension, ClackTtyLocator, ClackTtySession, type TreeMatch } from './extension.ts'; +export { useSemantic, type SemanticOptions } from './producer.ts'; +export { expectFocused, expectTreeCondition } from './expectations.ts'; diff --git a/packages/clack-tty/src/producer.ts b/packages/clack-tty/src/producer.ts new file mode 100644 index 0000000..b9081e5 --- /dev/null +++ b/packages/clack-tty/src/producer.ts @@ -0,0 +1,232 @@ +/** + * `useSemantic(host)` — the clack/ui producer plugin for the clack.ui semantic + * tree protocol (REQ-010..REQ-014). + * + * The semantic node map is maintained incrementally through clack/ui host + * middleware: elements join the map when they are attached (insertBefore), + * leave it when they are detached (removeChild), and structural state is never + * rebuilt by walking the host tree. Attribute values (`role`, `label`, + * `data-*`) ride the ordinary property channel and are read from the element's + * property bag at frame time; focus truth comes from clack/ui's focus API. + * + * Emission is opt-in and render-driven: `useSemantic` installs a render + * observer via `RenderApi.around`. Each committed render emits exactly one + * frame, written to the render chain's own output stream after that render's + * payload bytes. + */ +import type { RenderInfo } from '@bomb.sh/tty'; +import type { HostElement } from '@clack/ui/elements'; +import { FocusApi } from '@clack/ui/focus'; +import { HostApi, type Host } from '@clack/ui'; +import { RenderApi } from '@clack/ui/render'; +import { id } from '@clack/ui/core'; +import { + encodeFrame, + geometryFor, + LIMITS, + type ClackFrameV1, + type ClackNodeV1, + type JsonScalar, +} from './protocol.ts'; + +export interface Surface { + columns: number; + rows: number; + row?: number; +} + +export interface SemanticOptions { + /** Return the current render surface, in cells. */ + surface: () => Surface; + /** Called instead of emitting when a frame cannot be produced. */ + onDiagnostic?(error: Error): void; +} + +interface Entry { + key: string; + name: string; + node: object; + element: HostElement; + parent: Entry | null; + children: Entry[]; +} + +function collectAttached(element: HostElement, into: HostElement[]): void { + for (const child of element.children) { + if (child.type === 'element' && child.node) { + into.push(child); + collectAttached(child, into); + } + } +} + +/** Sibling order among element children, read from the host's own child list. */ +function siblingOrder(entry: Entry): number { + const siblings = entry.element.parent?.children ?? []; + let order = 0; + for (const child of siblings) { + if (child === entry.element) return order; + if (child.type === 'element') order++; + } + return order; +} + +export function useSemantic(host: Host, options: SemanticOptions): void { + const entries = new Map(); + + function entryOf(element: HostElement): Entry | undefined { + return element.node ? entries.get(element.node) : undefined; + } + + function register(element: HostElement): void { + if (!element.node || entries.has(element.node)) return; + const entry: Entry = { + key: id(element.node), + name: element.name, + node: element.node, + element, + parent: element.parent ? (entryOf(element.parent) ?? null) : null, + children: [], + }; + entries.set(element.node, entry); + entry.parent?.children.push(entry); + for (const child of element.children) { + if (child.type === 'element') register(child); + } + } + + function unregisterEntry(entry: Entry): void { + for (const child of entry.children) unregisterEntry(child); + entries.delete(entry.node); + if (entry.parent) { + const index = entry.parent.children.indexOf(entry); + if (index >= 0) entry.parent.children.splice(index, 1); + } + } + + HostApi.around(host.root, { + insertBefore([_node, _parent, child], next) { + next(_node, _parent, child); + if (child.type === 'element') register(child); + }, + removeChild([_node, _parent, child], next) { + // Capture the entry BEFORE the core removal: destroy() nulls element.node, + // so the lookup must happen while the node is still live. + const removed = child.type === 'element' && child.node ? entries.get(child.node) : undefined; + next(_node, _parent, child); + if (removed) unregisterEntry(removed); + }, + // Structural hooks only: attribute values ride the element property bag, + // which the host core keeps current. Registered so the middleware contract + // (create/insert/remove/setProperty/setText) is complete in one place. + setProperty([node, element, name, value], next) { + next(node, element, name, value); + }, + setText([node, text, content], next) { + next(node, text, content); + }, + }); + + // Adopt elements the application attached before the plugin installed. + const attached: HostElement[] = []; + collectAttached(host.element, attached); + for (const element of attached) register(element); + + // Sample once per frame so geometry and frame metadata cannot disagree. + const deriveSurface = (): { columns: number; rows: number; row: number } => { + const surface = options.surface(); + return { columns: surface.columns, rows: surface.rows, row: surface.row ?? 1 }; + }; + + function focusStack(): string[] { + const focus = FocusApi.methods.getFocus(host.root); + return focus === host.root ? [] : [id(focus)]; + } + + function buildNodes( + info: RenderInfo, + surface: { columns: number; rows: number; row: number }, + ): ClackNodeV1[] { + const focusNode = FocusApi.methods.getFocus(host.root); + const nodes: ClackNodeV1[] = []; + + function visit(entry: Entry, parentKey: string | null, order: number): void { + const focusable = FocusApi.methods.isFocusable(entry.node); + const focused = entry.node === focusNode; + const custom: Record = {}; + let role: string | undefined, + label: string | undefined; + for (const [name, value] of Object.entries(entry.element.properties)) { + if (name === 'role' && typeof value === 'string') role = value; + else if (name === 'label' && typeof value === 'string') label = value; + else if (name === 'type' && typeof value === 'string') custom.type = value; + else if (name.startsWith('data-') && value !== null && value !== undefined) + custom[name.slice(5)] = value as JsonScalar; + } + const bounds = info.get(entry.key)?.bounds; + const geo = bounds + ? geometryFor( + { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + surface, + ) + : undefined; + nodes.push({ + key: entry.key, + name: entry.name, + parent: parentKey, + order, + attrs: { + ...(role !== undefined ? { role } : {}), + ...(label !== undefined ? { label } : {}), + ...(entry.name === 'input' ? { input: true } : {}), + focusable, + ...(Object.keys(custom).length > 0 ? { custom } : {}), + }, + states: { focused, focusRoot: focused }, + ...(geo !== undefined ? { geo } : {}), + }); + entry.children.forEach((child, index) => visit(child, entry.key, index)); + } + + for (const entry of entries.values()) { + if (entry.parent === null) visit(entry, null, siblingOrder(entry)); + } + return nodes; + } + + let frameCounter = 0; + + function emit(info: RenderInfo, output: { write(chunk: Uint8Array): unknown }): void { + try { + const surface = deriveSurface(); + const frame: ClackFrameV1 = { + v: 1, + frame: ++frameCounter, + surface, + focusStack: focusStack(), + nodes: buildNodes(info, surface), + }; + if (frame.nodes.length > LIMITS.nodes) { + options.onDiagnostic?.( + new Error(`Semantic frame exceeds ${LIMITS.nodes} nodes; emission skipped`), + ); + return; + } + output.write(encodeFrame(frame)); + } catch (error) { + // A semantic failure is a diagnostic, never a broken paint. + options.onDiagnostic?.(error as Error); + } + } + + // Render observer: `next` runs the remaining chain (strategy facade, core); + // the core has written the payload bytes by the time it returns, so frames + // always follow the payload bytes of their render. + RenderApi.around(host.root, { + render([_node, output, term, ops], next) { + const result = next(_node, output, term, ops); + if (result) emit(result.info, output); + return result; + }, + }); +} diff --git a/packages/clack-tty/src/protocol.ts b/packages/clack-tty/src/protocol.ts new file mode 100644 index 0000000..8fe19f8 --- /dev/null +++ b/packages/clack-tty/src/protocol.ts @@ -0,0 +1,334 @@ +/** + * Wire protocol for the clack.ui semantic tree: OSC `7777;clack.ui;v=1;ST`. + * + * Version 1 is independent of the retired FreedomTtyFrameV1. It keeps the spike's + * lessons: versioned envelopes, bounded payloads, strict fail-closed validation, + * and honest geometry (authoritative bounds only, never guessed). + * + * Schema reference: .pi/specs/ghostwright-clack-tty-spec.md (REQ-006..REQ-009). + */ +import { GhostwrightError } from 'ghostwright'; + +export const CLACK_TTY_OSC = 7777; +export const CLACK_TTY_NAMESPACE = 'clack.ui'; +export const CLACK_TTY_VERSION = 1; + +/** Hard limits enforced before allocation on both producer and consumer sides (REQ-008). */ +export const LIMITS = { + payloadBytes: 512 * 1024, + nodes: 4096, + key: 256, + name: 256, + attribute: 1024, + depth: 128, +} as const; + +export interface FloatRect { + readonly x: number; + readonly y: number; + readonly width: number; + readonly height: number; +} + +export interface Rect { + readonly column: number; + readonly row: number; + readonly width: number; + readonly height: number; +} + +export type JsonScalar = string | number | boolean | null; + +export interface ClackNodeAttrs { + readonly role?: string; + readonly label?: string; + readonly input?: boolean; + readonly focusable: boolean; + readonly custom?: Readonly>; +} + +export interface ClackNodeStates { + readonly focused: boolean; + readonly focusRoot: boolean; +} + +export interface ClackNodeGeometry { + readonly layout: FloatRect; + readonly term: Rect; + readonly visible?: Rect; +} + +export interface ClackNodeV1 { + readonly key: string; + readonly name: string; + readonly parent: string | null; + readonly order: number; + readonly attrs: ClackNodeAttrs; + readonly states: ClackNodeStates; + readonly geo?: ClackNodeGeometry; +} + +export interface ClackFrameV1 { + readonly v: 1; + readonly frame: number; + readonly surface: Readonly<{ columns: number; rows: number; row: number }>; + readonly focusStack: readonly string[]; + readonly nodes: readonly ClackNodeV1[]; +} + +const utf8 = new TextEncoder(); +const fail = (code: string, message: string): never => { + throw new GhostwrightError({ code, message: message.slice(0, 1024) }); +}; +const isScalar = (value: unknown): value is JsonScalar => + value === null || + typeof value === 'string' || + typeof value === 'boolean' || + (typeof value === 'number' && Number.isFinite(value)); + +/** Encode a semantic frame into its registered OSC byte sequence (REQ-005). */ +export function encodeFrame(frame: ClackFrameV1): Uint8Array { + const json = JSON.stringify(validateFrame(frame)); + const bytes = utf8.encode(json); + if (bytes.length > LIMITS.payloadBytes) + fail( + 'GW_CLACK_LIMIT', + `Semantic frame payload is ${bytes.length} bytes, over the ${LIMITS.payloadBytes} byte limit`, + ); + const payload = Buffer.from(json).toString('base64url'); + return Buffer.from(`\u001b]${CLACK_TTY_OSC};${CLACK_TTY_NAMESPACE};v=1;${payload}\u001b\\`); +} + +/** Decode a registered OSC payload into a validated frame (REQ-016). */ +export function decodeFrame(payload: Uint8Array): ClackFrameV1 { + const source = Buffer.from(payload).toString('ascii'); + if (!/^[A-Za-z0-9_-]*$/.test(source)) + fail('GW_CLACK_BASE64', 'Semantic payload is not unpadded base64url'); + let decoded: Buffer; + try { + decoded = Buffer.from(source, 'base64url'); + } catch { + fail('GW_CLACK_BASE64', 'Semantic payload cannot be decoded'); + } + let parsed: unknown; + try { + parsed = JSON.parse(decoded.toString('utf8')); + } catch { + fail('GW_CLACK_BASE64', 'Semantic payload is not valid UTF-8 JSON'); + } + return validateFrame(parsed); +} + +/** Validate an already-parsed frame against the v1 schema and limits (REQ-006, REQ-008). */ +export function validateFrame(input: unknown): ClackFrameV1 { + if (!input || typeof input !== 'object' || Array.isArray(input)) + fail('GW_CLACK_SCHEMA', 'Semantic frame must be an object'); + const frame = input as Record; + if (frame.v !== CLACK_TTY_VERSION) + fail('GW_CLACK_VERSION', `Unsupported semantic frame version: ${String(frame.v)}`); + if (!Number.isSafeInteger(frame.frame) || (frame.frame as number) <= 0) + fail('GW_CLACK_SCHEMA', 'Frame number must be a positive safe integer'); + const surface = frame.surface as Record | undefined; + if ( + !surface || + !Number.isInteger(surface.columns) || + !Number.isInteger(surface.rows) || + !Number.isInteger(surface.row) || + (surface.columns as number) <= 0 || + (surface.rows as number) <= 0 || + (surface.row as number) <= 0 + ) + fail('GW_CLACK_SCHEMA', 'Invalid render surface'); + const focusStack = frame.focusStack; + if (!Array.isArray(focusStack) || !focusStack.every((key) => typeof key === 'string')) + fail('GW_CLACK_SCHEMA', 'Invalid focus stack'); + if (!Array.isArray(frame.nodes)) fail('GW_CLACK_SCHEMA', 'Invalid semantic node list'); + const rawNodes = frame.nodes as unknown[]; + if (rawNodes.length > LIMITS.nodes) + fail('GW_CLACK_LIMIT', `Semantic frame exceeds ${LIMITS.nodes} nodes`); + const nodes = rawNodes.map((raw, index) => validateNode(raw, index)); + validateTree(nodes); + return { + v: 1, + frame: frame.frame as number, + surface: { + columns: surface.columns as number, + rows: surface.rows as number, + row: surface.row as number, + }, + focusStack: Object.freeze([...(focusStack as string[])]), + nodes: Object.freeze(nodes), + }; +} + +function validateNode(raw: unknown, index: number): ClackNodeV1 { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) + fail('GW_CLACK_SCHEMA', `Node ${index} must be an object`); + const node = raw as Record; + const key = stringField(node.key, `node ${index} key`, LIMITS.key); + const name = stringField(node.name, `node ${index} name`, LIMITS.name); + if (node.parent !== null && typeof node.parent !== 'string') + fail('GW_CLACK_SCHEMA', `Node ${key} has an invalid parent`); + if (!Number.isInteger(node.order) || (node.order as number) < 0) + fail('GW_CLACK_SCHEMA', `Node ${key} has an invalid sibling order`); + const attrs = node.attrs as Record | undefined; + if (!attrs || typeof attrs.focusable !== 'boolean') + fail('GW_CLACK_SCHEMA', `Node ${key} has invalid attributes`); + if (attrs.role !== undefined) stringField(attrs.role, `node ${key} role`, LIMITS.attribute); + if (attrs.label !== undefined) stringField(attrs.label, `node ${key} label`, LIMITS.attribute); + if (attrs.input !== undefined && typeof attrs.input !== 'boolean') + fail('GW_CLACK_SCHEMA', `Node ${key} has an invalid input attribute`); + let custom: Record | undefined; + if (attrs.custom !== undefined) { + if (!attrs.custom || typeof attrs.custom !== 'object' || Array.isArray(attrs.custom)) + fail('GW_CLACK_SCHEMA', `Node ${key} has invalid custom attributes`); + custom = {}; + for (const [name, value] of Object.entries(attrs.custom as Record)) { + if (typeof name !== 'string' || name.length === 0 || utf8.encode(name).length > LIMITS.key) + fail('GW_CLACK_SCHEMA', `Node ${key} has an invalid custom attribute name`); + if (!isScalar(value)) + fail('GW_CLACK_SCHEMA', `Node ${key} custom attribute ${name} is not a scalar`); + if (typeof value === 'string' && utf8.encode(value).length > LIMITS.attribute) + fail('GW_CLACK_SCHEMA', `Node ${key} custom attribute ${name} exceeds the value limit`); + custom[name] = value as JsonScalar; + } + } + const states = node.states as Record | undefined; + if ( + !states || + typeof states.focused !== 'boolean' || + typeof states.focusRoot !== 'boolean' + ) + fail('GW_CLACK_SCHEMA', `Node ${key} has invalid states`); + return { + key, + name, + parent: node.parent === null ? null : (node.parent as string), + order: node.order as number, + attrs: { + ...(attrs.role !== undefined ? { role: attrs.role as string } : {}), + ...(attrs.label !== undefined ? { label: attrs.label as string } : {}), + ...(attrs.input !== undefined ? { input: attrs.input as boolean } : {}), + focusable: attrs.focusable as boolean, + ...(custom !== undefined ? { custom } : {}), + }, + states: { focused: states.focused as boolean, focusRoot: states.focusRoot as boolean }, + ...(node.geo !== undefined ? { geo: validateGeometry(node.geo, key) } : {}), + }; +} + +function validateGeometry(raw: unknown, key: string): ClackNodeGeometry { + if (!raw || typeof raw !== 'object') + fail('GW_CLACK_SCHEMA', `Node ${key} has invalid geometry`); + const geo = raw as Record; + const layout = floatRect(geo.layout, key, 'layout'); + const term = cellRect(geo.term, key, 'term'); + const visible = + geo.visible === undefined ? undefined : cellRect(geo.visible, key, 'visible'); + return { layout, term, ...(visible !== undefined ? { visible } : {}) }; +} + +function floatRect(raw: unknown, key: string, field: string): FloatRect { + if (!raw || typeof raw !== 'object') + fail('GW_CLACK_SCHEMA', `Node ${key} has invalid ${field} geometry`); + const rect = raw as Record; + for (const edge of ['x', 'y', 'width', 'height']) + if (typeof rect[edge] !== 'number' || !Number.isFinite(rect[edge])) + fail('GW_CLACK_SCHEMA', `Node ${key} has invalid ${field} ${edge}`); + if ((rect.width as number) < 0 || (rect.height as number) < 0) + fail('GW_CLACK_SCHEMA', `Node ${key} has a negative ${field} size`); + return { + x: rect.x as number, + y: rect.y as number, + width: rect.width as number, + height: rect.height as number, + }; +} + +function cellRect(raw: unknown, key: string, field: string): Rect { + if (!raw || typeof raw !== 'object') + fail('GW_CLACK_SCHEMA', `Node ${key} has invalid ${field} geometry`); + const rect = raw as Record; + for (const edge of ['column', 'row', 'width', 'height']) + if (!Number.isInteger(rect[edge])) + fail('GW_CLACK_SCHEMA', `Node ${key} has invalid ${field} ${edge}`); + if ((rect.width as number) < 0 || (rect.height as number) < 0) + fail('GW_CLACK_SCHEMA', `Node ${key} has a negative ${field} size`); + return { + column: rect.column as number, + row: rect.row as number, + width: rect.width as number, + height: rect.height as number, + }; +} + +function stringField(value: unknown, what: string, limit: number): string { + if (typeof value !== 'string' || value.length === 0) + fail('GW_CLACK_SCHEMA', `${what} must be a non-empty string`); + if (utf8.encode(value).length > limit) + fail('GW_CLACK_LIMIT', `${what} exceeds ${limit} bytes`); + return value; +} + +/** Reject duplicate keys and parent links that do not form an acyclic tree within the depth limit (REQ-008). */ +function validateTree(nodes: readonly ClackNodeV1[]): void { + const byKey = new Map(); + for (const node of nodes) { + if (byKey.has(node.key)) + fail('GW_CLACK_SCHEMA', `Duplicate semantic node key ${node.key}`); + byKey.set(node.key, node); + } + for (const node of nodes) { + let current = node.parent ? byKey.get(node.parent) : undefined; + const seen = new Set([node.key]); + let depth = 0; + while (current) { + if (++depth > LIMITS.depth) + fail('GW_CLACK_LIMIT', `Tree exceeds the depth limit of ${LIMITS.depth}`); + if (seen.has(current.key)) + fail('GW_CLACK_SCHEMA', `Node ${node.key} participates in a parent cycle`); + seen.add(current.key); + current = current.parent ? byKey.get(current.parent) : undefined; + } + } +} + +/** + * Clay-compatible edge truncation from authoritative float bounds, deliberately + * not `floor(origin) + ceil(size)` (carried from the retired freedom producer). + * `surface.row` is 1-based; the result is in 1-based terminal cell space. + */ +export function geometryFor( + layoutBounds: FloatRect, + surface: { columns: number; rows: number; row?: number }, +): { layout: FloatRect; term: Rect; visible?: Rect } { + const trunc = (value: number) => (value < 0 ? Math.ceil(value) : Math.floor(value)); + const originRow = (surface.row ?? 1) - 1; + const left = trunc(layoutBounds.x), + right = trunc(layoutBounds.x + layoutBounds.width); + const top = trunc(layoutBounds.y) + originRow, + bottom = trunc(layoutBounds.y + layoutBounds.height) + originRow; + const term: Rect = { + column: left, + row: top, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + }; + const viewport: Rect = { column: 0, row: 0, width: surface.columns, height: surface.rows }; + const visible = intersect(term, viewport); + return { + layout: { ...layoutBounds }, + term, + ...(visible !== undefined ? { visible } : {}), + }; +} + +export function intersect(a: Rect, b: Rect): Rect | undefined { + const left = Math.max(a.column, b.column), + top = Math.max(a.row, b.row); + const right = Math.min(a.column + a.width, b.column + b.width), + bottom = Math.min(a.row + a.height, b.row + b.height); + return right > left && bottom > top + ? { column: left, row: top, width: right - left, height: bottom - top } + : undefined; +} diff --git a/packages/clack-tty/test/e2e.test.ts b/packages/clack-tty/test/e2e.test.ts new file mode 100644 index 0000000..5011e80 --- /dev/null +++ b/packages/clack-tty/test/e2e.test.ts @@ -0,0 +1,155 @@ +import { readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { expect, test } from 'vitest'; +import { expectTerminal, withTerminalAsync } from 'ghostwright'; +import { + clackTtyExtension, + expectFocused, + expectTreeCondition, + type ClackTtySession, +} from '../src/index.ts'; + +// The demo application is a separate package that only imports clack/ui; +// semantic emission activates via the extension declared in its package.json +// plus the launcher environment. This suite exercises extension mechanics: +// frame ordering, counts, geometry honesty, and opt-in behavior. User-journey +// tests in selector syntax live in packages/hello-world/test. +const demoRoot = new URL('../../hello-world', import.meta.url).pathname; +const extension = clackTtyExtension(); + +const entry = (...extra: string[]) => ({ + command: process.execPath, + args: ['--import', 'tsx', 'src/hello-world.ts', ...extra], + cwd: demoRoot, + viewport: { columns: 80, rows: 24 }, + env: { CLACK_UI_SEMANTIC: '1' }, + trace: 'off' as const, + extensions: [extension], +}); + +test('idle app emits no frames; typing emits frames per render (TC-I2, TC-I3, REQ-011/REQ-015)', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const semantic = terminal.extension(extension) as ClackTtySession; + await expectTerminal(terminal.getByText('Hello, World!')).toBeStable(); + + // Screen is stable and the app is idle: no additional frames arrive while + // the screen stays unchanged (settle-driven, no sleeps). + const before = semantic.frames().length; + await expectTerminal(terminal).toSatisfy( + (snapshot) => snapshot.lastVisualChangeAt > 0 && semantic.frames().length === before, + { settleMs: 150 }, + ); + expect(semantic.frames().length).toBe(before); + + await terminal.keyboard.type('H'); + // Each committed render emits exactly one frame (a keystroke may commit + // more than one render: the input model and the listening update). + await expectTreeCondition( + terminal, + () => semantic.frames().length >= before + 1, + 'frames advance with renders', + ); + expect(semantic.frames().length).toBeGreaterThanOrEqual(before + 1); + + // Frames advance strictly by one and revisions correlate in order. + const numbers = semantic.frames().map((frame) => frame.frame); + expect(numbers).toEqual(numbers.map((_, index) => index + 1)); + const revisions = semantic.revisions(); + expect(revisions.map((revision) => revision.protocolFrame)).toEqual(numbers); + const screenSequences = revisions.map((revision) => revision.screenSequence); + expect([...screenSequences].sort((a, b) => a - b)).toEqual(screenSequences); + }); +}); + +test('focus states derive from the frame; exactly one focused node (TC-I6, REQ-013)', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const semantic = terminal.extension(extension) as ClackTtySession; + await expectTerminal(terminal.getByText('Hello, World!')).toBeStable(); + + const first = semantic.current(); + expect(first?.focusStack).toHaveLength(1); + const focused = first?.nodes.filter((node) => node.states.focused) ?? []; + expect(focused).toHaveLength(1); + expect(focused[0]!.key).toBe(first!.focusStack[0]); + expect(focused[0]!.name).toBe('input'); + + await terminal.keyboard.press('Tab'); + await expectTreeCondition( + terminal, + () => { + const frame = semantic.current(); + const focused = frame?.nodes.filter((node) => node.states.focused) ?? []; + return focused.length === 1 && focused[0]!.name === 'input' && frame!.focusStack.length === 1 + ? focused[0]!.key !== first!.focusStack[0] + : false; + }, + 'focus moved to the second input', + ); + }); +}); + +test('geometry matches the on-screen rects; attribute updates flow through (TC-I4, TC-I7, REQ-007/REQ-012)', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const semantic = terminal.extension(extension) as ClackTtySession; + await expectTerminal(terminal.getByText('Hello, World!')).toBeStable(); + + const frame = semantic.current(); + const group = frame!.nodes.find((node) => node.attrs.label === 'hello'); + expect(group?.geo?.term).toEqual({ column: 0, row: 0, width: 40, height: 8 }); + + // The say input's rect: verify with screen text via the region bridge — + // the greeting text lives outside the input rect, so region scoping must + // NOT find it there (negative, bounded). + const say = semantic.locator('input[label="say"]'); + await expect( + expectTerminal(say.getByText('Hello, World!'), { timeoutMs: 600 } as never).toBePresent(), + ).rejects.toThrow(); + void say; + }); +}); + +test('opt-in emission: no declaration, no env, no OSC (TC-I5, REQ-014)', async () => { + const noSemantic = { + command: process.execPath, + args: ['--import', 'tsx', 'test/fixtures/no-semantic.ts'], + cwd: new URL('..', import.meta.url).pathname, + viewport: { columns: 80, rows: 24 }, + trace: 'off' as const, + extensions: [extension], + }; + await withTerminalAsync(noSemantic, async (terminal) => { + const semantic = terminal.extension(extension) as ClackTtySession; + await expectTerminal(terminal.getByText('Plain hello')).toBeStable(); + await terminal.keyboard.press('Tab'); + expect(semantic.frames()).toHaveLength(0); + expect(semantic.current()).toBeUndefined(); + }); +}); + +test('frames follow their visual bytes in the raw stream (TC-I1, REQ-005)', async () => { + const capture = join(tmpdir(), `clack-tty-capture-${process.pid}.bin`); + await withTerminalAsync(entry('--teed', capture), async (terminal) => { + const semantic = terminal.extension(extension) as ClackTtySession; + await expectTerminal(terminal.getByText('Hello, World!')).toBeStable(); + await terminal.keyboard.type('Hi'); + await expectTreeCondition(terminal, () => semantic.frames().length >= 3, 'at least three frames'); + }); + const raw = readFileSync(capture, 'latin1'); + const altScreen = raw.indexOf('\u001b[?1049h'); + const greeting = raw.indexOf('Hello, World!'); + const framePositions: number[] = []; + let index = raw.indexOf('\u001b]7777;clack.ui;v=1;'); + while (index >= 0) { + framePositions.push(index); + index = raw.indexOf('\u001b]7777;clack.ui;v=1;', index + 1); + } + expect(framePositions.length).toBeGreaterThanOrEqual(3); + for (const position of framePositions) { + // Every frame begins after the visual bytes of its render: after the + // alternate-screen setup and after the greeting has been drawn at least + // once by the frame that preceded it. + expect(position).toBeGreaterThan(altScreen); + } + expect(framePositions[0]!).toBeGreaterThan(greeting); +}); diff --git a/packages/clack-tty/test/fixtures/no-semantic.ts b/packages/clack-tty/test/fixtures/no-semantic.ts new file mode 100644 index 0000000..a81a109 --- /dev/null +++ b/packages/clack-tty/test/fixtures/no-semantic.ts @@ -0,0 +1,34 @@ +/** + * Negative fixture: a clack/ui application WITHOUT the semantic plugin. + * Used to prove emission is opt-in (REQ-014) — no clack.ui OSC may appear. + */ +import { stdin, stdout } from 'node:process'; +import { fixed, rgba } from '@bomb.sh/tty'; +import { createUI } from '@clack/ui'; + +const blue = rgba(0, 0, 238); +const cyan = rgba(0, 205, 205); + +const ui = await createUI({ + input: stdin, + output: stdout, + width: stdout.columns || 80, + height: stdout.rows || 24, +}); +const { host } = ui; + +const output = host.createElement('text'); +host.setProperty(output, 'color', cyan); +host.insertBefore(output, host.createLiteral('Plain hello')); + +const app = host.createElement('box'); +host.setProperty(app, 'layout', { + direction: 'ttb', + padding: { top: 1, bottom: 1, left: 2, right: 2 }, + width: fixed(40), +}); +host.setProperty(app, 'border', { color: blue, top: 1, right: 1, bottom: 1, left: 1 }); +host.insertBefore(app, output); +host.insertBefore(host.element, app); + +await ui.main(); diff --git a/packages/clack-tty/test/locator.test.ts b/packages/clack-tty/test/locator.test.ts new file mode 100644 index 0000000..9efa71f --- /dev/null +++ b/packages/clack-tty/test/locator.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from 'vitest'; +import type { ExtensionSessionContext, ExtensionRevision } from 'ghostwright'; +import { clackTtyExtension, ClackTtySession, type TreeMatch } from '../src/extension.ts'; +import type { ClackFrameV1, ClackNodeV1 } from '../src/protocol.ts'; + +function node(overrides: Partial = {}): ClackNodeV1 { + return { + key: '1', + name: 'box', + parent: null, + order: 0, + attrs: { focusable: false }, + states: { focused: false, focusRoot: false }, + ...overrides, + }; +} + +const geo = { layout: { x: 0, y: 0, width: 40, height: 8 }, term: { column: 0, row: 0, width: 40, height: 8 } }; + +const demoFrame: ClackFrameV1 = { + v: 1, + frame: 1, + surface: { columns: 80, rows: 24, row: 1 }, + focusStack: ['5'], + nodes: [ + node({ key: '1', attrs: { role: 'group', label: 'hello', focusable: false }, geo }), + node({ key: '2', name: 'text', parent: '1', order: 0 }), + node({ key: '3', name: 'box', parent: '1', order: 1 }), + node({ key: '4', name: 'box', parent: '3', order: 0 }), + node({ + key: '5', + name: 'input', + parent: '3', + order: 1, + attrs: { role: 'textbox', label: 'say', input: true, focusable: true }, + states: { focused: true, focusRoot: true }, + geo: { layout: { x: 2, y: 6, width: 10, height: 3 }, term: { column: 2, row: 6, width: 10, height: 3 } }, + }), + node({ + key: '6', + name: 'input', + parent: '3', + order: 2, + attrs: { role: 'textbox', label: 'to', input: true, focusable: true }, + }), + node({ key: '7', name: 'box', parent: '1', order: 2, attrs: { focusable: false, custom: { kind: 'meta' } } }), + ], +}; + +function sessionWith(frame: ClackFrameV1): ClackTtySession { + const extension = clackTtyExtension(); + let sequence = 0; + const context: ExtensionSessionContext = { + terminal: {} as ExtensionSessionContext['terminal'], + screen: {} as ExtensionSessionContext['screen'], + publish(commit): ExtensionRevision { + return { + sequence: ++sequence, + timestamp: 0, + extensionId: 'test', + protocolFrame: commit.protocolFrame, + screenSequence: 0, + value: commit.value, + }; + }, + diagnostic() {}, + }; + const session = extension.createSession(context); + extension.accept(session, frame, context); + return session; +} + +describe('selector evaluation over a crafted tree (TC-U5, REQ-017)', () => { + const session = sessionWith(demoFrame); + + test('tag selectors match element names', () => { + expect(session.locator('input').matches().map((match) => match.key)).toEqual(['5', '6']); + }); + + test('attribute selectors expose semantic attributes', () => { + expect(session.locator('[role="textbox"]').matches().map((match) => match.key)).toEqual(['5', '6']); + expect(session.locator('[label="say"]').matches().map((match) => match.key)).toEqual(['5']); + expect(session.locator('input[input]').matches().map((match) => match.key)).toEqual(['5', '6']); + expect(session.locator('[focusable]').matches().map((match) => match.key)).toEqual(['5', '6']); + expect(session.locator('[focused]').matches().map((match) => match.key)).toEqual(['5']); + expect(session.locator('[focus-root]').matches().map((match) => match.key)).toEqual(['5']); + expect(session.locator('[data-kind="meta"]').matches().map((match) => match.key)).toEqual(['7']); + }); + + test('combinators resolve over parent/order links', () => { + expect(session.locator('box > text').matches().map((match) => match.key)).toEqual(['2']); + expect(session.locator('box[label="hello"] > box > input[label="to"]').matches().map((match) => match.key)).toEqual(['6']); + expect(session.locator('input[label="say"] + input').matches().map((match) => match.key)).toEqual(['6']); + expect(session.locator('box[label="hello"] input').matches().map((match) => match.key)).toEqual(['5', '6']); + }); + + test('focus pseudos mirror the attribute form', () => { + expect(session.locator('input:focus').matches().map((match) => match.key)).toEqual(['5']); + }); + + test('zero matches yield an empty list', () => { + expect(session.locator('input[label="nope"]').matches()).toEqual([]); + }); +}); + +describe('match semantics and diagnostics (TC-U6, REQ-019)', () => { + const session = sessionWith(demoFrame); + + test('nth selects deterministic document-ordered matches', () => { + expect(session.locator('input').nth(0).unique().key).toBe('5'); + expect(session.locator('input').nth(1).unique().key).toBe('6'); + expect(session.locator('input').nth(1).matches()).toHaveLength(1); + }); + + test('nonnegative validation; beyond-count indices are lazy (empty), not errors', () => { + expect(() => session.locator('input').nth(2)).not.toThrow(); + expect(session.locator('input').nth(2).matches()).toEqual([]); + expect(() => session.locator('input').nth(-1)).toThrowError( + expect.objectContaining({ code: 'GW_CLACK_LOCATOR_RANGE' }), + ); + expect(() => session.locator('input').nth(1.5)).toThrowError( + expect.objectContaining({ code: 'GW_CLACK_LOCATOR_RANGE' }), + ); + }); + + test('strict single-match requirement lists candidate keys', () => { + try { + session.locator('input').unique(); + expect.unreachable('unique() must throw on ambiguity'); + } catch (error) { + const message = (error as Error).message; + expect(message).toContain('matched 2'); + expect(message).toContain('5/input'); + expect(message).toContain('6/input'); + } + }); +}); + +describe('geometry bridge (REQ-020, TC-I8 support)', () => { + const session = sessionWith(demoFrame); + + test('range prefers visible bounds and falls back to term', () => { + const say = session.locator('input[label="say"]').unique(); + expect(say.range).toEqual({ column: 2, row: 6, width: 10, height: 3 }); + const group = session.locator('box[label="hello"]').unique(); + expect(group.range).toEqual({ column: 0, row: 0, width: 40, height: 8 }); + }); + + test('a match without geometry fails with GW_CLACK_NO_GEOMETRY', () => { + const text = session.locator('text').unique(); + expect(text.geo).toBeUndefined(); + expect(() => session.locator('text').region()).toThrowError( + expect.objectContaining({ code: 'GW_CLACK_NO_GEOMETRY' }), + ); + }); +}); + +describe('selector bounds (TC-U4, REQ-018)', () => { + const session = sessionWith(demoFrame); + const cases: [string, string][] = [ + ['4097 bytes', `box${':has(box)'.repeat(300)}`.slice(0, 4097)], + ['malformed syntax', 'box:'], + ['pseudo-element', 'box::before'], + ['unsupported traversal', 'box < input'], + ['unsupported pseudo', 'box:contains(x)'], + ]; + for (const [name, source] of cases) { + test(`${name} is rejected before evaluation`, () => { + expect(() => session.locator(source)).toThrowError( + expect.objectContaining({ + code: expect.stringMatching(/^GW_CLACK_SELECTOR_(LIMIT|INVALID)$/), + }), + ); + }); + } +}); + +describe('revision replacement (REQ-016)', () => { + test('locators resolve against the newest accepted frame', () => { + const extension = clackTtyExtension(); + let sequence = 0; + const context: ExtensionSessionContext = { + terminal: {} as ExtensionSessionContext['terminal'], + screen: {} as ExtensionSessionContext['screen'], + publish(commit): ExtensionRevision { + return { + sequence: ++sequence, + timestamp: 0, + extensionId: 'test', + protocolFrame: commit.protocolFrame, + screenSequence: 0, + value: commit.value, + }; + }, + diagnostic() {}, + }; + const session = extension.createSession(context); + extension.accept(session, demoFrame, context); + const lazy = session.locator('[focused]'); + expect(lazy.matches().map((match) => match.key)).toEqual(['5']); + + const next: ClackFrameV1 = { + ...demoFrame, + frame: 2, + focusStack: ['6'], + nodes: demoFrame.nodes.map((node) => + node.key === '6' + ? { ...node, states: { focused: true, focusRoot: true } } + : node.key === '5' + ? { ...node, states: { focused: false, focusRoot: false } } + : node, + ), + }; + extension.accept(session, next, context); + expect(lazy.matches().map((match) => match.key)).toEqual(['6']); + }); +}); diff --git a/packages/clack-tty/test/protocol.test.ts b/packages/clack-tty/test/protocol.test.ts new file mode 100644 index 0000000..d55898c --- /dev/null +++ b/packages/clack-tty/test/protocol.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, test } from 'vitest'; +import { + decodeFrame, + encodeFrame, + geometryFor, + LIMITS, + type ClackFrameV1, + type ClackNodeV1, +} from '../src/protocol.ts'; +import { clackTtyExtension, ClackTtySession } from '../src/extension.ts'; +import type { + ExtensionSessionContext, + ExtensionRevision, + GhostwrightError, +} from 'ghostwright'; + +function node(overrides: Partial = {}): ClackNodeV1 { + return { + key: '1', + name: 'box', + parent: null, + order: 0, + attrs: { focusable: false }, + states: { focused: false, focusRoot: false }, + ...overrides, + }; +} + +function frame(overrides: Partial = {}, nodes: ClackNodeV1[] = [node()]): ClackFrameV1 { + return { + v: 1, + frame: 1, + surface: { columns: 80, rows: 24, row: 1 }, + focusStack: [], + nodes, + ...overrides, + }; +} + +/** Minimal recording extension context: the real accept path, recorded revisions. */ +function recordingContext() { + const revisions: ExtensionRevision[] = []; + const diagnostics: GhostwrightError[] = []; + let sequence = 0; + const context: ExtensionSessionContext = { + terminal: {} as ExtensionSessionContext['terminal'], + screen: {} as ExtensionSessionContext['screen'], + publish(commit) { + const revision: ExtensionRevision = { + sequence: ++sequence, + timestamp: 0, + extensionId: 'test', + protocolFrame: commit.protocolFrame, + screenSequence: 0, + value: commit.value, + }; + revisions.push(revision); + return revision; + }, + diagnostic(error) { + diagnostics.push(error); + }, + }; + return { context, revisions, diagnostics }; +} + +/** Extract the payload section from an encoded envelope, as the OSC stream would. */ +function payloadOf(bytes: Uint8Array): Uint8Array { + const raw = Buffer.from(bytes).toString('latin1'); + const start = raw.indexOf(';v=1;') + 5; + return Buffer.from(raw.slice(start, raw.length - 2), 'latin1'); +} + +describe('protocol codec (TC-U1)', () => { + test('encode/decode round-trips a full tree deterministically', () => { + const tree = frame( + { frame: 7, focusStack: ['3'] }, + [ + node({ + key: '1', + name: 'box', + attrs: { role: 'group', label: 'hello', focusable: false, custom: { 'x': 1 } }, + geo: { + layout: { x: 0, y: 0, width: 40.5, height: 8 }, + term: { column: 0, row: 0, width: 40, height: 8 }, + visible: { column: 0, row: 0, width: 40, height: 8 }, + }, + }), + node({ key: '2', name: 'text', parent: '1', order: 0 }), + node({ + key: '3', + name: 'input', + parent: '1', + order: 1, + attrs: { role: 'textbox', label: 'say', input: true, focusable: true }, + states: { focused: true, focusRoot: true }, + }), + ], + ); + const bytes = encodeFrame(tree); + expect(decodeFrame(payloadOf(bytes))).toStrictEqual(tree); + expect(encodeFrame(decodeFrame(payloadOf(bytes)))).toStrictEqual(bytes); + }); + + test('envelope is the registered OSC 7777;clack.ui;v=1 with ST terminator', () => { + const bytes = Buffer.from(encodeFrame(frame())); + expect(bytes.subarray(0, 20).toString('latin1')).toBe('\u001b]7777;clack.ui;v=1;'); + expect(bytes.subarray(bytes.length - 2).toString('latin1')).toBe('\u001b\\'); + }); +}); + +describe('fail-closed validation (TC-U2)', () => { + const cases: { name: string; code: string; frame: () => unknown }[] = [ + { name: 'bad base64 charset', code: 'GW_CLACK_BASE64', frame: () => decodeFrame(Buffer.from('!!not-base64!!')) }, + { name: 'invalid JSON', code: 'GW_CLACK_BASE64', frame: () => decodeFrame(Buffer.from('{not json')) }, + { + name: 'version mismatch', + code: 'GW_CLACK_VERSION', + frame: () => frame({ v: 2 as unknown as 1 }), + }, + { name: 'frame not object', code: 'GW_CLACK_SCHEMA', frame: () => 'nope' as unknown as ClackFrameV1 }, + { name: 'zero frame number', code: 'GW_CLACK_SCHEMA', frame: () => frame({ frame: 0 }) }, + { name: 'bad surface', code: 'GW_CLACK_SCHEMA', frame: () => frame({ surface: { columns: 0, rows: 24, row: 1 } }) }, + { name: 'focus stack not strings', code: 'GW_CLACK_SCHEMA', frame: () => frame({ focusStack: [1] }) }, + { name: 'duplicate node key', code: 'GW_CLACK_SCHEMA', frame: () => frame({}, [node(), node()]) }, + { name: 'parent cycle', code: 'GW_CLACK_SCHEMA', frame: () => frame({}, [ + node({ key: 'a', parent: 'b' }), + node({ key: 'b', parent: 'a' }), + ]) }, + { name: 'depth over limit', code: 'GW_CLACK_LIMIT', frame: () => { + const chain: ClackNodeV1[] = [node({ key: 'n0' })]; + for (let i = 1; i <= LIMITS.depth + 1; i++) + chain.push(node({ key: `n${i}`, parent: `n${i - 1}`, order: 0 })); + return frame({}, chain); + } }, + { + name: 'too many nodes', + code: 'GW_CLACK_LIMIT', + frame: () => frame({}, Array.from({ length: LIMITS.nodes + 1 }, (_, i) => node({ key: `k${i}` }))), + }, + { + name: 'non-scalar custom attribute', + code: 'GW_CLACK_SCHEMA', + frame: () => frame({}, [node({ attrs: { focusable: false, custom: { x: { deep: true } } } })]), + }, + { + name: 'missing focusable attribute', + code: 'GW_CLACK_SCHEMA', + frame: () => frame({}, [node({ attrs: {} as ClackNodeV1['attrs'] })]), + }, + { + name: 'missing states', + code: 'GW_CLACK_SCHEMA', + frame: () => frame({}, [node({ states: undefined as unknown as ClackNodeV1['states'] })]), + }, + { + name: 'negative geometry size', + code: 'GW_CLACK_SCHEMA', + frame: () => frame({}, [node({ + geo: { layout: { x: 0, y: 0, width: -1, height: 0 }, term: { column: 0, row: 0, width: 0, height: 0 } }, + })]), + }, + { + name: 'non-integer cell rect', + code: 'GW_CLACK_SCHEMA', + frame: () => frame({}, [node({ + geo: { layout: { x: 0, y: 0, width: 1, height: 1 }, term: { column: 0.5, row: 0, width: 1, height: 1 } }, + })]), + }, + ]; + for (const { name, code, frame: make } of cases) { + test(`${name} -> ${code}`, () => { + expect(() => encodeFrame(make() as ClackFrameV1)).toThrowError( + expect.objectContaining({ code }), + ); + }); + } + + test('payload over the byte limit is refused by the encoder (TC-U3)', () => { + const fat = frame({}, [node({ attrs: { focusable: false, label: 'x'.repeat(LIMITS.attribute) } })]); + expect(() => encodeFrame(fat)).not.toThrow(); + const many = frame({}, Array.from({ length: LIMITS.nodes }, (_, i) => + node({ key: `k${i}`, attrs: { focusable: false, label: 'y'.repeat(100) } }), + )); + expect(() => encodeFrame(many)).toThrowError(expect.objectContaining({ code: 'GW_CLACK_LIMIT' })); + }); +}); + +describe('frame ordering through the real accept path (REQ-009, TC-U2)', () => { + function accept(frames: ClackFrameV1[]) { + const extension = clackTtyExtension(); + const { context, revisions } = recordingContext(); + const session = extension.createSession(context); + for (const frame of frames) extension.accept(session, frame, context); + return { session, revisions }; + } + + test('frames advance strictly by one', () => { + const { session, revisions } = accept([frame({ frame: 1 }), frame({ frame: 2 }), frame({ frame: 3 })]); + expect(revisions.map((revision) => revision.protocolFrame)).toEqual([1, 2, 3]); + expect(session.frames().map((frame) => frame.frame)).toEqual([1, 2, 3]); + }); + + test('a skipped frame number is rejected and the last good revision survives', () => { + const { context, revisions } = recordingContext(); + const extension = clackTtyExtension(); + const session = extension.createSession(context); + extension.accept(session, frame({ frame: 1 }), context); + expect(() => extension.accept(session, frame({ frame: 3 }), context)).toThrowError( + expect.objectContaining({ code: 'GW_CLACK_FRAME' }), + ); + expect(() => extension.accept(session, frame({ frame: 1 }), context)).toThrowError( + expect.objectContaining({ code: 'GW_CLACK_FRAME' }), + ); + expect(session.current()?.frame).toBe(1); + expect(revisions).toHaveLength(1); + }); +}); + +describe('geometry truncation (REQ-007, TC-U4 support)', () => { + test('Clay-compatible truncation with 1-based row offset', () => { + const { layout, term } = geometryFor( + { x: 1.5, y: 0.5, width: 10.25, height: 3.75 }, + { columns: 80, rows: 24, row: 1 }, + ); + expect(layout).toEqual({ x: 1.5, y: 0.5, width: 10.25, height: 3.75 }); + expect(term).toEqual({ column: 1, row: 0, width: 10, height: 4 }); + }); + + test('viewport intersection clamps to the surface', () => { + const { visible } = geometryFor( + { x: 70, y: 20, width: 40, height: 10 }, + { columns: 80, rows: 24, row: 1 }, + ); + expect(visible).toEqual({ column: 70, row: 20, width: 10, height: 4 }); + }); + + test('a node rendered fully outside the surface has no visible rect', () => { + const { visible } = geometryFor( + { x: 0, y: 40, width: 10, height: 2 }, + { columns: 80, rows: 24, row: 1 }, + ); + expect(visible).toBeUndefined(); + }); +}); diff --git a/packages/clack-tty/test/structural.test.ts b/packages/clack-tty/test/structural.test.ts new file mode 100644 index 0000000..d74df2c --- /dev/null +++ b/packages/clack-tty/test/structural.test.ts @@ -0,0 +1,97 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; +import { describe, expect, test } from 'vitest'; + +const packageRoot = fileURLToPath(new URL('..', import.meta.url)); +const playgroundRoot = fileURLToPath(new URL('../../..', import.meta.url)); +const uiClone = resolve(playgroundRoot, '../ui'); + +const git = (args: string[], cwd: string) => + spawnSync('git', args, { cwd, encoding: 'utf8' }).stdout.trim(); + +describe('vehicle and packaging (TC-P1, REQ-001/REQ-002, NFR-001)', () => { + test('ghostwright artifacts are available in-tree', () => { + expect(existsSync(`${playgroundRoot}/experiments/ghostwright/artifacts/ghostty-vt.wasm`)).toBe(true); + }); + + test('clack/ui resolves to the vendored workspace package', () => { + const pkg = JSON.parse(readFileSync(`${packageRoot}/package.json`, 'utf8')); + expect(pkg.dependencies['@clack/ui']).toBe('workspace:*'); + const link = spawnSync('node', ['-e', 'console.log(require.resolve("@clack/ui/package.json"))'], { + cwd: packageRoot, + encoding: 'utf8', + }); + // The vendored package is source-first; resolving its directory is enough. + const resolved = link.stdout.trim() || link.stderr; + expect(resolved.length).toBeGreaterThan(0); + expect(readFileSync(`${packageRoot}/../../vendor/clack-ui/package.json`, 'utf8')).toContain('"@clack/ui"'); + }); + + test('render is an extensible API member; the onFrame hook is gone', () => { + const renderSource = readFileSync(`${packageRoot}/../../vendor/clack-ui/src/render.ts`, 'utf8'); + expect(renderSource).toContain('render(_node'); + expect(renderSource).toContain('return result;'); + const uiSource = readFileSync(`${packageRoot}/../../vendor/clack-ui/src/ui.ts`, 'utf8'); + expect(uiSource.includes('onFrame')).toBe(false); + const focusSource = readFileSync(`${packageRoot}/../../vendor/clack-ui/src/focus.ts`, 'utf8'); + expect(focusSource).toContain('isFocusable(node): boolean'); + expect(focusSource.includes('export const FocusableContext')).toBe(false); + }); + + test('the clack/ui repository clone carries no working-tree changes', () => { + expect(git(['status', '--porcelain'], uiClone)).toBe(''); + }); +}); + +describe('freedom experiment removal (TC-P2, REQ-003)', () => { + test('packages/freedom-tty is gone and no OSC usages remain', () => { + expect(existsSync(`${playgroundRoot}/packages/freedom-tty`)).toBe(false); + const files = spawnSync( + 'node', + [ + '-e', + `const { execSync } = require('child_process'); + let out = ''; + try { out = execSync('grep -rEl --exclude-dir=node_modules --exclude-dir=test "encodeFreedomTtyFrame|FREEDOM_TTY_OSC|ghostwright.freedom-tty" packages examples scripts', { cwd: ${JSON.stringify(playgroundRoot)}, encoding: 'utf8' }); } catch {} + console.log(out.trim());`, + ], + { encoding: 'utf8' }, + ); + expect(files.stdout.trim()).toBe(''); + }); + +}); + +describe('extension/application separation (Decision: husky-style activation)', () => { + const demoRoot = resolve(packageRoot, '../hello-world'); + + test('the demo application source imports nothing from the extension package', () => { + const source = readFileSync(`${demoRoot}/src/hello-world.ts`, 'utf8'); + expect(source.includes('@ghostwright')).toBe(false); + expect(source.includes('useSemantic')).toBe(false); + expect(source).toContain("from '@clack/ui'"); + }); + + test('the demo declares the extension in package.json, husky-style', () => { + const pkg = JSON.parse(readFileSync(`${demoRoot}/package.json`, 'utf8')); + expect(pkg['@clack/ui']?.extensions).toEqual(['@ghostwright/clack-tty/auto']); + expect(pkg.dependencies['@ghostwright/clack-tty']).toBe('workspace:*'); + expect(pkg.dependencies['@clack/ui']).toBe('workspace:*'); + }); + + test('the extension package itself declares no clack/ui extensions', () => { + const pkg = JSON.parse(readFileSync(`${packageRoot}/package.json`, 'utf8')); + expect(pkg['@clack/ui']).toBeUndefined(); + }); +}); + +describe('bun-free test path (TC-P3, REQ-004)', () => { + test('the package test script is vitest-only', () => { + const pkg = JSON.parse(readFileSync(`${packageRoot}/package.json`, 'utf8')); + expect(pkg.scripts.test).toBe('vitest run'); + const scripts = JSON.stringify(pkg.scripts); + expect(scripts.includes('bun')).toBe(false); + }); +}); diff --git a/packages/clack-tty/tsconfig.json b/packages/clack-tty/tsconfig.json new file mode 100644 index 0000000..f08f29c --- /dev/null +++ b/packages/clack-tty/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "types": ["node"], + "paths": { + "@clack/ui": ["../../vendor/clack-ui/src/index.ts"], + "@clack/ui/elements": ["../../vendor/clack-ui/src/elements.ts"], + "@clack/ui/events": ["../../vendor/clack-ui/src/events.ts"], + "@clack/ui/focus": ["../../vendor/clack-ui/src/focus.ts"], + "@clack/ui/core": ["../../vendor/clack-ui/src/core.ts"] + } + } +} diff --git a/packages/clack-tty/vitest.config.ts b/packages/clack-tty/vitest.config.ts new file mode 100644 index 0000000..23938da --- /dev/null +++ b/packages/clack-tty/vitest.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + testTimeout: 60_000, + hookTimeout: 30_000, + teardownTimeout: 30_000, + // TUI sessions share no state, but ghostwright spawns a PTY sidecar per + // test; parallel forks each spawn their own — keep them isolated. + pool: 'forks', + poolOptions: { forks: { singleFork: true } }, + }, +}); diff --git a/packages/hello-world/package.json b/packages/hello-world/package.json new file mode 100644 index 0000000..07a0e61 --- /dev/null +++ b/packages/hello-world/package.json @@ -0,0 +1,40 @@ +{ + "name": "@ghostwright/hello-world", + "version": "0.0.0", + "description": "A plain clack/ui hello-world application, validated end to end with ghostwright tree locators", + "private": true, + "license": "MIT", + "type": "module", + "scripts": { + "start": "tsx src/hello-world.ts", + "test": "vitest run" + }, + "dependencies": { + "@bomb.sh/tty": "^0.8.0", + "@clack/ui": "workspace:*", + "@ghostwright/clack-tty": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "tsx": "^4.19.0", + "ghostwright": "workspace:*", + "vitest": "^4.1.9" + }, + "@clack/ui": { + "extensions": [ + "@ghostwright/clack-tty/auto" + ] + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.7.0", + "onFail": "error" + }, + "runtime": { + "name": "node", + "version": "22.14.0", + "onFail": "error" + } + } +} diff --git a/packages/hello-world/src/hello-world.ts b/packages/hello-world/src/hello-world.ts new file mode 100644 index 0000000..d4b7b31 --- /dev/null +++ b/packages/hello-world/src/hello-world.ts @@ -0,0 +1,116 @@ +/** + * A plain clack/ui application. Nothing in this file knows about testing: + * semantic-tree emission is activated by the launcher (CLACK_UI_SEMANTIC=1) + * through the extension declared in package.json. + * + * Run: `tsx src/hello-world.ts` + * With byte capture for ordering tests: `--teed ` appends every stdout + * write to `` (configuration seam, see the test plan rig section). + */ +import { appendFileSync, openSync } from 'node:fs'; +import { stdin, stdout } from 'node:process'; +import { fixed, grow, percent, rgba } from '@bomb.sh/tty'; +import { createUI, type HostElement, type TextProps } from '@clack/ui'; + +const blue = rgba(0, 0, 238); +const cyan = rgba(0, 205, 205); +const gray = rgba(127, 127, 127); + +const teedIndex = process.argv.indexOf('--teed'); +const teedFile = teedIndex >= 0 ? process.argv[teedIndex + 1] : undefined; +if (teedFile) { + openSync(teedFile, 'w'); + const original = stdout.write.bind(stdout); + stdout.write = ((chunk: Uint8Array | string, ...rest: unknown[]) => { + appendFileSync(teedFile, typeof chunk === 'string' ? Buffer.from(chunk) : Buffer.from(chunk)); + return (original as (...args: unknown[]) => boolean)(chunk, ...rest); + }) as typeof stdout.write; +} + +const columns = stdout.columns || 80; +const rows = stdout.rows || 24; + +const ui = await createUI({ + input: stdin, + output: stdout, + width: columns, + height: rows, +}); + +const { host } = ui; + +const sayInput = host.createElement('input'); +host.setProperty(sayInput, 'role', 'textbox'); +host.setProperty(sayInput, 'label', 'say'); + +const toInput = host.createElement('input'); +host.setProperty(toInput, 'role', 'textbox'); +host.setProperty(toInput, 'label', 'to'); + +const say = host.createLiteral('Hello'); +const comma = host.createLiteral(', '); +const to = host.createLiteral('World'); +const bang = host.createLiteral('!'); + +const output = host.createElement('text'); +host.setProperty(output, 'color', cyan); +host.insertBefore(output, say); +host.insertBefore(output, comma); +host.insertBefore(output, to); +host.insertBefore(output, bang); + +host.addEventListener(sayInput, 'input', (event) => { + host.setText(say, event.value); +}); +host.addEventListener(toInput, 'input', (event) => { + host.setText(to, event.value); +}); + +const app = box( + { + role: 'group', + label: 'hello', + layout: { + direction: 'ttb', + gap: 1, + padding: { top: 1, bottom: 1, left: 2, right: 2 }, + width: fixed(40), + }, + border: { color: blue, top: 1, right: 1, bottom: 1, left: 1 }, + }, + output, + box( + { layout: { direction: 'ttb', width: grow() } }, + box( + { layout: { direction: 'ltr', gap: 1, width: grow() } }, + label('say:'), + label('to:'), + ), + box({ layout: { direction: 'ltr', gap: 1, width: grow() } }, sayInput, toInput), + ), +); + +host.insertBefore(host.element, app); + +await ui.main(); + +function box(properties: Record, ...children: HostElement[]): HostElement { + const element = host.createElement('box'); + for (const [name, value] of Object.entries(properties)) host.setProperty(element, name, value); + for (const child of children) host.insertBefore(element, child); + return element; +} + +function label(content: string): HostElement { + return box( + { layout: { width: percent(0.3) } }, + text({ color: gray }, content), + ); +} + +function text(properties: TextProps, content: string): HostElement { + const element = host.createElement('text'); + for (const [name, value] of Object.entries(properties)) host.setProperty(element, name, value); + host.insertBefore(element, host.createLiteral(content)); + return element; +} diff --git a/packages/hello-world/test/hello-world.test.ts b/packages/hello-world/test/hello-world.test.ts new file mode 100644 index 0000000..89e951c --- /dev/null +++ b/packages/hello-world/test/hello-world.test.ts @@ -0,0 +1,104 @@ +import { expect, test } from 'vitest'; +import { cellsMatchStyle, expectTerminal, withTerminalAsync } from 'ghostwright'; +import { + clackTtyExtension, + expectFocused, + expectTreeCondition, + type ClackTtySession, +} from '@ghostwright/clack-tty'; + +// The application under test is this package's hello-world; it contains no +// test code itself. The launcher environment activates the semantic producer +// through the extension declared in package.json. +const extension = clackTtyExtension(); + +const entry = () => ({ + command: process.execPath, + args: ['--import', 'tsx', 'src/hello-world.ts'], + cwd: new URL('..', import.meta.url).pathname, + viewport: { columns: 80, rows: 24 }, + env: { CLACK_UI_SEMANTIC: '1' }, + trace: 'off' as const, + extensions: [extension], +}); + +test('the greeting renders and the semantic tree exposes it (selector syntax)', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const semantic = terminal.extension(extension) as ClackTtySession; + + // The group box is in the tree before we ever look at the screen. + const group = semantic.locator('box[role="group"][label="hello"]'); + await expectTreeCondition(terminal, () => group.matches().length === 1, 'group present'); + + // The bridge: text assertions scoped to the group's on-screen rect. + await expectTerminal(group.getByText('Hello, World!')).toBeStable(); + }); +}); + +test('typing into the say input updates the greeting (region-scoped)', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const semantic = terminal.extension(extension) as ClackTtySession; + const group = semantic.locator('box[role="group"][label="hello"]'); + const say = semantic.locator('input[label="say"]'); + + await expectTerminal(terminal.getByText('Hello, World!')).toBeStable(); + await expectFocused(terminal, say); + + await terminal.keyboard.type('Hi'); + + // The greeting text element and the input's own model both updated. + await expectTerminal(group.getByText('Hi, World!')).toBeStable(); + await expectTerminal(say.getByText('Hi')).toBePresent(); + }); +}); + +test('Tab moves focus and the focused input paints its focus ring', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const semantic = terminal.extension(extension) as ClackTtySession; + const say = semantic.locator('input[label="say"]'); + const to = semantic.locator('input[label="to"]'); + + await expectFocused(terminal, say); + + // Focus is visual: the focused input draws white, the other gray. + const foregroundOf = (locator: typeof say) => { + const [match] = locator.matches(); + const cells = terminal.screen.getCells(match!.range!); + const focused = cells.some((cell) => cellsMatchStyle([cell], { foreground: '#ffffff' })); + const gray = cells.some((cell) => cellsMatchStyle([cell], { foreground: '#646464' })); + return { focused, gray }; + }; + expect(foregroundOf(say)).toEqual({ focused: true, gray: false }); + expect(foregroundOf(to)).toEqual({ focused: false, gray: true }); + + await terminal.keyboard.press('Tab'); + await expectFocused(terminal, to); + expect(foregroundOf(to)).toEqual({ focused: true, gray: false }); + expect(foregroundOf(say)).toEqual({ focused: false, gray: true }); + }); +}); + +test('ambiguous selectors fail with candidate diagnostics', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const semantic = terminal.extension(extension) as ClackTtySession; + await expectTerminal(terminal.getByText('Hello, World!')).toBeStable(); + + try { + semantic.locator('input').unique(); + expect.unreachable('unique() must throw on ambiguity'); + } catch (error) { + const message = (error as Error).message; + expect(message).toContain('matched 2'); + expect(message).toContain('/input'); + } + + await expect( + expectTreeCondition( + terminal, + () => semantic.locator('input[label="nope"]').matches().length > 0, + 'never matches', + 1500, + ), + ).rejects.toThrow(/never matches/); + }); +}); diff --git a/packages/hello-world/vitest.config.ts b/packages/hello-world/vitest.config.ts new file mode 100644 index 0000000..48cdfd7 --- /dev/null +++ b/packages/hello-world/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + testTimeout: 60_000, + hookTimeout: 30_000, + teardownTimeout: 30_000, + pool: 'forks', + poolOptions: { forks: { singleFork: true } }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e4a53f..685c731 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: 0.3.1 '@bomb.sh/tools': specifier: ^0.6.1 - version: 0.6.1(@types/node@22.20.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 0.6.1(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@clack/prompts': specifier: 'catalog:' version: 1.7.0 @@ -66,6 +66,59 @@ importers: specifier: ^4.0.2 version: 4.0.3 + packages/clack-tty: + dependencies: + '@bomb.sh/tty': + specifier: https://pkg.pr.new/@bomb.sh/tty@103 + version: https://pkg.pr.new/@bomb.sh/tty@103 + '@clack/ui': + specifier: workspace:* + version: link:../../vendor/clack-ui + css-select: + specifier: ^7.0.0 + version: 7.0.0 + css-what: + specifier: ^8.0.0 + version: 8.0.0 + ghostwright: + specifier: workspace:* + version: link:../../experiments/ghostwright + devDependencies: + '@types/node': + specifier: ^22.20.0 + version: 22.20.1 + tsx: + specifier: ^4.19.0 + version: 4.23.13 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + + packages/hello-world: + dependencies: + '@bomb.sh/tty': + specifier: https://pkg.pr.new/@bomb.sh/tty@103 + version: https://pkg.pr.new/@bomb.sh/tty@103 + '@clack/ui': + specifier: workspace:* + version: link:../../vendor/clack-ui + '@ghostwright/clack-tty': + specifier: workspace:* + version: link:../clack-tty + devDependencies: + '@types/node': + specifier: ^22.20.0 + version: 22.20.1 + ghostwright: + specifier: workspace:* + version: link:../../experiments/ghostwright + tsx: + specifier: ^4.19.0 + version: 4.23.13 + vitest: + specifier: ^4.1.9 + version: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + vendor/clack-ui: dependencies: '@bomb.sh/tty': @@ -96,10 +149,10 @@ importers: devDependencies: '@bomb.sh/tools': specifier: ^0.5.4 - version: 0.5.6(@types/node@22.20.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 0.5.6(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + version: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -155,6 +208,162 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1040,6 +1249,10 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + boolbase@2.0.0: + resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} + engines: {node: '>=20.19.0'} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} @@ -1051,6 +1264,14 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + css-select@7.0.0: + resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} + engines: {node: '>=20.19.0'} + + css-what@8.0.0: + resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} + engines: {node: '>=20.19.0'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -1058,6 +1279,22 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + dts-resolver@3.0.0: resolution: {integrity: sha512-1T1f+z+4tl9XD+m+0HBgWoL/nm0bOIffyWaUuUSBlFg/86IWvfx+wjNaO/ybU0AJzG9/Mi5hBUgGV6zCmWEN7Q==} engines: {node: ^22.18.0 || >=24.0.0} @@ -1075,9 +1312,18 @@ packages: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -1221,6 +1467,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nth-check@3.0.1: + resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} + engines: {node: '>=20.19.0'} + obug@2.1.3: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} @@ -1408,6 +1658,11 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} + engines: {node: '>=18.0.0'} + hasBin: true + ultramatter@0.0.4: resolution: {integrity: sha512-1f/hO3mR+/Hgue4eInOF/Qm/wzDqwhYha4DxM0hre9YIUyso3fE2XtrAU6B4njLqTC8CM49EZaYgsVSa+dXHGw==} @@ -1550,7 +1805,7 @@ snapshots: '@bomb.sh/args@0.3.1': {} - '@bomb.sh/tools@0.5.6(@types/node@22.20.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))': + '@bomb.sh/tools@0.5.6(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@bomb.sh/args': 0.3.1 '@humanfs/node': 0.16.8 @@ -1561,10 +1816,10 @@ snapshots: oxlint: 1.74.0 publint: 0.3.21 tinyexec: 1.2.4 - tsdown: 0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(unrun@0.2.39) + tsdown: 0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.13)(unrun@0.2.39) ultramatter: 0.0.4 - vitest: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) - vitest-ansi-serializer: 0.2.1(vitest@4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))) + vitest: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + vitest-ansi-serializer: 0.2.1(vitest@4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -1593,7 +1848,7 @@ snapshots: - vite-plus - vue-tsc - '@bomb.sh/tools@0.6.1(@types/node@22.20.1)(oxc-resolver@11.21.3)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))': + '@bomb.sh/tools@0.6.1(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(unrun@0.2.39)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@bomb.sh/args': 0.3.1 '@humanfs/node': 0.16.8 @@ -1604,10 +1859,10 @@ snapshots: oxlint: 1.74.0 publint: 0.3.21 tinyexec: 1.2.4 - tsdown: 0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(unrun@0.2.39) + tsdown: 0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.13)(unrun@0.2.39) ultramatter: 0.0.4 - vitest: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) - vitest-ansi-serializer: 0.2.1(vitest@4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))) + vitest: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + vitest-ansi-serializer: 0.2.1(vitest@4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) transitivePeerDependencies: - '@arethetypeswrong/core' - '@edge-runtime/vm' @@ -1698,6 +1953,84 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2152,13 +2485,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -2258,16 +2591,46 @@ snapshots: assertion-error@2.0.1: {} + boolbase@2.0.0: {} + cac@7.0.0: {} chai@6.2.2: {} convert-source-map@2.0.0: {} + css-select@7.0.0: + dependencies: + boolbase: 2.0.0 + css-what: 8.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + nth-check: 3.0.1 + + css-what@8.0.0: {} + defu@6.1.7: {} detect-libc@2.1.2: {} + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + + domelementtype@3.0.0: {} + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + dts-resolver@3.0.0(oxc-resolver@11.21.3): optionalDependencies: oxc-resolver: 11.21.3 @@ -2276,8 +2639,39 @@ snapshots: empathic@2.0.1: {} + entities@8.0.0: {} + es-module-lexer@2.1.0: {} + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -2396,6 +2790,10 @@ snapshots: nanoid@3.3.15: {} + nth-check@3.0.1: + dependencies: + boolbase: 2.0.0 + obug@2.1.3: {} oxc-parser@0.137.0: @@ -2610,7 +3008,7 @@ snapshots: tree-kill@1.2.2: {} - tsdown@0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(unrun@0.2.39): + tsdown@0.22.7(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(publint@0.3.21)(tsx@4.23.13)(unrun@0.2.39): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -2629,6 +3027,7 @@ snapshots: unconfig-core: 7.5.0 optionalDependencies: publint: 0.3.21 + tsx: 4.23.13 unrun: 0.2.39 transitivePeerDependencies: - '@ts-macro/tsc' @@ -2639,6 +3038,12 @@ snapshots: tslib@2.8.1: optional: true + tsx@4.23.13: + dependencies: + esbuild: 0.28.2 + optionalDependencies: + fsevents: 2.3.3 + ultramatter@0.0.4: {} unbash@4.0.1: {} @@ -2655,7 +3060,7 @@ snapshots: rolldown: 1.0.0-rc.17 optional: true - vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0): + vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.5 @@ -2664,18 +3069,20 @@ snapshots: tinyglobby: 0.2.17 optionalDependencies: '@types/node': 22.20.1 + esbuild: 0.28.2 fsevents: 2.3.3 jiti: 2.7.0 + tsx: 4.23.13 yaml: 2.9.0 - vitest-ansi-serializer@0.2.1(vitest@4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0))): + vitest-ansi-serializer@0.2.1(vitest@4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))): dependencies: - vitest: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + vitest: 4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) - vitest@4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)): + vitest@4.1.9(@types/node@22.20.1)(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -2692,7 +3099,7 @@ snapshots: tinyexec: 1.2.4 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@22.20.1)(jiti@2.7.0)(yaml@2.9.0) + vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.20.1