diff --git a/.gitignore b/.gitignore index f8285e5..644e0cd 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,9 @@ build/Release node_modules/ jspm_packages/ +# Terminal test failure artifacts +.ghostwright/ + # TypeScript cache *.tsbuildinfo diff --git a/experiments/ghostwright/AGENTS.md b/experiments/ghostwright/AGENTS.md index 5b9bf82..a9142d4 100644 --- a/experiments/ghostwright/AGENTS.md +++ b/experiments/ghostwright/AGENTS.md @@ -10,21 +10,31 @@ Test the application from the outside. Launch its real command under a PTY, inte 1. Identify the repository's existing test runner and conventions. 2. Identify the direct executable, argument array, cwd, and any nonreserved environment values. -3. Prefer `withTerminalAsync` unless the surrounding code already uses Effection. +3. Use `await using` with `launchTerminal`, or the `withTerminal` callback helper. Effection uses `ghostwright/effection`. 4. Wait for a visible readiness condition before acting. 5. Await every action. Save the receipt when a transient assertion follows. 6. Use a stable assertion for final UI state and revision history for fleeting state. 7. Verify process exit when exit behavior matters. 8. Run the focused test. On failure, inspect the attached `.ghostwright` artifacts before changing timeouts. -## Canonical async template +## Public query and assertion model + +Use `terminal.screen.getBy/queryBy/findBy` with a locator recipe, or their `ByText` and adapter-owned `BySelector` conveniences. The `All` forms return arrays. `get` and `query` inspect now; `find` waits. A result is frozen evidence, not a live element. Query again inside `terminal.waitFor` when checking a changing value. + +Jest and Vitest can use `ghostwright/jest` and `ghostwright/vitest` to install immediate terminal matchers on their own `expect`. Keep keyboard and mouse input outside retried assertions. Capture observations when intermediate states matter. The README and pizza examples show this style. + +`launchTerminal` supports `await using`; the `withTerminal` callback form also records body failures. Bare disposal cannot detect a test assertion failure. Use the Vitest launch fixture, the callback form, or `trace: 'on'` when artifacts are required. + +The lower-level `expectTerminal` helpers below remain available for existing revision/history workflows. + +## Lower-level async template ```ts import { expect, test } from 'bun:test'; -import { expectTerminal, withTerminalAsync } from 'ghostwright'; +import { expectTerminal, withTerminal } from 'ghostwright'; test('interactive happy path', async () => { - await withTerminalAsync( + await withTerminal( { command: 'bun', args: ['src/cli.ts'], diff --git a/experiments/ghostwright/HOST-COMPARISON.md b/experiments/ghostwright/HOST-COMPARISON.md index 3c43545..a5907a5 100644 --- a/experiments/ghostwright/HOST-COMPARISON.md +++ b/experiments/ghostwright/HOST-COMPARISON.md @@ -1,4 +1,6 @@ -# PTY Host C vs. Rust Comparison +# Historical PTY Host C vs. Rust Comparison + +This report predates the scoped-execution rewrite. Rust is now the sole packaged host. The C implementation and comparison script were removed. These measurements were not rerun and do not describe the current queues or cancellation behavior. Generated on 2026-07-15T09:03:59.623Z by `bun run compare:hosts` on darwin-arm64. diff --git a/experiments/ghostwright/README.md b/experiments/ghostwright/README.md index 3333d98..b1c6c2c 100644 --- a/experiments/ghostwright/README.md +++ b/experiments/ghostwright/README.md @@ -10,7 +10,7 @@ Install Ghostwright, then tell your agent: Inside this repository: -> Read `packages/ghostwright/AGENTS.md`, then blackbox test `` using Ghostwright's public API. +> Read `experiments/ghostwright/AGENTS.md`, then blackbox test `` using Ghostwright's public API. Start with the [agent quickstart](docs/agent-quickstart.md) or prove your agent can [exit vi](examples/async/agent-closes-vi.test.ts). @@ -27,40 +27,116 @@ Consumers receive prebuilt WASM, terminfo, and the native host for each supporte ## First async test ```ts -import { expect, test } from 'bun:test'; -import { expectTerminal, withTerminalAsync } from 'ghostwright'; +import { expect, test } from 'vitest'; +import 'ghostwright/vitest'; +import { launchTerminal } from 'ghostwright'; test('interactive CLI', async () => { - await withTerminalAsync( - { - command: 'bun', - args: ['src/cli.ts'], - cwd: process.cwd(), - viewport: { columns: 80, rows: 24 }, - }, - async (terminal) => { - await expectTerminal(terminal.getByText('Ready')).toBePresent(); - - const action = await terminal.keyboard.press('Enter'); - await expectTerminal(terminal).toHaveShownText('Working', { - since: action, - }); - await expectTerminal(terminal.getByText('Complete')).toBeStable(); - - const status = await terminal.process.waitForExit(); - expect(status.exitCode).toBe(0); - }, - ); + await using terminal = await launchTerminal({ + command: 'node', + args: ['src/cli.js'], + viewport: { columns: 80, rows: 24 }, + }); + const { screen, keyboard } = terminal; + + expect(await screen.findByText('Ready')).toBeVisible(); + await keyboard.press('Enter'); + expect(await screen.findByText('Complete')).toBeVisible(); + expect((await terminal.process.waitForExit()).exitCode).toBe(0); }); ``` -The callback owns the terminal. Normal return, throw, assertion failure, and cancellation close the PTY, sidecar, application process group, and WASM resources before the outer operation completes. +`launchTerminal` returns an owned execution scope. `await using` or `close()` cancels owned work and awaits PTY, sidecar, process-group, and WASM cleanup. Disposal is idempotent. Query results and locators are not disposable. TypeScript users need explicit-resource-management support in their compiler/runtime toolchain. -Effection users get the same operations and lifecycle through `withTerminal`; see the [Effection examples](examples/effection/). +The callback form uses the same lifecycle: -## Synchronization model +```ts +import { withTerminal } from 'ghostwright'; + +await withTerminal(options, async ({ screen, keyboard }) => { + await screen.findByText('Ready'); + await keyboard.press('Enter'); +}); +``` + +**Disposal cannot observe a test-body exception.** Bare `await using` guarantees cleanup, but does not automatically retain failure-only traces. Use `trace: 'on'`, `withTerminal`, or a runner fixture for that. A runner can also call `terminal.recordFailure(error)` explicitly. + +With Vitest, the exported fixture owns launches and reports test failures before cleanup: + +```ts +import { expect } from 'vitest'; +import { test } from 'ghostwright/vitest'; + +test('CLI starts', async ({ launchTerminal }) => { + const { screen } = await launchTerminal(options); + expect(await screen.findByText('Ready')).toBeVisible(); +}); +``` + +For Jest, import `ghostwright/jest` to install the same immediate matchers. Use the callback helper for automatic failure artifacts. `ghostwright/matchers` exports `terminalMatchers` and `createRunnerMatchers(definitions)` for local `expect.extend(...)` integrations. Core imports do not load a test runner. + +Effection users import `withTerminal` from `ghostwright/effection`; see the [Effection examples](examples/effection/). + +## Scoped capture and semantic addressing + +The new region API separates immutable locator queries, paired observations, terminal-evidence matchers, and scope-owned execution. Start with [Scoped observations and assertions](docs/scoped-execution.md). The pizza and pizza-preact tests demonstrate this API through real PTYs. + +Descriptions provide identity and geometry, not proof of focus or value. Typed matcher extensions stay local. Async and Effection capture share one execution core. + +The older text-locator and screen-history API below remains available during this experiment. + +## Queries and waiting + +A locator is a reusable recipe. A query returns frozen `RegionInspection` evidence from one observation. Later output does not change a previous query result. -Ghostwright assertions are revision-driven rather than polling-based: +| Query | No matches | Multiple matches | Waits | +| ------------ | ---------- | -------------------- | ----- | +| `getBy` | Throws | Throws | No | +| `queryBy` | `null` | Throws | No | +| `findBy` | Retries | Retries until unique | Yes | +| `getAllBy` | Throws | Returns all | No | +| `queryAllBy` | `[]` | Returns all | No | +| `findAllBy` | Retries | Returns all | Yes | + +Each method accepts a locator recipe. The `ByText` forms construct a cell-aware text recipe and use the same engine. Text matching is literal and case-sensitive within one physical row. By default it finds substrings; `{ exact: true }` matches the whole row after trailing padding is removed. `BySelector` forms require an adapter-owned `selector` function in the launch options; core does not interpret CSS. + +```ts +import { launchTerminal } from 'ghostwright'; +import { clackTtyExtension, locator } from '@ghostwright/clack-tty'; + +await using terminal = await launchTerminal({ + ...options, + env: { ...options.env, CLACK_UI_SEMANTIC: '1' }, + extensions: [clackTtyExtension()], + selector: locator, +}); +const { screen, mouse, waitFor } = terminal; +const save = locator('button[label="save"]'); + +await screen.findBy(save); // Same behavior as findBySelector('button[label="save"]'). +await mouse.click(save); // Resolve again before input; never reuse old query coordinates. +await waitFor(() => { + expect(screen.getBySelector('text[label="status"]')).toContainText('Saved'); +}); +``` + +Runner matchers inspect immediately. `waitFor` starts an attempt without waiting for an interval, retries thrown/rejected assertions, and returns any successful value—including `false`. It awaits an async callback without overlapping attempts. Terminal observations prompt early retries; an interval covers changes that produce no terminal output. Process exit alone does not end a generic wait. Scope disposal or an explicit abort signal cancels it. + +`waitFor` and `findBy` accept `{ timeoutMs, intervalMs, signal }`. The default timeout is the session's `assertionTimeoutMs` or 4000 ms. The default interval is 50 ms. Durations must be finite and between 0 and 2147483647 ms, the supported timer range. Text finders accept text and wait options in the same options object. Timeout errors retain the last assertion as their cause and include the current screen. Put input outside retry callbacks. Combine related assertions in one callback when they must describe a coherent state. + +Described queries require a valid description paired with the current screen. An unavailable or invalid description throws—even for `queryBy` and `queryAllBy`. Unknown structure is not proof of absence. `findBy` and `waitFor` can wait for the next valid observation. + +A successful query proves a match, not visibility, focus, or enabled state. Use the corresponding evidence matcher. `toBeVisible` requires viewport overlap and at least one cell without the invisible style; it does not assert terminal-window visibility or graphical occlusion. `null` is accepted by `.not.toBeVisible()`. + +## Input targets + +`mouse.move`, `hover`, `down`, `up`, `click`, and `doubleClick` accept coordinates or locator recipes. A locator action waits for one on-screen target and fails immediately on ambiguity. This differs deliberately from `findBy`, which retries ambiguity. + +`mouse.drag(start, destination)` accepts a locator or point as its start, and either a point or `{ by: { columns, rows } }` as its destination. The start resolves once. The gesture does not chase a moving target, retry input, or enable application mouse reporting. Invalid destinations fail before button-down. Modifier options support Shift, Alt, and Control; standard mouse reports cannot encode Super/Command. `keyboard` and `mouse` send terminal input, not application events. + +## Lower-level assertion helpers + +The `expectTerminal` helpers below remain revision-driven: | Intent | API | | ------------------------------------- | ------------------- | @@ -157,7 +233,7 @@ The deterministic profile uses `TERM=xterm-ghostty`, package-local terminfo, tru ## Fidelity boundary -A sidecar output frame is one OS PTY read, not a pixel-rendered frame. The kernel may combine application writes. Ghostwright never splits a read into artificial per-byte revisions and never coalesces separate host frames, but it cannot recover a state overwritten within one kernel-coalesced read. +A sidecar output frame is one OS PTY read, not a pixel-rendered frame. The kernel may combine application writes. Ghostwright does not create per-byte revisions. Registered OSC boundaries can split one read into coherent description/screen observations. Without such boundaries, it cannot recover a state overwritten within one kernel-coalesced read. Ghostwright validates terminal-grid and PTY behavior. It does not validate fonts, shaping, rasterization, GPU output, or graphical occlusion. @@ -165,7 +241,7 @@ Ghostwright validates terminal-grid and PTY behavior. It does not validate fonts Ghostwright is **not a sandbox**. Commands run directly, without an implicit shell, using the caller's filesystem, network, process, and credential permissions. Launch a shell explicitly only when shell syntax is intended. -Failure tracing defaults to `retain-on-failure`. Common secret-like environment keys are redacted, and typed/pasted input can use `{ trace: "redact" }`, but application output and unmarked values may still contain secrets. Use `trace: "off"` for sensitive sessions. +Failure tracing defaults to `retain-on-failure` when the callback helper or runner fixture reports a failure. Bare async disposal cannot detect the test outcome. Common secret-like environment keys are redacted, and typed/pasted input can use `{ trace: "redact" }`, but application output and unmarked values may still contain secrets. Use `trace: "off"` for sensitive sessions. ## Maintainer artifacts @@ -177,28 +253,28 @@ Working on Ghostwright itself (as opposed to consuming it) requires building tho bun run setup ``` -That fetches the pinned Ghostty source, builds `ghostty-vt.wasm` and the native PTY host, compiles terminfo, refreshes checksums, and verifies the result. It needs the exact Zig version recorded in `ghostty.lock.json` (currently 0.15.2) on `PATH`; nothing else is required. The command is idempotent and safe to re-run. +That fetches the pinned Ghostty source, builds `ghostty-vt.wasm` and the native PTY host, compiles terminfo, refreshes checksums, and verifies the result. It needs the exact Zig version recorded in `ghostty.lock.json` (currently 0.15.2) on `PATH`; Rust/Cargo and the platform linker are also required for the native host. Consumers do not need these tools. The command is idempotent and safe to re-run. Then run the tests: ```sh -bun test examples +pnpm test ``` -`ghostty.lock.json` is the source of truth for the build contract and is edited by hand. `bun run update:manifest` only refreshes the `artifacts` checksum map, and only for targets built on the current machine; entries for targets built elsewhere (for example the Linux hosts when building on macOS) are preserved. `bun run verify:artifacts` skips and reports artifacts that are absent locally, and fails hard on any artifact that is present but does not match. +This runs the Bun suite and real Jest/Vitest matcher and failure-artifact contracts. `bun test examples` runs only the application examples. -The PTY host has two side-by-side implementations: +`ghostty.lock.json` is the source of truth for the build contract and is edited by hand. `bun run update:manifest` only refreshes the `artifacts` checksum map, and only for targets built on the current machine; entries for targets built elsewhere (for example the Linux hosts when building on macOS) are preserved. `bun run verify:artifacts` skips and reports artifacts that are absent locally, and fails hard on any artifact that is present but does not match. -- `native/pty-host-c`: packaged pure-C default, compiled with Apple Clang or native `musl-gcc` -- `native/pty-host-rust`: synchronous Rust candidate using `nix`, `minicbor`, and `thiserror` +The sole PTY host is `native/pty-host-rust`. It uses `nix`, `minicbor`, and `thiserror`, without Tokio. The host owns only POSIX processes, PTYs, byte queues, and control messages. ```sh -bun run build:host:c bun run build:host:rust -bun run test:hosts -bun run compare:hosts +bun run test:host +bun run typecheck ``` -See [`HOST-COMPARISON.md`](HOST-COMPARISON.md). Zig remains pinned only because upstream Ghostty uses it to build `ghostty-vt.wasm`; the PTY host has no Zig wrapper or `zig cc` dependency. +Set `GHOSTWRIGHT_RUST_TARGET` to select a Rust target. Release builds need the matching linker and standard library. This rewrite has been built and tested locally only on macOS arm64; Linux and macOS x64 artifacts still need release-runner validation. + +[`HOST-COMPARISON.md`](HOST-COMPARISON.md) is a historical report. Zig remains pinned for upstream Ghostty WASM. Release jobs build native targets on matching runners, compile tracked terminfo, generate package output, and record checksums. `bun run verify:artifacts` independently checks hashes, protocol markers, WASM exports, and ABI layouts without rebuilding. diff --git a/experiments/ghostwright/docs/agent-quickstart.md b/experiments/ghostwright/docs/agent-quickstart.md index fb28807..37ec8f2 100644 --- a/experiments/ghostwright/docs/agent-quickstart.md +++ b/experiments/ghostwright/docs/agent-quickstart.md @@ -63,10 +63,10 @@ Do not combine an executable and arguments into an implicit shell string. ```ts import { expect, test } from 'bun:test'; -import { expectTerminal, withTerminalAsync } from 'ghostwright'; +import { expectTerminal, withTerminal } from 'ghostwright'; test('CLI starts and accepts input', async () => { - await withTerminalAsync( + await withTerminal( { command: 'bun', args: ['src/cli.ts'], @@ -102,17 +102,14 @@ Ghostwright assertions throw ordinary typed errors and require no runner plugin: ```ts import assert from 'node:assert/strict'; import test from 'node:test'; -import { expectTerminal, withTerminalAsync } from 'ghostwright'; +import { expectTerminal, withTerminal } from 'ghostwright'; test('CLI help', async () => { - await withTerminalAsync( - { command: 'node', args: ['dist/cli.js', '--help'] }, - async (terminal) => { - await expectTerminal(terminal.getByText('Usage:')).toBePresent(); - const status = await terminal.process.waitForExit(); - assert.equal(status.exitCode, 0); - }, - ); + await withTerminal({ command: 'node', args: ['dist/cli.js', '--help'] }, async (terminal) => { + await expectTerminal(terminal.getByText('Usage:')).toBePresent(); + const status = await terminal.process.waitForExit(); + assert.equal(status.exitCode, 0); + }); }); ``` diff --git a/experiments/ghostwright/docs/architecture.md b/experiments/ghostwright/docs/architecture.md index b2fd59e..29acc12 100644 --- a/experiments/ghostwright/docs/architecture.md +++ b/experiments/ghostwright/docs/architecture.md @@ -53,12 +53,7 @@ The host performs only OS-facing work that `wasm32-freestanding` cannot perform: It does not parse VT sequences, maintain cells, encode input, or evaluate assertions. -Two implementations are maintained side by side: - -- `native/pty-host-c`: packaged default, pure C compiled with Clang or `musl-gcc` -- `native/pty-host-rust`: synchronous Rust candidate using `nix`, `minicbor`, and `thiserror` - -Both implement the same protocol and pass the same host/full Ghostwright contract. See [`../HOST-COMPARISON.md`](../HOST-COMPARISON.md). +`native/pty-host-rust` is the sole implementation. It uses `nix`, `minicbor`, and `thiserror`, without Tokio. Nonblocking queues keep control commands responsive when a child stops reading or a client stops consuming output. Rust ownership includes a process-killing fallback when protocol failure prevents normal cleanup. ### Ghostty WASM @@ -73,12 +68,12 @@ Ghostty is the sole authority for: - Key, focus, mouse, and paste encoding - Terminal query responses and effects -JavaScript does not maintain a second CSI/OSC parser. +JavaScript does not interpret terminal control sequences. It extracts only registered description OSC messages before forwarding ordinary bytes to Ghostty. ## Session resource tree ```text -withTerminal / withTerminalAsync scope +launchTerminal / withTerminal scope ├── Ghostty WASM instance ├── render state and input encoders ├── PTY-host subprocess @@ -109,21 +104,25 @@ A 20-byte little-endian header contains: Control messages use deterministic CBOR. PTY `WRITE` and `OUTPUT` payloads remain raw bytes. Limits are enforced before allocation/action. -Commands include handshake, spawn, write, resize, signal, and close. Events include output, process exit, PTY EOF, acknowledgement, and structured error. +Commands include handshake, spawn, write, cancel-write, resize, signal, and close. Events include output, process exit, PTY EOF, acknowledgement, and structured error. The spawn barrier establishes and reports trusted PID/process-group information before application code can create descendants. Exec confirmation completes the public spawn operation. +The host bounds queued PTY input at 4 MiB / 1,024 writes and protocol output at 8 MiB. It pauses PTY reads above 4 MiB of queued protocol output. Close and cancel-write commands can overtake blocked PTY input. Input completion acknowledges bytes accepted by the PTY, not application processing. Interrupted native writes report `GW_WRITE_INTERRUPTED` and their partial `bytesWritten` count. Cancellation removes only the unwritten remainder. Client disappearance triggers process cleanup; final protocol flushing has a one-second bound. + ## Output and effects For each PTY-host output frame: 1. Record raw offset and frame sequence. -2. Write the complete frame once to the session's Ghostty instance. -3. Copy synchronous terminal effects out of callbacks. -4. Extract the Ghostty render grid and evaluate one revision boundary. -5. Publish an immutable revision if observable state changed. -6. Drain terminal effects in callback order. -7. Serialize PTY-response writes with user actions. +2. Split ordinary bytes and registered description OSC messages in stream order. +3. Write each ordinary segment once to Ghostty and publish its screen observation. +4. Decode each description with a pure extension decoder. +5. Validate its frame sequence and pair it with the preceding immutable screen. +6. Copy synchronous terminal effects out of callbacks. +7. Queue PTY responses with user actions without blocking output parsing. + +Live sessions and replay share this pipeline. See [Scoped observations and assertions](scoped-execution.md) for the query, matcher, and capture layers. Ghostty callbacks never re-enter terminal write. @@ -147,7 +146,7 @@ Visual convergence compares visible cells/styles, cursor, active buffer, and vie The PTY host emits one output frame for each successful OS read, up to 64 KiB. JavaScript processes frames serially and does not debounce or coalesce them. -The kernel may combine application writes before the host reads. Ghostwright cannot recover a state overwritten inside one kernel-coalesced read and does not manufacture per-byte/parser-action revisions. A revision is a terminal-state boundary, not a claim that a user saw a separate pixel-rendered frame. +The kernel may combine application writes before the host reads. Without a registered description boundary, Ghostwright cannot recover a state overwritten inside one kernel-coalesced read. It does not manufacture per-byte/parser-action revisions. A revision is a terminal-state boundary, not a claim that a user saw a separate pixel-rendered frame. ## Process lifecycle @@ -181,8 +180,8 @@ Explicit overrides of profile-owned environment keys are rejected. Other explici ## Generated artifacts -`dist/`, `artifacts/`, native candidate outputs, and Rust `target/` are generated and Git-ignored. Release jobs build them before packing. Consumers receive prebuilt WASM, terminfo, and four native hosts and do not need native toolchains. +`dist/`, `artifacts/`, native candidate outputs, and Rust `target/` are generated and Git-ignored. Release jobs build them before packing. Consumers receive prebuilt WASM, terminfo, and native hosts and do not need native toolchains. This rewrite has only been built and tested locally on macOS arm64; the other target artifacts still need release validation. -Zig is required only to build upstream Ghostty WASM. The pure-C PTY host does not use a Zig wrapper or `zig cc`. +Maintainers use pinned Zig for upstream Ghostty WASM and Cargo plus the target linker for the Rust host. Artifact metadata pins source commit, toolchains, build flags, protocol/binding versions, ABI layouts, and checksums. Independent verification checks files without rebuilding them. diff --git a/experiments/ghostwright/docs/choosing-assertions.md b/experiments/ghostwright/docs/choosing-assertions.md index 466471d..0a9f469 100644 --- a/experiments/ghostwright/docs/choosing-assertions.md +++ b/experiments/ghostwright/docs/choosing-assertions.md @@ -1,5 +1,7 @@ # Choosing locators and assertions +This page covers the lower-level `expectTerminal` helpers. For runner-owned assertions, frozen screen queries, and general `waitFor`, start with [Queries and waiting](../README.md#queries-and-waiting). + Ghostwright separates first appearance, visual convergence, stable absence, and transient revision history. Choosing the right assertion is the main defense against flaky terminal tests. ## Decision table @@ -15,7 +17,7 @@ Ghostwright separates first appearance, visual convergence, stable absence, and | 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. +These lower-level helpers evaluate current state and subscribe to revisions. The separate general-purpose `waitFor` helper also uses an interval fallback for conditions that produce no terminal output. 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. diff --git a/experiments/ghostwright/docs/debugging-failures.md b/experiments/ghostwright/docs/debugging-failures.md index 9af6cd3..d7437e9 100644 --- a/experiments/ghostwright/docs/debugging-failures.md +++ b/experiments/ghostwright/docs/debugging-failures.md @@ -7,7 +7,7 @@ Ghostwright failures are designed for coding agents to diagnose from the thrown The default trace policy is `retain-on-failure`: ```ts -await withTerminalAsync( +await withTerminal( { command: 'my-cli', name: 'save-flow', diff --git a/experiments/ghostwright/docs/interaction-recipes.md b/experiments/ghostwright/docs/interaction-recipes.md index 6a1af67..f32d3bb 100644 --- a/experiments/ghostwright/docs/interaction-recipes.md +++ b/experiments/ghostwright/docs/interaction-recipes.md @@ -1,11 +1,11 @@ # Interaction recipes -These recipes use the async API. With Effection, replace `await` with `yield*` and `withTerminalAsync` with `withTerminal`; operation names and semantics remain the same. +These recipes use the async API. With Effection, import `withTerminal` from `ghostwright/effection` and replace `await` with `yield*`; operation names and semantics remain the same. ## Launch an interactive command ```ts -await withTerminalAsync( +await withTerminal( { command: 'my-cli', args: ['--interactive'], @@ -190,7 +190,8 @@ Runnable versions: ```ts import { run } from 'effection'; -import { expectTerminal, withTerminal } from 'ghostwright'; +import { expectTerminal } from 'ghostwright'; +import { withTerminal } from 'ghostwright/effection'; await run(function* () { return yield* withTerminal(options, function* (terminal) { diff --git a/experiments/ghostwright/docs/scoped-execution.md b/experiments/ghostwright/docs/scoped-execution.md new file mode 100644 index 0000000..d27e597 --- /dev/null +++ b/experiments/ghostwright/docs/scoped-execution.md @@ -0,0 +1,124 @@ +# Scoped observations and assertions + +## Evidence + +Ghostty-decoded cells, styles, and cursor state are the assertion evidence. An optional OSC description provides identity and geometry. It cannot prove focus, input value, or application behavior. + +The output pipeline publishes descriptions with the immutable screen that precedes their OSC boundary. Two descriptions can share one screen snapshot. A PTY read is not a render boundary. Unregistered applications still get screen observations, but overwritten intermediate states cannot be recovered. + +`RegionLocator` is an immutable query. It owns no session or pending work. `resolve(observation)` produces `RegionInspection` values tied to that observation. Inspections retain original bounds separately from viewport clipping. An offscreen top border does not become the first visible row. + +Without OSC, `defineScreenLocator(source, resolve)` passes a `ScreenSnapshot` to a pure resolver that returns zero or more rectangles. It resolves screen observations through the same inspection and execution layer. The [Vim/netrw spike](../examples/vim-netrw/README.md) demonstrates an authored spatial adapter, including its deliberate limits. + +## Async API + +```ts +import { withTerminal, textContains, sequence } from 'ghostwright'; +import { clackTtyExtension, expectUI, locator } from '@ghostwright/clack-tty'; + +const name = locator('input[label="name"]'); +const notice = locator('text[label="status"]'); + +await withTerminal( + { + command: 'node', + args: ['app.js'], + env: { CLACK_UI_SEMANTIC: '1' }, + extensions: [clackTtyExtension()], + }, + async (ui) => { + await expectUI(ui, name).toHaveInputFocus(); + await ui.keyboard.type('Ryan'); + await ui.expect(name).toContainText('Ryan'); + + const capture = await ui.capture( + { + until: sequence( + notice.satisfies(textContains('Saving')), + notice.satisfies(textContains('Saved')), + ), + timeoutMs: 4000, + }, + async (child) => { + await child.keyboard.press('Enter'); + }, + ); + + for (const observation of capture.observations) { + // Pure historical evaluation. It never reads the current session. + const regions = notice.resolve(observation); + for (const region of regions) console.log(region.text()); + } + }, +); +``` + +The example requires an application that emits the named descriptions. See the pizza tests for runnable journeys. + +## Matchers + +Matchers are synchronous functions from terminal evidence to a result with `pass`, `expected`, and `actual`. The executor supplies subscriptions, retries, deadlines, and cancellation. + +```ts +import { createExpect, defineMatchers, textContains, type RegionInspection } from 'ghostwright'; + +const expect = createExpect().extend( + defineMatchers({ + toShow(actual: RegionInspection, text: string) { + return textContains(text)(actual); + }, + }), +); + +await expect(ui, name).toShow('Ryan'); +``` + +Extension returns a new typed factory. Registration is local. There is no global registry or declaration merging. Native Effection callers use `yield* expect.operation(ui, name).toShow('Ryan')`. + +## Capture lifetime + +The executor establishes the baseline, subscription, limits, and deadline before it starts the callback. The baseline is separate from recorded observations. + +- A matching observation stops recording and remains in the result. +- Later observations in the same transport batch stay out of that capture. +- Recording completion does not end the callback. Capture waits for both. +- The deadline stays active while the callback finishes. +- Abort, callback failure, timeout, and storage overflow stop recording and cancel child-owned work. +- Process EOF is checked after queued terminal output is inspected. +- A failed capture leaves its parent session open. + +`sequence(a, b)` requires separate observations. A baseline does not prove a transition. `settled(locator, ms)` tracks region contents and cursor evidence. Its `geometry` option tracks position and size instead. Region settlement does not restart for unrelated animation. It suspends while output has no fresh associated description. `elapsed(ms)` is available for an explicit duration. + +Capture defaults to 1,000 observations and a 64 MiB serialized-size estimate. Exceeding either bound fails with `GW_CAPTURE_LIMIT`; it does not silently discard the beginning. + +The capture callback receives a child executor. Work through an outer `ui` remains parent-owned. Nesting does not rebind existing handles. Closed executors reject new scoped work. + +Each executor exposes its scope-owned `signal`. The signal aborts on normal scope completion as well as failure. An optional capture signal adds cancellation; it does not replace scope ownership. The returned promise distinguishes success from failure. Arbitrary JavaScript promises cannot be forcibly cancelled, and cancellation cannot undo bytes already written to the PTY. + +Async callbacks begin outside the Effection dispatcher. This permits runner assertion helpers that drain promises synchronously to call back into the executor without blocking its dispatcher. + +## Effection API + +`withTerminal` from `ghostwright/effection` uses the same session resource, matcher executor, and capture operation: + +```ts +import { run } from 'effection'; +import { withTerminal } from 'ghostwright/effection'; + +await run(function* () { + yield* withTerminal(options, function* (ui) { + yield* ui.expect(name).toContainText('Ryan'); + yield* ui.capture({ until: notice.satisfies(textContains('Saved')) }, function* (child) { + yield* child.keyboard.press('Enter'); + }); + }); +}); +``` + +## Replay + +`replayTrace(path, { extensions: [clackTtyExtension()] })` uses the live output splitter and description-pairing pipeline. Traces record required decoder identities and the initial viewport. Replay rejects missing decoders and traces whose beginning was evicted. Replay returns both screen revisions and paired observations. + +## Boundaries still worth revisiting + +The older text-locator, screen-revision, history, and graphics APIs remain available. They have not all been redesigned into pure locator descriptions. The [public query and lifetime APIs](../README.md#queries-and-waiting) add frozen screen queries, general `waitFor`, runner matchers, disposable acquisition, and locator-aware mouse actions over these primitives. diff --git a/experiments/ghostwright/examples/README.md b/experiments/ghostwright/examples/README.md index 2ce650f..8c2c2e9 100644 --- a/experiments/ghostwright/examples/README.md +++ b/experiments/ghostwright/examples/README.md @@ -2,19 +2,21 @@ Both example suites automate the same interactive CLI so the two public API styles can be compared directly: -- [`async/simple-cli.test.ts`](async/simple-cli.test.ts) uses `withTerminalAsync` and promises. -- [`effection/simple-cli.test.ts`](effection/simple-cli.test.ts) uses `withTerminal` and Effection operations. +- [`async/simple-cli.test.ts`](async/simple-cli.test.ts) uses `launchTerminal`, `await using`, and screen queries. +- [`effection/simple-cli.test.ts`](effection/simple-cli.test.ts) uses `withTerminal` from the Effection entry point. - [`async/agent-closes-vi.test.ts`](async/agent-closes-vi.test.ts) proves an async coding agent can exit vi. - [`effection/agent-closes-vi.test.ts`](effection/agent-closes-vi.test.ts) proves the same thing with structured concurrency. - [`async/bash-vi-roundtrip.test.ts`](async/bash-vi-roundtrip.test.ts) verifies Bash's primary screen survives a vi alternate-screen round trip. - [`effection/bash-vi-roundtrip.test.ts`](effection/bash-vi-roundtrip.test.ts) runs the same screen-restoration check with Effection. -The vi examples use an isolated temporary HOME and a fixture marker, avoiding user configuration, welcome-screen, and locale assumptions. Linux CI installs `vim-tiny`; macOS uses its system vi. +The basic vi examples use an isolated temporary HOME and a fixture marker, avoiding user configuration, welcome-screen, and locale assumptions. + +The [Vim/netrw spike](vim-netrw/README.md) goes further: it finds an explorer and opens a file using only screen-derived geometry. It requires Vim with the full netrw runtime; `vim-tiny` alone is not sufficient. See its README for the supported layout and validation limits. The simple CLI application under test is `/bin/sh`, which is present on every macOS and Linux host supported by Ghostwright. The shell is launched explicitly—Ghostwright never inserts an implicit shell. Its script uses only POSIX `printf` and `read` builtins, prompts for a name, and prints a greeting. Run all examples with: ```sh -bun test packages/ghostwright/examples +bun test experiments/ghostwright/examples ``` diff --git a/experiments/ghostwright/examples/async/agent-closes-vi.test.ts b/experiments/ghostwright/examples/async/agent-closes-vi.test.ts index 821d1b1..277adc7 100644 --- a/experiments/ghostwright/examples/async/agent-closes-vi.test.ts +++ b/experiments/ghostwright/examples/async/agent-closes-vi.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; // oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; -import { expectTerminal, withTerminalAsync } from '../../src/index.ts'; +import { expectTerminal, withTerminal } from '../../src/index.ts'; test('a coding agent can successfully close vi', async () => { const directory = await mkdtemp(join(tmpdir(), 'ghostwright-vi-')), @@ -11,7 +11,7 @@ test('a coding agent can successfully close vi', async () => { await writeFile(fixture, 'GHOSTWRIGHT_VI_MARKER\n'); try { - await withTerminalAsync( + await withTerminal( { command: 'vi', args: [fixture], diff --git a/experiments/ghostwright/examples/async/bash-vi-roundtrip.test.ts b/experiments/ghostwright/examples/async/bash-vi-roundtrip.test.ts index 1083c63..136bc50 100644 --- a/experiments/ghostwright/examples/async/bash-vi-roundtrip.test.ts +++ b/experiments/ghostwright/examples/async/bash-vi-roundtrip.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; // oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; -import { expectTerminal, withTerminalAsync } from '../../src/index.ts'; +import { expectTerminal, withTerminal } from '../../src/index.ts'; test('interactive bash restores its screen after vi exits', async () => { const directory = await mkdtemp(join(tmpdir(), 'ghostwright-bash-vi-')), @@ -11,7 +11,7 @@ test('interactive bash restores its screen after vi exits', async () => { await writeFile(fixture, 'GHOSTWRIGHT_VI_MARKER\n'); try { - await withTerminalAsync( + await withTerminal( { command: 'bash', args: ['--noprofile', '--norc', '-i'], diff --git a/experiments/ghostwright/examples/async/simple-cli.test.ts b/experiments/ghostwright/examples/async/simple-cli.test.ts index 1ed9cc4..5e9b720 100644 --- a/experiments/ghostwright/examples/async/simple-cli.test.ts +++ b/experiments/ghostwright/examples/async/simple-cli.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test'; -import { expectTerminal, withTerminalAsync } from '../../src/index.ts'; +import { launchTerminal } from '../../src/index.ts'; const cli = { command: '/bin/sh', @@ -11,17 +11,20 @@ const cli = { trace: 'off' as const, }; -test('async API drives a portable interactive shell CLI', async () => { - await withTerminalAsync(cli, async (terminal) => { - await expectTerminal(terminal.getByText('What is your name?')).toBePresent(); +test('await using drives a portable CLI and owns terminal cleanup', async () => { + await using terminal = await launchTerminal(cli); + const { screen, keyboard } = terminal; + const prompt = await screen.findByText('What is your name?'); - await terminal.keyboard.type('Ada'); - await terminal.keyboard.press('Enter'); + await keyboard.type('Ada'); + await keyboard.press('Enter'); - await expectTerminal(terminal.getByText('Hello, Ada!')).toBeStable(); + const greeting = await screen.findByText('Hello, Ada!'); + expect(greeting.text()).toBe('Hello, Ada!'); + // A query result keeps its original evidence after later output. + expect(prompt.text()).toBe('What is your name?'); - const status = await terminal.process.waitForExit(); - expect(status.exitCode).toBe(0); - expect(status.ptyEof).toBe(true); - }); + const status = await terminal.process.waitForExit(); + expect(status.exitCode).toBe(0); + expect(status.ptyEof).toBe(true); }); diff --git a/experiments/ghostwright/examples/effection/agent-closes-vi.test.ts b/experiments/ghostwright/examples/effection/agent-closes-vi.test.ts index 3be1c31..f8e70ae 100644 --- a/experiments/ghostwright/examples/effection/agent-closes-vi.test.ts +++ b/experiments/ghostwright/examples/effection/agent-closes-vi.test.ts @@ -4,7 +4,8 @@ import { tmpdir } from 'node:os'; // oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; import { run } from 'effection'; -import { expectTerminal, withTerminal } from '../../src/index.ts'; +import { expectTerminal } from '../../src/index.ts'; +import { withTerminal } from '../../src/effection/index.ts'; test('a coding agent can successfully close vi with Effection', async () => { const directory = await mkdtemp(join(tmpdir(), 'ghostwright-vi-')), diff --git a/experiments/ghostwright/examples/effection/bash-vi-roundtrip.test.ts b/experiments/ghostwright/examples/effection/bash-vi-roundtrip.test.ts index 8200f08..7039914 100644 --- a/experiments/ghostwright/examples/effection/bash-vi-roundtrip.test.ts +++ b/experiments/ghostwright/examples/effection/bash-vi-roundtrip.test.ts @@ -4,7 +4,8 @@ import { tmpdir } from 'node:os'; // oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; import { run } from 'effection'; -import { expectTerminal, withTerminal } from '../../src/index.ts'; +import { expectTerminal } from '../../src/index.ts'; +import { withTerminal } from '../../src/effection/index.ts'; test('interactive bash restores its screen after vi exits with Effection', async () => { const directory = await mkdtemp(join(tmpdir(), 'ghostwright-bash-vi-')), diff --git a/experiments/ghostwright/examples/effection/simple-cli.test.ts b/experiments/ghostwright/examples/effection/simple-cli.test.ts index bb8916b..66476a8 100644 --- a/experiments/ghostwright/examples/effection/simple-cli.test.ts +++ b/experiments/ghostwright/examples/effection/simple-cli.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'bun:test'; import { run } from 'effection'; -import { expectTerminal, withTerminal } from '../../src/index.ts'; +import { expectTerminal } from '../../src/index.ts'; +import { withTerminal } from '../../src/effection/index.ts'; const cli = { command: '/bin/sh', diff --git a/experiments/ghostwright/examples/vim-netrw/README.md b/experiments/ghostwright/examples/vim-netrw/README.md new file mode 100644 index 0000000..645f57b --- /dev/null +++ b/experiments/ghostwright/examples/vim-netrw/README.md @@ -0,0 +1,83 @@ +# Finding controls in Vim without OSC + +This spike treats Vim's netrw explorer as a control, using only its rendered screen. The adapter is ordinary TypeScript. It does not import Vim state, query buffers or window coordinates, emit OSC descriptions, or use a runtime LLM. + +```ts +const explorer = netrw(ui); +const readme = explorer.find('README.md'); + +await readme.open(); +await ui.expect(readme.editor).toContainText('# Opened through the explorer'); +``` + +`netrw.test.ts` is the runnable example. It launches a real Vim with an explorer on the left and an editor on the right. Both windows contain `README.md`. Only the explorer's entry is a valid target. + +## Run + +From `experiments/ghostwright`, after the normal artifact setup: + +```sh +bun test examples/vim-netrw +``` + +The tests require Vim with its bundled netrw. They do not silently skip a missing installation. Set `GHOSTWRIGHT_VIM` to select another Vim executable. The spike was validated locally with Apple's Vim 9.1 and netrw v184 on macOS arm64, not Neovim or other Vim versions. + +The shared fixture in `fixture.ts` disables user configuration, swap files, viminfo, and netrw history. It explicitly loads the bundled netrw and enables Vim's mouse handling with `mouse=a` and `ttymouse=sgr`. Startup commands arrange the windows; opening the target file uses only normal-mode navigation and Enter. + +## How it finds a file + +1. Trace a reverse-video vertical separator from the top of the screen to the statusline. +2. Confirm the reverse-video statusline and the full-height left-window layout. +3. Find netrw's heading and the rules above and below its banner. +4. Search only the listing below the banner for an exact filename row. + +Every step reads the same immutable screen snapshot. Regions exclude the separator, statusline, and neighboring editor. Duplicate entries remain duplicate matches; strict execution rejects them. Ambiguous window boundaries raise an error rather than selecting the first candidate. + +`open()` waits for the entry and a visible cursor, then checks that the cursor belongs to the listing. It sends a counted `j` or `k` motion. It resolves the entry again and waits for visible cursor evidence before pressing Enter. It recognizes the resulting editor from the filename painted in the left statusline. Assertions then inspect that editor's actual cells. + +## Drag a divider, then replay the evidence + +`resize-replay.test.ts` extends the journey at 80×24 and 100×36: + +1. Open README.md through the explorer. +2. Derive the divider and neighboring editor from the recognized left editor. +3. Drag the divider eight columns to the right with real mouse input. +4. Assert that the left pane grows, the right pane shrinks, and both retain their content. The terminal viewport does not change. +5. Insert text into the opened file, then undo it and quit Vim without saving. +6. Replay the persisted trace after Vim has closed and its temporary files have been removed. + +The mouse sends button-down, motion with the button held, and button-up. No command asks Vim to resize a window. This tests a split owned by Vim, not a split owned by a terminal application's GUI. + +Capture starts before the drag. Its completion condition requires the wider editor followed by the visible edit. The recording retains that edit after the live application undoes it. Replay compares every captured screen's cells, styles, cursor, modes, and observation order, plus the regions resolved by the same locators. It also runs the same width and text matchers on the replayed endpoint. Clock timestamps are not part of this comparison. + +Successful replay tests remove their trace files. Failures leave them under `.ghostwright/vim-drag-*` in the test working directory. Other Vim tests retain traces on failure. + +## Deliberate limits + +This is an authored adapter for one arrangement, not a general Vim DOM: + +- One full-height explorer on the left of a vertical split. +- One command row and the default monochrome separator/statusline appearance. +- Netrw's visible banner and thin listing style. +- Visible, unwrapped regular-file entries with simple ASCII names. +- Normal-mode keyboard navigation, starting inside the listing. +- No scrolling search, directory traversal, tree/wide views, themes, or arbitrary window layouts. + +Missing geometry or a missing entry stays unmatched and ends in the normal assertion timeout, with the screen diagnostic. The adapter does not guess coordinates. An unsupported filename, an ambiguous boundary, or a cursor in the wrong window fails explicitly. Recognition is a screen heuristic under these constraints, not proof that arbitrary Vim layouts can be reconstructed. + +## What this validates + +Screen recognition starts with: + +```ts +const locator = defineScreenLocator('description', (screen) => { + // Recognize regions in this snapshot. Return zero or more rectangles. + return regions; +}); +``` + +It uses the same region inspection, strict matching, assertions, and scope-owned execution as OSC-backed locators. The Vim-specific interpretation and control actions stay in `netrw.ts`; they are not built into Ghostwright. + +The live tests cover two viewport sizes, both navigation directions, the neighboring filename decoy, refusal to navigate from the wrong window, mouse-driven split resizing, in-memory editing and undo, and capture/replay evidence equivalence. Focused recognition tests use grids decoded by real Ghostty to check banner scoping, duplicate matches, missing boundaries, and ambiguity. + +The drag journey uses `mouse.drag(divider, { by: { columns: 8, rows: 0 } })`, frozen screen queries, and explicit `waitFor` assertions. The divider is a derived locator. Mouse resolution happens once at the start of the gesture. There is no Vim instrumentation and no action retry. diff --git a/experiments/ghostwright/examples/vim-netrw/fixture.ts b/experiments/ghostwright/examples/vim-netrw/fixture.ts new file mode 100644 index 0000000..653f9f9 --- /dev/null +++ b/experiments/ghostwright/examples/vim-netrw/fixture.ts @@ -0,0 +1,61 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +// oxlint-disable-next-line no-restricted-imports -- temporary fixture paths +import { join } from 'node:path'; +import { + withTerminal, + type AsyncExecution, + type TerminalLaunchOptions, + type Viewport, +} from '../../src/index.ts'; + +/** A real, isolated Vim with its bundled netrw. No application instrumentation. */ +export async function withVim( + options: { viewport: Viewport; trace?: TerminalLaunchOptions['trace'] }, + body: (ui: AsyncExecution) => Promise, +): Promise { + const directory = await mkdtemp(join(tmpdir(), 'ghostwright-netrw-')); + try { + await writeFile( + join(directory, 'README.md'), + '# Opened through the explorer\nThis text came from README.md.\n', + ); + await writeFile( + join(directory, 'WELCOME.txt'), + 'README.md\nThis is a decoy in the neighboring editor.\n', + ); + return await withTerminal( + { + command: process.env.GHOSTWRIGHT_VIM ?? 'vim', + args: [ + '-Nu', + 'NONE', + '-i', + 'NONE', + '-n', + '-R', + '--cmd', + 'set nocompatible', + '--cmd', + 'set runtimepath=$VIMRUNTIME packpath=$VIMRUNTIME', + '-c', + 'let g:netrw_dirhistmax=0 | let g:netrw_liststyle=0 | let g:netrw_winsize=45', + '-c', + 'runtime plugin/netrwPlugin.vim', + '-c', + 'set laststatus=2 mouse=a ttymouse=sgr', + '-c', + 'Vexplore .', + 'WELCOME.txt', + ], + cwd: directory, + env: { HOME: directory, EXINIT: '', VIMINIT: '', LC_ALL: 'C' }, + viewport: options.viewport, + trace: options.trace ?? 'retain-on-failure', + }, + body, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/experiments/ghostwright/examples/vim-netrw/netrw.test.ts b/experiments/ghostwright/examples/vim-netrw/netrw.test.ts new file mode 100644 index 0000000..61db03c --- /dev/null +++ b/experiments/ghostwright/examples/vim-netrw/netrw.test.ts @@ -0,0 +1,58 @@ +import { expect, test } from 'bun:test'; +import { netrw } from './netrw.ts'; +import { withVim } from './fixture.ts'; + +for (const viewport of [ + { columns: 80, rows: 24 }, + { columns: 100, rows: 36 }, +]) { + test(`open a file from Vim's explorer at ${viewport.columns}×${viewport.rows}`, async () => { + await withVim({ viewport }, async (ui) => { + const explorer = netrw(ui); + const readme = explorer.find('README.md'); + + // Recognize the explorer, then find a file inside it. The other window + // also says "README.md", but it is not an explorer entry. + await ui.expect(explorer.region).toContainText('Netrw Directory Listing'); + await ui.expect(readme.region).toContainText('README.md'); + + // The adapter navigates with normal-mode keys and presses Enter. + // It does not ask Vim for a buffer, filename, or window coordinate. + await readme.open(); + await ui.expect(readme.editor).toContainText('# Opened through the explorer'); + await ui.expect(readme.editor).toContainText('This text came from README.md.'); + await ui.expect(readme.editor).toContainCursor({ visible: true }); + + await ui.keyboard.type(':qa!'); + await ui.keyboard.press('Enter'); + expect((await ui.process.waitForExit()).exitCode).toBe(0); + }); + }); +} + +test('open an earlier file after moving to the end of the listing', async () => { + await withVim({ viewport: { columns: 80, rows: 24 } }, async (ui) => { + const explorer = netrw(ui); + await ui.expect(explorer.region).toContainCursor({ visible: true }); + await ui.keyboard.type('G'); + await ui.expect(explorer.find('WELCOME.txt').region).toContainCursor({ visible: true }); + const readme = explorer.find('README.md'); + await readme.open(); + await ui.expect(readme.editor).toContainText('# Opened through the explorer'); + }); +}); + +test('refuse to navigate when the cursor belongs to the neighboring editor', async () => { + await withVim({ viewport: { columns: 80, rows: 24 } }, async (ui) => { + const explorer = netrw(ui); + await ui.expect(explorer.region).toContainCursor({ visible: true }); + await ui.keyboard.press({ key: 'w', control: true }); + await ui.keyboard.type('l'); + await ui.expect(explorer.region).toSatisfy((region) => ({ + pass: region.screen.cursor.visible && !region.cursor().inside, + expected: 'cursor in the neighboring window', + actual: region.cursor(), + })); + await expect(explorer.find('README.md').open()).rejects.toMatchObject({ code: 'GW_VIM_FOCUS' }); + }); +}); diff --git a/experiments/ghostwright/examples/vim-netrw/netrw.ts b/experiments/ghostwright/examples/vim-netrw/netrw.ts new file mode 100644 index 0000000..151b3b0 --- /dev/null +++ b/experiments/ghostwright/examples/vim-netrw/netrw.ts @@ -0,0 +1,170 @@ +import { + defineScreenLocator, + GhostwrightError, + inspect, + type AsyncExecution, + type Rect, + type RegionInspection, + type RegionLocator, + type ScreenSnapshot, +} from '../../src/index.ts'; + +const heading = '" Netrw Directory Listing'; +const bannerRule = /^" ={3,}\s*$/; +const fail = (code: string, message: string): never => { + throw new GhostwrightError({ code, message }); +}; + +interface Explorer { + readonly window: Rect; + readonly entries: Rect; +} + +/** Recognize one full-height left window in Vim's default monochrome layout. */ +function leftWindow(screen: ScreenSnapshot): Rect | undefined { + const candidates: Rect[] = []; + for (const cell of screen.lines[0]?.cells ?? []) { + if (cell.column === 0 || cell.column >= screen.viewport.columns - 1) continue; + const isSeparator = (row: number): boolean => { + const candidate = screen.lines[row]?.cells[cell.column]; + return ( + candidate !== undefined && + candidate.style.inverse && + !candidate.style.invisible && + ['|', '│'].includes(candidate.text) + ); + }; + if (!isSeparator(0)) continue; + let bottom = 0; + while (bottom < screen.viewport.rows && isSeparator(bottom)) bottom++; + // The separator ends at a reverse-video statusline, not at a gap in text. + const status = screen.lines[bottom]?.cells.slice(0, cell.column); + if ( + bottom !== screen.viewport.rows - 2 || + status?.length !== cell.column || + !status.every((edgeCell) => edgeCell.style.inverse && !edgeCell.style.invisible) + ) + continue; + candidates.push({ column: 0, row: 0, width: cell.column, height: bottom }); + } + if (candidates.length > 1) fail('GW_VIM_LAYOUT_AMBIGUOUS', 'Ambiguous Vim window boundary'); + return candidates[0]; +} + +/** The listing starts below the banner and ends before the window's statusline. */ +function listingBounds(window: RegionInspection): Rect | undefined { + const rows = window.text().split('\n'); + const titles = rows.flatMap((text, row) => (text.trimEnd().startsWith(heading) ? [row] : [])); + if (titles.length > 1) fail('GW_VIM_LAYOUT_AMBIGUOUS', 'Ambiguous netrw heading'); + const title = titles[0]; + if (title === undefined || title === 0 || !bannerRule.test(rows[title - 1]!)) return undefined; + const closingRule = rows.findIndex((text, row) => row > title && bannerRule.test(text)); + if (closingRule === -1) return undefined; + return { + column: window.bounds.column, + row: window.bounds.row + closingRule + 1, + width: window.bounds.width, + height: window.bounds.height - closingRule - 1, + }; +} + +/** Find the banner and listing within the same immutable screen. */ +function explorer(screen: ScreenSnapshot): Explorer | undefined { + const window = leftWindow(screen); + if (!window) return undefined; + const entries = listingBounds(inspect(screen).region(window)); + return entries ? { window, entries } : undefined; +} + +/** A live query for the explorer, derived only from painted cells. */ +export const explorerRegion = defineScreenLocator( + 'netrw explorer (left split, visible banner and statusline)', + (screen) => { + const found = explorer(screen); + return found ? [found.window] : []; + }, +); + +const listingRegion = explorerRegion.derive('listing', (window) => { + const bounds = listingBounds(window); + return bounds ? [bounds] : []; +}); + +/** Thin-list entries with plain ASCII filenames; never a path or a Vim command. */ +export function fileEntry(name: string): RegionLocator { + if (!name || /[^A-Za-z0-9_.-]/.test(name)) + fail('GW_VIM_FILENAME', 'Use a plain ASCII filename, not a path or command'); + return listingRegion.derive(`file ${JSON.stringify(name)} (visible thin-list entry)`, (listing) => + listing + .text() + .split('\n') + .flatMap((text, row) => + text.trimEnd() === name + ? [ + { + column: listing.bounds.column, + row: listing.bounds.row + row, + width: listing.bounds.width, + height: 1, + }, + ] + : [], + ), + ); +} + +function editorFor(name: string): RegionLocator { + return defineScreenLocator( + `Vim editor for ${JSON.stringify(name)} in the left split`, + (screen) => { + const window = leftWindow(screen); + if (!window || explorer(screen)) return []; + const status = inspect(screen) + .region({ column: window.column, row: window.height, width: window.width, height: 1 }) + .text() + .trim(); + // Read the actual statusline. A matching string in buffer contents is not a filename. + const displayedPath = status.split(/\s+/)[0] ?? ''; + return displayedPath.split('/').at(-1) === name ? [window] : []; + }, + ); +} + +/** Small authored control model. Recognition is pure; actions use its owning executor. */ +export function netrw(ui: AsyncExecution): Readonly<{ + region: RegionLocator; + find( + name: string, + ): Readonly<{ region: RegionLocator; editor: RegionLocator; open(): Promise }>; +}> { + return Object.freeze({ + region: explorerRegion, + find(name: string) { + const region = fileEntry(name); + const editor = editorFor(name); + return Object.freeze({ + region, + editor, + async open(): Promise { + const target = await ui.assert(region, (actual) => ({ + pass: actual.screen.cursor.visible, + expected: 'visible normal-mode cursor before navigating', + actual: actual.screen.cursor, + })); + const bounds = explorer(target.screen)!.entries; + const cursor = target.screen.cursor; + if (!inspect(target.screen).region(bounds).cursor().inside) + fail('GW_VIM_FOCUS', 'Move the cursor into the netrw listing before opening a file'); + const distance = target.bounds.row - cursor.row; + if (distance !== 0) + await ui.keyboard.type(`${Math.abs(distance)}${distance > 0 ? 'j' : 'k'}`); + // Resolve again after movement. Do not press Enter until the cursor + // visibly belongs to the intended entry in the current screen. + await ui.expect(region).toContainCursor({ visible: true }); + await ui.keyboard.press('Enter'); + return ui.expect(editor).toContainCursor({ visible: true }); + }, + }); + }, + }); +} diff --git a/experiments/ghostwright/examples/vim-netrw/recognition.test.ts b/experiments/ghostwright/examples/vim-netrw/recognition.test.ts new file mode 100644 index 0000000..2f1eed3 --- /dev/null +++ b/experiments/ghostwright/examples/vim-netrw/recognition.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from 'bun:test'; +import { GhosttyWasmTerminal } from '../../src/terminal/wasm.ts'; +import { type Observation, type ScreenSnapshot } from '../../src/index.ts'; +import { explorerRegion, fileEntry } from './netrw.ts'; + +// Small rendered grids isolate the recognition rules. Ghostty still decodes +// their cells and styles; these tests do not construct pretend client results. +async function screen( + options: { + separator?: boolean; + status?: boolean; + duplicate?: boolean; + extraBoundary?: boolean; + } = {}, +): Promise { + const terminal = await GhosttyWasmTerminal.create({ + columns: 80, + rows: 14, + widthPixels: 800, + heightPixels: 280, + }); + try { + const rows = [ + '" =================================', + '" Netrw Directory Listing', + 'README.md', // another decoy, in the banner rather than the file list + '" Quick Help: :help', + '" =================================', + '../', + './', + 'README.md', + options.duplicate ? 'README.md' : 'WELCOME.txt', + '~', + '~', + '~', + ]; + const inverse = '\x1b[7m', + reset = '\x1b[0m'; + for (const [row, text] of rows.entries()) { + const neighbor = 'README.md'.padEnd(80 - 36 - 1); + terminal.write( + Buffer.from( + `\x1b[${row + 1};1H${text.padEnd(36)}${options.separator === false ? ' ' : inverse + '|' + reset}${neighbor}`, + ), + ); + if (options.extraBoundary) + terminal.write(Buffer.from(`\x1b[${row + 1};61H${inverse}|${reset}`)); + } + terminal.write( + Buffer.from( + `\x1b[13;1H${options.status === false ? reset : inverse}${'directory [RO]'.padEnd(36)} WELCOME.txt${reset}`, + ), + ); + if (options.extraBoundary) + terminal.write(Buffer.from(`\x1b[13;1H${inverse}${'directory [RO]'.padEnd(80)}${reset}`)); + return terminal.snapshot(); + } finally { + terminal.free(); + } +} +function observation(snapshot: ScreenSnapshot): Observation { + return { kind: 'screen', screen: snapshot, sequence: 1, timestamp: 0 }; +} + +test('find a file only below the explorer banner and inside its window', async () => { + const sample = observation(await screen()); + expect(explorerRegion.resolve(sample).map((region) => region.bounds)).toEqual([ + { column: 0, row: 0, width: 36, height: 12 }, + ]); + expect( + fileEntry('README.md') + .resolve(sample) + .map((region) => region.bounds), + ).toEqual([{ column: 0, row: 7, width: 36, height: 1 }]); + expect(fileEntry('MISSING.md').resolve(sample)).toEqual([]); +}); + +test('preserve duplicate matches so strict execution cannot choose one silently', async () => { + expect( + fileEntry('README.md').resolve(observation(await screen({ duplicate: true }))), + ).toHaveLength(2); +}); + +for (const options of [{ separator: false }, { status: false }]) { + test(`do not guess geometry when a visible boundary is missing: ${JSON.stringify(options)}`, async () => { + expect(explorerRegion.resolve(observation(await screen(options)))).toEqual([]); + }); +} + +test('ambiguous window boundaries fail instead of choosing the first one', async () => { + const sample = observation(await screen({ extraBoundary: true })); + expect(() => explorerRegion.resolve(sample)).toThrow('Ambiguous Vim window boundary'); +}); + +test('reject paths and control characters rather than interpreting them as file entries', () => { + for (const name of ['../README.md', '', 'README.md\n']) expect(() => fileEntry(name)).toThrow(); +}); diff --git a/experiments/ghostwright/examples/vim-netrw/resize-replay.test.ts b/experiments/ghostwright/examples/vim-netrw/resize-replay.test.ts new file mode 100644 index 0000000..69d9c73 --- /dev/null +++ b/experiments/ghostwright/examples/vim-netrw/resize-replay.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from 'bun:test'; +import { mkdir, mkdtemp, readdir, rm } from 'node:fs/promises'; +// oxlint-disable-next-line no-restricted-imports -- trace artifact paths +import { join } from 'node:path'; +import { + replayTrace, + sequence, + textContains, + type Matcher, + type ScreenSnapshot, +} from '../../src/index.ts'; +import { netrw } from './netrw.ts'; +import { withVim } from './fixture.ts'; + +for (const viewport of [ + { columns: 80, rows: 24 }, + { columns: 100, rows: 36 }, +]) { + test(`drag Vim's divider, keep editing, and replay at ${viewport.columns}×${viewport.rows}`, async () => { + // Remove traces only after all assertions pass. A failed live or offline + // assertion leaves its artifacts here for diagnosis. + await mkdir('.ghostwright', { recursive: true }); + const traces = await mkdtemp('.ghostwright/vim-drag-'); + const journey = await withVim( + { viewport, trace: { policy: 'on', directory: traces } }, + async (ui) => { + const readme = netrw(ui).find('README.md'); + await readme.open(); + const before = await ui.screen.findBy(readme.editor); + expect(before.text()).toContain('This text came from README.md.'); + + // The editor recognizer found this divider from its painted cells. + // Derive the neighboring pane from the same observation, not Vim internals. + const divider = readme.editor.derive('right divider', (editor) => [ + { + column: editor.bounds.column + editor.bounds.width, + row: editor.bounds.row, + width: 1, + height: editor.bounds.height, + }, + ]); + const neighbor = divider.derive('neighboring editor', (edge) => [ + { + column: edge.bounds.column + 1, + row: edge.bounds.row, + width: edge.screen.viewport.columns - edge.bounds.column - 1, + height: edge.bounds.height, + }, + ]); + expect((await ui.screen.findBy(divider)).text()).toContain('|'); + const neighboringBefore = await ui.screen.findBy(neighbor); + expect(neighboringBefore.text()).toContain('This is a decoy'); + const widened: Matcher = (editor) => ({ + pass: editor.bounds.width === before.bounds.width + 8, + expected: 'left editor widened by eight columns', + actual: editor.bounds, + }); + const edited = textContains('Resized: # Opened through the explorer'); + + const recording = await ui.capture( + { until: sequence(readme.editor.satisfies(widened), readme.editor.satisfies(edited)) }, + async (capture) => { + // Real button-down, motion, button-up. No :vertical resize command + // and no terminal viewport resize masquerading as a mouse gesture. + await capture.mouse.drag(divider, { by: { columns: 8, rows: 0 } }); + await capture.waitFor(() => + expect(capture.screen.getBy(readme.editor).bounds.width).toBe( + before.bounds.width + 8, + ), + ); + const neighboringAfter = await capture.screen.findBy(neighbor); + expect(neighboringAfter.text()).toContain('This is a decoy'); + expect(neighboringAfter.text()).toContain('README.md'); + expect(neighboringAfter.text()).toContain('editor.'); + expect(neighboringAfter.bounds.width).toBe(neighboringBefore.bounds.width - 8); + expect(neighboringAfter.bounds.column).toBe(neighboringBefore.bounds.column + 8); + expect(neighboringAfter.screen.viewport).toEqual(before.screen.viewport); + expect((await capture.screen.findBy(readme.editor)).text()).toContain( + 'This text came from README.md.', + ); + + // Edit in memory: the file remains usable after mouse resizing. + await capture.keyboard.type('ggIResized: '); + await capture.keyboard.press('Escape'); + await capture.waitFor(() => + expect(capture.screen.getBy(readme.editor).text()).toContain( + 'Resized: # Opened through the explorer', + ), + ); + }, + ); + + // Keep using the live application after recording has ended. The + // recording must retain the edit even after undo and process exit. + await ui.keyboard.type('u'); + await ui.waitFor(() => { + const restored = ui.screen.getBy(readme.editor); + expect(restored.text()).not.toContain('Resized:'); + expect(restored.text()).toContain('# Opened through the explorer'); + }); + await ui.keyboard.type(':qa!'); + await ui.keyboard.press('Enter'); + expect((await ui.process.waitForExit()).exitCode).toBe(0); + return { recording, editor: readme.editor, divider, neighbor, widened, edited }; + }, + ); + + // Vim is closed and its temporary files are gone. Replay reads only the trace. + const path = join(traces, (await readdir(traces))[0]!); + const replay = await replayTrace(path); + const { recording, editor, divider, neighbor, widened, edited } = journey; + const endpoint = recording.observations.at(-1)!; + const replayed = replay.observations.filter( + (observation) => + observation.sequence >= recording.baseline.sequence && + observation.sequence <= endpoint.sequence, + ); + const captured = [recording.baseline, ...recording.observations]; + expect(replayed.map((observation) => observation.sequence)).toEqual( + captured.map((observation) => observation.sequence), + ); + for (const [index, observation] of replayed.entries()) { + // Compare every captured state, not only the final text. Wall-clock + // timestamps differ; cells (including styles), cursor, modes, and order must not. + expect(evidence(observation.screen)).toEqual(evidence(captured[index]!.screen)); + for (const query of [editor, divider, neighbor]) { + expect(query.resolve(observation).map((region) => region.bounds)).toEqual( + query.resolve(captured[index]!).map((region) => region.bounds), + ); + } + } + + // The same authored matchers work on reconstructed evidence without a session. + const lastEditor = editor.resolve(replayed.at(-1)!)[0]!; + expect(widened(lastEditor).pass).toBe(true); + expect(edited(lastEditor).pass).toBe(true); + expect(edited(editor.resolve(recording.baseline)[0]!).pass).toBe(false); + expect(edited(editor.resolve(endpoint)[0]!).pass).toBe(true); + await rm(traces, { recursive: true, force: true }); + }); +} + +function evidence( + snapshot: ScreenSnapshot, +): Omit { + const { timestamp: _timestamp, lastVisualChangeAt: _lastVisualChangeAt, ...state } = snapshot; + return state; +} diff --git a/experiments/ghostwright/ghostty.lock.json b/experiments/ghostwright/ghostty.lock.json index ecaaa45..3ed18cb 100644 --- a/experiments/ghostwright/ghostty.lock.json +++ b/experiments/ghostwright/ghostty.lock.json @@ -6,8 +6,8 @@ }, "zigVersion": "0.15.2", "buildFlags": ["-Demit-lib-vt", "-Dtarget=wasm32-freestanding", "ReleaseSmall"], - "ptyHostImplementation": "c", - "ptyHostBuildFlags": ["clang-or-musl-gcc", "-std=c17", "-O2", "linux:-static"], + "ptyHostImplementation": "rust", + "ptyHostBuildFlags": ["cargo", "--release", "--locked", "linux:musl"], "targets": ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64"], "protocolVersion": 1, "bindingVersion": 2, @@ -125,16 +125,7 @@ "sha256": "9cc284061558b47237e478107dbbe8eabd1f6139038f61b1e403bb97e74ced1f" }, "artifacts/pty-host-darwin-arm64": { - "sha256": "947dc314df01a11eb47a507288f8216f8cef149c27c09587a41db6e44242ea6a" - }, - "artifacts/pty-host-darwin-x64": { - "sha256": "2e997b0cee77b9f61a5694c783e18de3cd6e36bcec2e794db43c2a4a269942b4" - }, - "artifacts/pty-host-linux-arm64": { - "sha256": "d3a9fee7159eb298449b61d8c1842f80add5003e17b9924045c582393e9f49c7" - }, - "artifacts/pty-host-linux-x64": { - "sha256": "554b5e74a24e698582c61e9c16ccd82421cd68f8857dd4422912391b610cf937" + "sha256": "e17474ad808b84548e3d354da573cc7d6d1f434b27b51fac367e5ba3624f758d" }, "artifacts/terminfo/67/ghostty": { "sha256": "8ac69a6a57378edd05bcca8769ff49ce3d01e9496ff134781af5b9ee1d934b7b" diff --git a/experiments/ghostwright/native/pty-host-c/main.c b/experiments/ghostwright/native/pty-host-c/main.c deleted file mode 100644 index a438a52..0000000 --- a/experiments/ghostwright/native/pty-host-c/main.c +++ /dev/null @@ -1,190 +0,0 @@ -#include "protocol.h" -#include "session.h" - -#include -#include -#include -#include -#include -#include - -typedef enum { - HOST_INITIAL, - HOST_READY, - HOST_RUNNING, - HOST_DRAINING, - HOST_CLOSED, -} HostState; - -typedef struct { - GwProtocol protocol; - GwSession session; - GwBuffer input; - HostState state; -} Host; - -static int handle_command(Host *host, const GwFrame *frame) { - switch (frame->kind) { - case GW_HELLO: - if (host->state != HOST_INITIAL) - break; - if (gw_emit_ready(&host->protocol, frame->sequence) != 0) - return -1; - host->state = HOST_READY; - return 0; - - case GW_SPAWN: { - if (host->state != HOST_READY) - break; - GwSpawnRequest request; - if (gw_decode_spawn(frame->payload, frame->payload_length, &request) != 0) { - gw_emit_error(&host->protocol, frame->sequence, "GW_LAUNCH", - "malformed SPAWN payload", true); - return -1; - } - int result = gw_session_spawn(&host->session, &host->protocol, - frame->sequence, &request); - gw_spawn_request_free(&request); - if (result != 0) - return -1; - host->state = HOST_RUNNING; - return 0; - } - - case GW_WRITE: { - if (host->state != HOST_RUNNING) - break; - ssize_t written = - gw_session_write(&host->session, frame->payload, frame->payload_length); - if (written < 0) { - gw_emit_error(&host->protocol, frame->sequence, "GW_LAUNCH", - strerror(errno), false); - return 0; - } - return gw_emit_ack(&host->protocol, frame->sequence, GW_WRITE, written); - } - - case GW_RESIZE: { - if (host->state != HOST_RUNNING) - break; - GwViewport viewport; - if (gw_decode_viewport(frame->payload, frame->payload_length, &viewport) != - 0) { - gw_emit_error(&host->protocol, frame->sequence, "GW_PROTOCOL", - "bad resize", false); - } else if (gw_session_resize(&host->session, &viewport) != 0) { - gw_emit_error(&host->protocol, frame->sequence, "GW_LAUNCH", - strerror(errno), false); - } else { - gw_emit_ack(&host->protocol, frame->sequence, GW_RESIZE, -1); - } - return 0; - } - - case GW_SIGNAL: { - if (host->state != HOST_RUNNING) - break; - GwSignalRequest request; - if (gw_decode_signal(frame->payload, frame->payload_length, &request) != - 0) { - gw_emit_error(&host->protocol, frame->sequence, "GW_PROTOCOL", - "bad signal", false); - } else if (gw_session_signal(&host->session, &request) != 0) { - gw_emit_error(&host->protocol, frame->sequence, "GW_LAUNCH", - strerror(errno), false); - } else { - gw_emit_ack(&host->protocol, frame->sequence, GW_SIGNAL, -1); - } - gw_signal_request_free(&request); - return 0; - } - - case GW_CLOSE: - if (host->state == HOST_INITIAL) - break; - gw_session_cleanup(&host->session, &host->protocol); - host->state = HOST_CLOSED; - gw_emit_ack(&host->protocol, frame->sequence, GW_CLOSE, -1); - return 1; - - default: - break; - } - - gw_emit_error(&host->protocol, frame->sequence, "GW_PROTOCOL", - "command invalid in current state", false); - return 0; -} - -static int read_control(Host *host) { - uint8_t buffer[GW_RAW_LIMIT]; - ssize_t length = read(STDIN_FILENO, buffer, sizeof(buffer)); - if (length <= 0) { - if (length < 0 && errno == EINTR) - return 0; - gw_session_cleanup(&host->session, &host->protocol); - return 1; - } - if (gw_buffer_append(&host->input, buffer, (size_t)length) != 0) - return -1; - - for (;;) { - GwFrame frame; - int decoded = gw_protocol_next_frame(&host->protocol, &host->input, &frame); - if (decoded == 0) - return 0; - if (decoded < 0) { - gw_emit_error(&host->protocol, 0, "GW_PROTOCOL", "invalid frame", true); - gw_session_cleanup(&host->session, &host->protocol); - return -1; - } - size_t consumed = GW_HEADER_SIZE + frame.payload_length; - int command = handle_command(host, &frame); - gw_buffer_consume(&host->input, consumed); - if (command != 0) - return command; - } -} - -int main(void) { - signal(SIGPIPE, SIG_IGN); - Host host = {.state = HOST_INITIAL}; - gw_protocol_init(&host.protocol); - gw_session_init(&host.session); - - for (;;) { - struct pollfd descriptors[2] = { - {.fd = STDIN_FILENO, .events = POLLIN}, - {.fd = gw_session_poll_fd(&host.session), .events = POLLIN}, - }; - nfds_t count = descriptors[1].fd >= 0 ? 2 : 1; - int result = poll(descriptors, count, 25); - if (result < 0 && errno != EINTR) { - perror("ghostwright pty-host poll"); - gw_session_cleanup(&host.session, &host.protocol); - gw_buffer_free(&host.input); - return 2; - } - - if (descriptors[0].revents & (POLLIN | POLLHUP)) { - int control = read_control(&host); - if (control != 0) { - gw_buffer_free(&host.input); - return control < 0 ? 2 : 0; - } - } - if (count == 2 && descriptors[1].revents & (POLLIN | POLLHUP)) { - if (gw_session_read_pty(&host.session, &host.protocol) != 0) { - gw_session_cleanup(&host.session, &host.protocol); - gw_buffer_free(&host.input); - return 2; - } - } - - gw_session_tick(&host.session, &host.protocol); - if (host.session.child_exited && host.state == HOST_RUNNING) - host.state = HOST_DRAINING; - if (host.session.pty_eof && host.session.child_exited) - host.state = HOST_CLOSED; - } -} diff --git a/experiments/ghostwright/native/pty-host-c/protocol.c b/experiments/ghostwright/native/pty-host-c/protocol.c deleted file mode 100644 index 4be46ae..0000000 --- a/experiments/ghostwright/native/pty-host-c/protocol.c +++ /dev/null @@ -1,572 +0,0 @@ -#include "protocol.h" - -#include -#include -#include -#include -#include -#include -#include - -__attribute__((used)) const char ghostwright_protocol_marker[] = - "GWPT_PROTOCOL_VERSION=1"; - -typedef struct { - const uint8_t *data; - size_t length; - size_t offset; -} CborCursor; - -static uint16_t read_u16_le(const uint8_t *data) { - return (uint16_t)data[0] | ((uint16_t)data[1] << 8); -} - -static uint32_t read_u32_le(const uint8_t *data) { - return (uint32_t)data[0] | ((uint32_t)data[1] << 8) | - ((uint32_t)data[2] << 16) | ((uint32_t)data[3] << 24); -} - -static void write_u16_le(uint8_t *data, uint16_t value) { - data[0] = (uint8_t)value; - data[1] = (uint8_t)(value >> 8); -} - -static void write_u32_le(uint8_t *data, uint32_t value) { - data[0] = (uint8_t)value; - data[1] = (uint8_t)(value >> 8); - data[2] = (uint8_t)(value >> 16); - data[3] = (uint8_t)(value >> 24); -} - -static int write_all(int fd, const void *data, size_t length) { - const uint8_t *cursor = data; - while (length > 0) { - ssize_t written = write(fd, cursor, length); - if (written < 0 && errno == EINTR) - continue; - if (written <= 0) - return -1; - cursor += written; - length -= (size_t)written; - } - return 0; -} - -void gw_protocol_init(GwProtocol *protocol) { - protocol->output_sequence = 1; - protocol->input_sequence = 0; -} - -void gw_buffer_free(GwBuffer *buffer) { - free(buffer->data); - *buffer = (GwBuffer){0}; -} - -int gw_buffer_append(GwBuffer *buffer, const void *data, size_t length) { - if (length > SIZE_MAX - buffer->length) - return -1; - size_t needed = buffer->length + length; - if (needed > buffer->capacity) { - size_t capacity = needed * 2 + 64; - uint8_t *next = realloc(buffer->data, capacity); - if (next == NULL) - return -1; - buffer->data = next; - buffer->capacity = capacity; - } - memcpy(buffer->data + buffer->length, data, length); - buffer->length = needed; - return 0; -} - -void gw_buffer_consume(GwBuffer *buffer, size_t length) { - if (length >= buffer->length) { - buffer->length = 0; - return; - } - memmove(buffer->data, buffer->data + length, buffer->length - length); - buffer->length -= length; -} - -int gw_protocol_next_frame(GwProtocol *protocol, GwBuffer *buffer, - GwFrame *frame) { - if (buffer->length < GW_HEADER_SIZE) - return 0; - const uint8_t *header = buffer->data; - if (memcmp(header, "GWPT", 4) != 0 || - read_u16_le(header + 4) != GW_PROTOCOL_VERSION || - read_u32_le(header + 12) != 0) - return -1; - - uint16_t kind = read_u16_le(header + 6); - uint32_t sequence = read_u32_le(header + 8); - uint32_t payload_length = read_u32_le(header + 16); - uint32_t limit = kind == GW_WRITE ? GW_RAW_LIMIT : GW_CONTROL_LIMIT; - if (payload_length > limit) - return -1; - if (buffer->length < GW_HEADER_SIZE + payload_length) - return 0; - if (sequence == 0 || sequence <= protocol->input_sequence) - return -1; - protocol->input_sequence = sequence; - - *frame = (GwFrame){ - .kind = kind, - .sequence = sequence, - .correlation = 0, - .payload = header + GW_HEADER_SIZE, - .payload_length = payload_length, - }; - return 1; -} - -static int emit_frame(GwProtocol *protocol, uint16_t kind, uint32_t correlation, - const void *payload, uint32_t payload_length) { - uint8_t header[GW_HEADER_SIZE] = {'G', 'W', 'P', 'T'}; - write_u16_le(header + 4, GW_PROTOCOL_VERSION); - write_u16_le(header + 6, kind); - write_u32_le(header + 8, protocol->output_sequence++); - write_u32_le(header + 12, correlation); - write_u32_le(header + 16, payload_length); - if (write_all(STDOUT_FILENO, header, sizeof(header)) != 0) - return -1; - if (payload_length > 0 && - write_all(STDOUT_FILENO, payload, payload_length) != 0) - return -1; - return 0; -} - -static int cbor_head(GwBuffer *buffer, unsigned major, uint64_t value) { - uint8_t bytes[5]; - size_t length; - if (value < 24) { - bytes[0] = (uint8_t)((major << 5) | value); - length = 1; - } else if (value <= UINT8_MAX) { - bytes[0] = (uint8_t)((major << 5) | 24); - bytes[1] = (uint8_t)value; - length = 2; - } else if (value <= UINT16_MAX) { - bytes[0] = (uint8_t)((major << 5) | 25); - bytes[1] = (uint8_t)(value >> 8); - bytes[2] = (uint8_t)value; - length = 3; - } else { - bytes[0] = (uint8_t)((major << 5) | 26); - bytes[1] = (uint8_t)(value >> 24); - bytes[2] = (uint8_t)(value >> 16); - bytes[3] = (uint8_t)(value >> 8); - bytes[4] = (uint8_t)value; - length = 5; - } - return gw_buffer_append(buffer, bytes, length); -} - -static int cbor_text(GwBuffer *buffer, const char *value) { - size_t length = strlen(value); - return cbor_head(buffer, 3, length) || - gw_buffer_append(buffer, value, length); -} - -static int cbor_uint(GwBuffer *buffer, uint64_t value) { - return cbor_head(buffer, 0, value); -} - -static int cbor_null(GwBuffer *buffer) { - uint8_t value = 0xf6; - return gw_buffer_append(buffer, &value, 1); -} - -static int cbor_bool(GwBuffer *buffer, bool value) { - uint8_t encoded = value ? 0xf5 : 0xf4; - return gw_buffer_append(buffer, &encoded, 1); -} - -int gw_emit_ready(GwProtocol *protocol, uint32_t correlation) { - GwBuffer payload = {0}; - int failed = cbor_head(&payload, 5, 3) || cbor_text(&payload, "version") || - cbor_uint(&payload, 1) || cbor_text(&payload, "platform") || - cbor_text(&payload, "posix") || - cbor_text(&payload, "hostVersion") || - cbor_text(&payload, "0.1.0"); - int result = failed ? -1 - : emit_frame(protocol, GW_READY, correlation, - payload.data, payload.length); - gw_buffer_free(&payload); - return result; -} - -int gw_emit_spawned(GwProtocol *protocol, uint32_t correlation, pid_t pid, - pid_t pgid) { - GwBuffer payload = {0}; - int failed = - cbor_head(&payload, 5, 4) || cbor_text(&payload, "pid") || - cbor_uint(&payload, (uint64_t)pid) || cbor_text(&payload, "ttyName") || - cbor_text(&payload, "pty") || cbor_text(&payload, "execPending") || - cbor_bool(&payload, true) || cbor_text(&payload, "processGroupId") || - cbor_uint(&payload, (uint64_t)pgid); - int result = failed ? -1 - : emit_frame(protocol, GW_SPAWNED, correlation, - payload.data, payload.length); - gw_buffer_free(&payload); - return result; -} - -int gw_emit_ack(GwProtocol *protocol, uint32_t correlation, uint16_t kind, - ssize_t bytes_written) { - GwBuffer payload = {0}; - int failed = cbor_head(&payload, 5, bytes_written < 0 ? 1 : 2) || - cbor_text(&payload, "kind") || cbor_uint(&payload, kind); - if (!failed && bytes_written >= 0) - failed = cbor_text(&payload, "bytesWritten") || - cbor_uint(&payload, bytes_written); - int result = failed ? -1 - : emit_frame(protocol, GW_ACK, correlation, payload.data, - payload.length); - gw_buffer_free(&payload); - return result; -} - -int gw_emit_error(GwProtocol *protocol, uint32_t correlation, const char *code, - const char *message, bool fatal) { - GwBuffer payload = {0}; - int failed = cbor_head(&payload, 5, 3) || cbor_text(&payload, "code") || - cbor_text(&payload, code) || cbor_text(&payload, "fatal") || - cbor_bool(&payload, fatal) || cbor_text(&payload, "message") || - cbor_text(&payload, message); - int result = failed ? -1 - : emit_frame(protocol, GW_ERROR, correlation, - payload.data, payload.length); - gw_buffer_free(&payload); - return result; -} - -int gw_emit_output(GwProtocol *protocol, const uint8_t *data, size_t length) { - if (length > GW_RAW_LIMIT) - return -1; - return emit_frame(protocol, GW_OUTPUT, 0, data, (uint32_t)length); -} - -int gw_emit_process_exit(GwProtocol *protocol, int wait_status) { - GwBuffer payload = {0}; - int failed = cbor_head(&payload, 5, 2) || cbor_text(&payload, "signal"); - if (!failed) { - if (WIFSIGNALED(wait_status)) { - char signal[24]; - snprintf(signal, sizeof(signal), "SIG%d", WTERMSIG(wait_status)); - failed = cbor_text(&payload, signal); - } else { - failed = cbor_null(&payload); - } - } - if (!failed) - failed = cbor_text(&payload, "exitCode"); - if (!failed) { - failed = WIFEXITED(wait_status) - ? cbor_uint(&payload, WEXITSTATUS(wait_status)) - : cbor_null(&payload); - } - int result = failed ? -1 - : emit_frame(protocol, GW_PROCESS_EXIT, 0, payload.data, - payload.length); - gw_buffer_free(&payload); - return result; -} - -int gw_emit_pty_eof(GwProtocol *protocol) { - return emit_frame(protocol, GW_PTY_EOF, 0, NULL, 0); -} - -static int cbor_length(CborCursor *cursor, unsigned *major, uint64_t *value) { - if (cursor->offset >= cursor->length) - return -1; - uint8_t head = cursor->data[cursor->offset++]; - *major = head >> 5; - unsigned additional = head & 31; - if (additional < 24) { - *value = additional; - return 0; - } - unsigned bytes = additional == 24 ? 1 - : additional == 25 ? 2 - : additional == 26 ? 4 - : 0; - if (bytes == 0 || cursor->offset + bytes > cursor->length) - return -1; - *value = 0; - while (bytes-- > 0) - *value = (*value << 8) | cursor->data[cursor->offset++]; - return 0; -} - -static int cbor_skip(CborCursor *cursor) { - unsigned major; - uint64_t length; - if (cbor_length(cursor, &major, &length) != 0) - return -1; - if (major <= 1 || major == 7) - return 0; - if (major == 2 || major == 3) { - if (length > cursor->length - cursor->offset) - return -1; - cursor->offset += (size_t)length; - return 0; - } - if (major == 4) { - while (length-- > 0) - if (cbor_skip(cursor) != 0) - return -1; - return 0; - } - if (major == 5) { - while (length-- > 0) - if (cbor_skip(cursor) != 0 || cbor_skip(cursor) != 0) - return -1; - return 0; - } - return -1; -} - -static int cbor_text_value(CborCursor *cursor, char **output) { - unsigned major; - uint64_t length; - if (cbor_length(cursor, &major, &length) != 0 || major != 3 || - length > cursor->length - cursor->offset) - return -1; - char *value = malloc((size_t)length + 1); - if (value == NULL) - return -1; - memcpy(value, cursor->data + cursor->offset, (size_t)length); - value[length] = '\0'; - cursor->offset += (size_t)length; - *output = value; - return 0; -} - -static int cbor_uint_value(CborCursor *cursor, uint64_t *output) { - unsigned major; - return cbor_length(cursor, &major, output) == 0 && major == 0 ? 0 : -1; -} - -static int decode_viewport_cursor(CborCursor *cursor, GwViewport *viewport) { - unsigned major; - uint64_t entries; - if (cbor_length(cursor, &major, &entries) != 0 || major != 5) - return -1; - while (entries-- > 0) { - char *key = NULL; - uint64_t value; - if (cbor_text_value(cursor, &key) != 0 || - cbor_uint_value(cursor, &value) != 0 || value > UINT16_MAX) { - free(key); - return -1; - } - if (strcmp(key, "columns") == 0) - viewport->columns = (uint16_t)value; - else if (strcmp(key, "rows") == 0) - viewport->rows = (uint16_t)value; - else if (strcmp(key, "widthPixels") == 0) - viewport->width_pixels = (uint16_t)value; - else if (strcmp(key, "heightPixels") == 0) - viewport->height_pixels = (uint16_t)value; - free(key); - } - return viewport->columns && viewport->rows && viewport->width_pixels && - viewport->height_pixels - ? 0 - : -1; -} - -static int decode_cleanup(CborCursor *cursor, GwCleanupOptions *cleanup) { - unsigned major; - uint64_t entries; - if (cbor_length(cursor, &major, &entries) != 0 || major != 5) - return -1; - while (entries-- > 0) { - char *key = NULL; - uint64_t value; - if (cbor_text_value(cursor, &key) != 0 || - cbor_uint_value(cursor, &value) != 0 || value > UINT_MAX) { - free(key); - return -1; - } - if (strcmp(key, "hangupGraceMs") == 0) - cleanup->hangup_grace_ms = (unsigned)value; - else if (strcmp(key, "terminateGraceMs") == 0) - cleanup->terminate_grace_ms = (unsigned)value; - else if (strcmp(key, "postExitDrainMs") == 0) - cleanup->post_exit_drain_ms = (unsigned)value; - free(key); - } - return 0; -} - -int gw_decode_spawn(const uint8_t *payload, size_t length, - GwSpawnRequest *request) { - *request = (GwSpawnRequest){ - .cleanup = {.hangup_grace_ms = 500, - .terminate_grace_ms = 500, - .post_exit_drain_ms = 1000}, - }; - CborCursor cursor = {.data = payload, .length = length}; - unsigned major; - uint64_t entries; - if (cbor_length(&cursor, &major, &entries) != 0 || major != 5) - return -1; - - while (entries-- > 0) { - char *key = NULL; - if (cbor_text_value(&cursor, &key) != 0) - goto fail; - if (strcmp(key, "command") == 0) { - if (cbor_text_value(&cursor, &request->command) != 0) - goto key_fail; - } else if (strcmp(key, "cwd") == 0) { - if (cursor.offset < cursor.length && cursor.data[cursor.offset] == 0xf6) - cursor.offset++; - else if (cbor_text_value(&cursor, &request->cwd) != 0) - goto key_fail; - } else if (strcmp(key, "args") == 0) { - uint64_t count; - if (cbor_length(&cursor, &major, &count) != 0 || major != 4 || - count > SIZE_MAX - 2) - goto key_fail; - request->args = calloc((size_t)count + 2, sizeof(char *)); - if (request->args == NULL) - goto key_fail; - request->args_length = (size_t)count; - for (size_t index = 0; index < request->args_length; index++) - if (cbor_text_value(&cursor, &request->args[index + 1]) != 0) - goto key_fail; - } else if (strcmp(key, "env") == 0) { - uint64_t count; - if (cbor_length(&cursor, &major, &count) != 0 || major != 5 || - count > SIZE_MAX - 1) - goto key_fail; - request->environment = calloc((size_t)count + 1, sizeof(char *)); - if (request->environment == NULL) - goto key_fail; - request->environment_length = (size_t)count; - for (size_t index = 0; index < request->environment_length; index++) { - char *name = NULL; - char *value = NULL; - if (cbor_text_value(&cursor, &name) != 0 || - cbor_text_value(&cursor, &value) != 0) { - free(name); - free(value); - goto key_fail; - } - size_t pair_length = strlen(name) + strlen(value) + 2; - request->environment[index] = malloc(pair_length); - if (request->environment[index] == NULL) { - free(name); - free(value); - goto key_fail; - } - snprintf(request->environment[index], pair_length, "%s=%s", name, - value); - free(name); - free(value); - } - } else if (strcmp(key, "viewport") == 0) { - if (decode_viewport_cursor(&cursor, &request->viewport) != 0) - goto key_fail; - } else if (strcmp(key, "cleanup") == 0) { - if (decode_cleanup(&cursor, &request->cleanup) != 0) - goto key_fail; - } else if (cbor_skip(&cursor) != 0) { - goto key_fail; - } - free(key); - continue; - - key_fail: - free(key); - goto fail; - } - - if (request->command == NULL || request->command[0] == '\0' || - request->viewport.columns == 0 || request->viewport.rows == 0) - goto fail; - if (request->args == NULL) { - request->args = calloc(2, sizeof(char *)); - if (request->args == NULL) - goto fail; - } - request->args[0] = request->command; - return cursor.offset == cursor.length ? 0 : -1; - -fail: - gw_spawn_request_free(request); - return -1; -} - -void gw_spawn_request_free(GwSpawnRequest *request) { - if (request->args != NULL) { - for (size_t index = 0; index < request->args_length; index++) - free(request->args[index + 1]); - free(request->args); - } - if (request->environment != NULL) { - for (size_t index = 0; index < request->environment_length; index++) - free(request->environment[index]); - free(request->environment); - } - free(request->command); - free(request->cwd); - *request = (GwSpawnRequest){0}; -} - -int gw_decode_viewport(const uint8_t *payload, size_t length, - GwViewport *viewport) { - *viewport = (GwViewport){0}; - CborCursor cursor = {.data = payload, .length = length}; - return decode_viewport_cursor(&cursor, viewport) == 0 && - cursor.offset == cursor.length - ? 0 - : -1; -} - -int gw_decode_signal(const uint8_t *payload, size_t length, - GwSignalRequest *request) { - *request = (GwSignalRequest){0}; - CborCursor cursor = {.data = payload, .length = length}; - unsigned major; - uint64_t entries; - if (cbor_length(&cursor, &major, &entries) != 0 || major != 5) - return -1; - while (entries-- > 0) { - char *key = NULL; - if (cbor_text_value(&cursor, &key) != 0) - goto fail; - if (strcmp(key, "signal") == 0) { - if (cbor_text_value(&cursor, &request->signal) != 0) { - free(key); - goto fail; - } - } else if (strcmp(key, "target") == 0) { - if (cbor_text_value(&cursor, &request->target) != 0) { - free(key); - goto fail; - } - } else if (cbor_skip(&cursor) != 0) { - free(key); - goto fail; - } - free(key); - } - if (request->signal == NULL || request->target == NULL || - cursor.offset != cursor.length) - goto fail; - return 0; - -fail: - gw_signal_request_free(request); - return -1; -} - -void gw_signal_request_free(GwSignalRequest *request) { - free(request->signal); - free(request->target); - *request = (GwSignalRequest){0}; -} diff --git a/experiments/ghostwright/native/pty-host-c/protocol.h b/experiments/ghostwright/native/pty-host-c/protocol.h deleted file mode 100644 index 227befd..0000000 --- a/experiments/ghostwright/native/pty-host-c/protocol.h +++ /dev/null @@ -1,107 +0,0 @@ -#ifndef GHOSTWRIGHT_PROTOCOL_H -#define GHOSTWRIGHT_PROTOCOL_H - -#include -#include -#include -#include - -#define GW_PROTOCOL_VERSION 1 -#define GW_HEADER_SIZE 20 -#define GW_CONTROL_LIMIT (1024U * 1024U) -#define GW_RAW_LIMIT 65536U - -extern const char ghostwright_protocol_marker[]; - -typedef enum { - GW_HELLO = 0x0001, - GW_SPAWN = 0x0002, - GW_WRITE = 0x0003, - GW_RESIZE = 0x0004, - GW_SIGNAL = 0x0005, - GW_CLOSE = 0x0006, - GW_READY = 0x8001, - GW_SPAWNED = 0x8002, - GW_ACK = 0x8003, - GW_ERROR = 0x80ff, - GW_OUTPUT = 0x8100, - GW_PROCESS_EXIT = 0x8101, - GW_PTY_EOF = 0x8102, -} GwFrameKind; - -typedef struct { - uint8_t *data; - size_t length; - size_t capacity; -} GwBuffer; - -typedef struct { - uint16_t kind; - uint32_t sequence; - uint32_t correlation; - const uint8_t *payload; - uint32_t payload_length; -} GwFrame; - -typedef struct { - uint32_t output_sequence; - uint32_t input_sequence; -} GwProtocol; - -typedef struct { - uint16_t columns; - uint16_t rows; - uint16_t width_pixels; - uint16_t height_pixels; -} GwViewport; - -typedef struct { - unsigned hangup_grace_ms; - unsigned terminate_grace_ms; - unsigned post_exit_drain_ms; -} GwCleanupOptions; - -typedef struct { - char *command; - char **args; - size_t args_length; - char **environment; - size_t environment_length; - char *cwd; - GwViewport viewport; - GwCleanupOptions cleanup; -} GwSpawnRequest; - -typedef struct { - char *signal; - char *target; -} GwSignalRequest; - -void gw_protocol_init(GwProtocol *protocol); -void gw_buffer_free(GwBuffer *buffer); -int gw_buffer_append(GwBuffer *buffer, const void *data, size_t length); -void gw_buffer_consume(GwBuffer *buffer, size_t length); -int gw_protocol_next_frame(GwProtocol *protocol, GwBuffer *buffer, - GwFrame *frame); - -int gw_decode_spawn(const uint8_t *payload, size_t length, - GwSpawnRequest *request); -void gw_spawn_request_free(GwSpawnRequest *request); -int gw_decode_viewport(const uint8_t *payload, size_t length, - GwViewport *viewport); -int gw_decode_signal(const uint8_t *payload, size_t length, - GwSignalRequest *request); -void gw_signal_request_free(GwSignalRequest *request); - -int gw_emit_ready(GwProtocol *protocol, uint32_t correlation); -int gw_emit_spawned(GwProtocol *protocol, uint32_t correlation, pid_t pid, - pid_t pgid); -int gw_emit_ack(GwProtocol *protocol, uint32_t correlation, uint16_t kind, - ssize_t bytes_written); -int gw_emit_error(GwProtocol *protocol, uint32_t correlation, const char *code, - const char *message, bool fatal); -int gw_emit_output(GwProtocol *protocol, const uint8_t *data, size_t length); -int gw_emit_process_exit(GwProtocol *protocol, int wait_status); -int gw_emit_pty_eof(GwProtocol *protocol); - -#endif diff --git a/experiments/ghostwright/native/pty-host-c/session.c b/experiments/ghostwright/native/pty-host-c/session.c deleted file mode 100644 index 0c249fd..0000000 --- a/experiments/ghostwright/native/pty-host-c/session.c +++ /dev/null @@ -1,293 +0,0 @@ -#define _GNU_SOURCE -#include "session.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#if defined(__APPLE__) -#include -#else -#include -#endif - -extern char **environ; - -static uint64_t monotonic_ms(void) { - struct timespec value; - if (clock_gettime(CLOCK_MONOTONIC, &value) != 0) - return 0; - return (uint64_t)value.tv_sec * 1000 + (uint64_t)value.tv_nsec / 1000000; -} - -static int process_group_alive(const GwSession *session) { - return session->process_group > 1 && kill(-session->process_group, 0) == 0; -} - -static int emit_exit_once(GwSession *session, GwProtocol *protocol, - int wait_status) { - if (session->child_exited) - return 0; - session->child_exited = true; - session->wait_status = wait_status; - session->exited_at_ms = monotonic_ms(); - return gw_emit_process_exit(protocol, wait_status); -} - -static int reap_nonblocking(GwSession *session, GwProtocol *protocol) { - if (session->child_pid <= 0 || session->child_exited) - return session->child_exited; - int wait_status; - pid_t result = waitpid(session->child_pid, &wait_status, WNOHANG); - if (result == session->child_pid) { - emit_exit_once(session, protocol, wait_status); - return 1; - } - return 0; -} - -static void wait_for_group(GwSession *session, GwProtocol *protocol, - unsigned milliseconds) { - for (unsigned elapsed = 0; elapsed < milliseconds; elapsed += 10) { - reap_nonblocking(session, protocol); - if (!process_group_alive(session)) - return; - usleep(10000); - } -} - -static void terminate_group(GwSession *session, GwProtocol *protocol) { - if (session->process_group <= 1 || !process_group_alive(session)) - return; - if (kill(-session->process_group, SIGHUP) == 0) { - wait_for_group(session, protocol, session->cleanup.hangup_grace_ms); - if (process_group_alive(session)) { - kill(-session->process_group, SIGTERM); - wait_for_group(session, protocol, session->cleanup.terminate_grace_ms); - } - if (process_group_alive(session)) - kill(-session->process_group, SIGKILL); - } -} - -static void close_master(GwSession *session, GwProtocol *protocol) { - if (session->master_fd >= 0) { - close(session->master_fd); - session->master_fd = -1; - } - if (!session->pty_eof) { - gw_emit_pty_eof(protocol); - session->pty_eof = true; - } -} - -void gw_session_init(GwSession *session) { - *session = (GwSession){ - .master_fd = -1, - .child_pid = -1, - .process_group = -1, - .cleanup = - { - .hangup_grace_ms = 500, - .terminate_grace_ms = 500, - .post_exit_drain_ms = 1000, - }, - }; -} - -int gw_session_spawn(GwSession *session, GwProtocol *protocol, - uint32_t correlation, const GwSpawnRequest *request) { - struct winsize window = { - .ws_row = request->viewport.rows, - .ws_col = request->viewport.columns, - .ws_xpixel = request->viewport.width_pixels, - .ws_ypixel = request->viewport.height_pixels, - }; - int slave = -1; - int barrier[2] = {-1, -1}; - int exec_error[2] = {-1, -1}; - - if (openpty(&session->master_fd, &slave, NULL, NULL, &window) != 0 || - pipe(barrier) != 0 || pipe(exec_error) != 0) { - gw_emit_error(protocol, correlation, "GW_LAUNCH", strerror(errno), true); - if (slave >= 0) - close(slave); - return -1; - } - fcntl(exec_error[1], F_SETFD, FD_CLOEXEC); - - struct termios attributes; - if (tcgetattr(slave, &attributes) == 0) { -#ifdef IUTF8 - attributes.c_iflag |= IUTF8; -#endif - tcsetattr(slave, TCSANOW, &attributes); - } - - pid_t child = fork(); - if (child < 0) { - gw_emit_error(protocol, correlation, "GW_LAUNCH", strerror(errno), true); - close(slave); - return -1; - } - - if (child == 0) { - close(session->master_fd); - close(barrier[1]); - close(exec_error[0]); - - if (setsid() < 0 || ioctl(slave, TIOCSCTTY, 0) < 0 || - tcsetpgrp(slave, getpid()) < 0 || dup2(slave, STDIN_FILENO) < 0 || - dup2(slave, STDOUT_FILENO) < 0 || dup2(slave, STDERR_FILENO) < 0) - _exit(126); - if (slave > STDERR_FILENO) - close(slave); - - char release; - if (read(barrier[0], &release, 1) != 1) - _exit(126); - close(barrier[0]); - - if (request->cwd != NULL && chdir(request->cwd) != 0) { - int child_errno = errno; - write(exec_error[1], &child_errno, sizeof(child_errno)); - _exit(126); - } - if (request->environment != NULL) - environ = request->environment; - execvp(request->command, request->args); - - int child_errno = errno; - write(exec_error[1], &child_errno, sizeof(child_errno)); - _exit(127); - } - - close(slave); - close(barrier[0]); - close(exec_error[1]); - session->child_pid = child; - session->process_group = child; - session->cleanup = request->cleanup; - - if (gw_emit_spawned(protocol, correlation, child, child) != 0) - return -1; - if (write(barrier[1], "x", 1) != 1) - return -1; - close(barrier[1]); - - int child_errno = 0; - ssize_t exec_result; - do { - exec_result = read(exec_error[0], &child_errno, sizeof(child_errno)); - } while (exec_result < 0 && errno == EINTR); - close(exec_error[0]); - - if (exec_result > 0) { - gw_emit_error(protocol, correlation, "GW_LAUNCH", strerror(child_errno), - true); - gw_session_cleanup(session, protocol); - return -1; - } - return gw_emit_ack(protocol, correlation, GW_SPAWN, -1); -} - -ssize_t gw_session_write(GwSession *session, const uint8_t *data, - size_t length) { - size_t offset = 0; - while (offset < length) { - ssize_t written = write(session->master_fd, data + offset, length - offset); - if (written < 0 && errno == EINTR) - continue; - if (written <= 0) - return offset > 0 ? (ssize_t)offset : -1; - offset += (size_t)written; - } - return (ssize_t)offset; -} - -int gw_session_resize(GwSession *session, const GwViewport *viewport) { - struct winsize window = { - .ws_row = viewport->rows, - .ws_col = viewport->columns, - .ws_xpixel = viewport->width_pixels, - .ws_ypixel = viewport->height_pixels, - }; - return ioctl(session->master_fd, TIOCSWINSZ, &window); -} - -static int signal_number(const char *name) { - if (strcmp(name, "SIGINT") == 0 || strcmp(name, "INT") == 0) - return SIGINT; - if (strcmp(name, "SIGTERM") == 0 || strcmp(name, "TERM") == 0) - return SIGTERM; - if (strcmp(name, "SIGHUP") == 0 || strcmp(name, "HUP") == 0) - return SIGHUP; - if (strcmp(name, "SIGKILL") == 0 || strcmp(name, "KILL") == 0) - return SIGKILL; - if (strcmp(name, "SIGUSR1") == 0 || strcmp(name, "USR1") == 0) - return SIGUSR1; - if (strcmp(name, "SIGUSR2") == 0 || strcmp(name, "USR2") == 0) - return SIGUSR2; - return 0; -} - -int gw_session_signal(GwSession *session, const GwSignalRequest *request) { - int signal = signal_number(request->signal); - if (signal == 0) { - errno = EINVAL; - return -1; - } - pid_t target = strcmp(request->target, "child") == 0 - ? session->child_pid - : -session->process_group; - return kill(target, signal); -} - -int gw_session_read_pty(GwSession *session, GwProtocol *protocol) { - uint8_t buffer[GW_RAW_LIMIT]; - ssize_t length = read(session->master_fd, buffer, sizeof(buffer)); - if (length > 0) - return gw_emit_output(protocol, buffer, (size_t)length); - if (length == 0 || (length < 0 && (errno == EIO || errno == EBADF))) { - close_master(session, protocol); - return 0; - } - return errno == EINTR ? 0 : -1; -} - -int gw_session_tick(GwSession *session, GwProtocol *protocol) { - reap_nonblocking(session, protocol); - if (session->child_exited && !session->pty_eof && session->master_fd >= 0 && - monotonic_ms() - session->exited_at_ms >= - session->cleanup.post_exit_drain_ms) { - terminate_group(session, protocol); - close_master(session, protocol); - } - return 0; -} - -int gw_session_cleanup(GwSession *session, GwProtocol *protocol) { - terminate_group(session, protocol); - if (session->master_fd >= 0) { - close(session->master_fd); - session->master_fd = -1; - } - if (session->child_pid > 0 && !session->child_exited) { - int wait_status; - pid_t result; - do { - result = waitpid(session->child_pid, &wait_status, 0); - } while (result < 0 && errno == EINTR); - if (result == session->child_pid) - emit_exit_once(session, protocol, wait_status); - } - return 0; -} - -int gw_session_poll_fd(const GwSession *session) { return session->master_fd; } diff --git a/experiments/ghostwright/native/pty-host-c/session.h b/experiments/ghostwright/native/pty-host-c/session.h deleted file mode 100644 index ce711e8..0000000 --- a/experiments/ghostwright/native/pty-host-c/session.h +++ /dev/null @@ -1,33 +0,0 @@ -#ifndef GHOSTWRIGHT_SESSION_H -#define GHOSTWRIGHT_SESSION_H - -#include "protocol.h" - -#include -#include -#include - -typedef struct { - int master_fd; - pid_t child_pid; - pid_t process_group; - bool child_exited; - bool pty_eof; - int wait_status; - uint64_t exited_at_ms; - GwCleanupOptions cleanup; -} GwSession; - -void gw_session_init(GwSession *session); -int gw_session_spawn(GwSession *session, GwProtocol *protocol, - uint32_t correlation, const GwSpawnRequest *request); -ssize_t gw_session_write(GwSession *session, const uint8_t *data, - size_t length); -int gw_session_resize(GwSession *session, const GwViewport *viewport); -int gw_session_signal(GwSession *session, const GwSignalRequest *request); -int gw_session_read_pty(GwSession *session, GwProtocol *protocol); -int gw_session_tick(GwSession *session, GwProtocol *protocol); -int gw_session_cleanup(GwSession *session, GwProtocol *protocol); -int gw_session_poll_fd(const GwSession *session); - -#endif diff --git a/experiments/ghostwright/native/pty-host-rust/src/contract_tests.rs b/experiments/ghostwright/native/pty-host-rust/src/contract_tests.rs new file mode 100644 index 0000000..17f292c --- /dev/null +++ b/experiments/ghostwright/native/pty-host-rust/src/contract_tests.rs @@ -0,0 +1,73 @@ +use crate::protocol::{decode_signal, decode_spawn, Protocol, HEADER_SIZE}; + +fn frame(payload: &[u8]) -> Vec { + let mut frame = vec![0; HEADER_SIZE]; + frame[..4].copy_from_slice(b"GWPT"); + frame[4..6].copy_from_slice(&1u16.to_le_bytes()); + frame[6..8].copy_from_slice(&3u16.to_le_bytes()); + frame[8..12].copy_from_slice(&1u32.to_le_bytes()); + frame[16..20].copy_from_slice(&(payload.len() as u32).to_le_bytes()); + frame.extend_from_slice(payload); + frame +} + +#[test] +fn framing_accepts_every_split_and_rejects_repeated_sequence() { + let bytes = frame(b"hello"); + for split in 1..bytes.len() { + let mut protocol = Protocol::new(); + protocol.append(&bytes[..split]); + assert!(protocol.next_frame().unwrap().is_none()); + protocol.append(&bytes[split..]); + assert_eq!(protocol.next_frame().unwrap().unwrap().payload, b"hello"); + protocol.append(&bytes); + assert!(protocol.next_frame().is_err()); + } +} + +#[test] +fn oversized_write_fails_from_header_alone() { + let mut bytes = frame(b""); + bytes[16..20].copy_from_slice(&65537u32.to_le_bytes()); + let mut protocol = Protocol::new(); + protocol.append(&bytes); + assert!(protocol.next_frame().is_err()); +} + +#[test] +fn invalid_signal_target_and_trailing_data_fail() { + for target in ["typo", ""] { + let mut e = minicbor::Encoder::new(Vec::new()); + e.map(2) + .unwrap() + .str("signal") + .unwrap() + .str("SIGTERM") + .unwrap() + .str("target") + .unwrap() + .str(target) + .unwrap(); + assert!(decode_signal(&e.into_writer()).is_err()); + } + let mut e = minicbor::Encoder::new(Vec::new()); + e.map(2) + .unwrap() + .str("signal") + .unwrap() + .str("SIGTERM") + .unwrap() + .str("target") + .unwrap() + .str("child") + .unwrap() + .null() + .unwrap(); + assert!(decode_signal(&e.into_writer()).is_err()); +} + +#[test] +fn malformed_spawn_is_rejected() { + assert!(decode_spawn(&[]).is_err()); + assert!(decode_spawn(&[0xa0]).is_err()); +} diff --git a/experiments/ghostwright/native/pty-host-rust/src/main.rs b/experiments/ghostwright/native/pty-host-rust/src/main.rs index bd6546d..0757632 100644 --- a/experiments/ghostwright/native/pty-host-rust/src/main.rs +++ b/experiments/ghostwright/native/pty-host-rust/src/main.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +mod contract_tests; mod protocol; mod session; @@ -58,11 +60,8 @@ impl Host { self.state = HostState::Running; } kind::WRITE if self.state == HostState::Running => { - match self.session.write(&frame.payload) { - Ok(written) => { - self.protocol - .ack(frame.sequence, kind::WRITE, Some(written))?; - } + match self.session.queue_write(frame.sequence, frame.payload) { + Ok(()) => {} Err(error) => { self.protocol.error( frame.sequence, @@ -103,6 +102,12 @@ impl Host { } } } + kind::CANCEL_WRITE if self.state != HostState::Initial => { + let sequence = protocol::decode_cancel(&frame.payload)?; + self.session.cancel_write(sequence, &mut self.protocol)?; + self.protocol + .ack(frame.sequence, kind::CANCEL_WRITE, None)?; + } kind::CLOSE if self.state != HostState::Initial => { self.session.cleanup(&mut self.protocol)?; self.state = HostState::Closed; @@ -156,6 +161,33 @@ impl Host { } fn run(&mut self) -> Result<(), Box> { + session::set_nonblocking(nix::libc::STDOUT_FILENO)?; + let outcome = self.event_loop(); + let cleanup = self.session.cleanup(&mut self.protocol); + // Flush final acknowledgements, but never retain the owned process group + // indefinitely because a client stopped reading its control channel. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(1); + while self.protocol.has_output() && std::time::Instant::now() < deadline { + if self.protocol.flush_output().is_err() { + break; + } + if self.protocol.has_output() { + let mut fd = nix::libc::pollfd { + fd: 1, + events: nix::libc::POLLOUT, + revents: 0, + }; + unsafe { + nix::libc::poll(&mut fd, 1, 25); + } + } + } + outcome?; + cleanup?; + Ok(()) + } + + fn event_loop(&mut self) -> Result<(), Box> { unsafe { nix::libc::signal(nix::libc::SIGPIPE, nix::libc::SIG_IGN); } @@ -168,12 +200,28 @@ impl Host { }, nix::libc::pollfd { fd: self.session.poll_fd().unwrap_or(-1), - events: nix::libc::POLLIN, + events: (if self.protocol.can_read_pty() { + nix::libc::POLLIN + } else { + 0 + }) | (if self.session.wants_write() { + nix::libc::POLLOUT + } else { + 0 + }), + revents: 0, + }, + nix::libc::pollfd { + fd: nix::libc::STDOUT_FILENO, + events: if self.protocol.has_output() { + nix::libc::POLLOUT + } else { + 0 + }, revents: 0, }, ]; - let count = if descriptors[1].fd >= 0 { 2 } else { 1 }; - let result = unsafe { nix::libc::poll(descriptors.as_mut_ptr(), count, 25) }; + let result = unsafe { nix::libc::poll(descriptors.as_mut_ptr(), 3, 25) }; if result < 0 { let error = io::Error::last_os_error(); if error.raw_os_error() != Some(nix::libc::EINTR) { @@ -186,11 +234,14 @@ impl Host { { return Ok(()); } - if count == 2 && descriptors[1].revents & (nix::libc::POLLIN | nix::libc::POLLHUP) != 0 + if self.protocol.can_read_pty() + && descriptors[1].revents & (nix::libc::POLLIN | nix::libc::POLLHUP) != 0 { self.session.read_pty(&mut self.protocol)?; } + self.session.flush_writes(&mut self.protocol)?; self.session.tick(&mut self.protocol)?; + self.protocol.flush_output()?; if self.session.child_exited() && self.state == HostState::Running { self.state = HostState::Draining; } diff --git a/experiments/ghostwright/native/pty-host-rust/src/protocol.rs b/experiments/ghostwright/native/pty-host-rust/src/protocol.rs index 14d9fc5..7bea847 100644 --- a/experiments/ghostwright/native/pty-host-rust/src/protocol.rs +++ b/experiments/ghostwright/native/pty-host-rust/src/protocol.rs @@ -1,6 +1,7 @@ use minicbor::{Decoder, Encoder}; +use std::collections::VecDeque; use std::convert::Infallible; -use std::io::{self, Write}; +use std::io; use thiserror::Error; pub const VERSION: u16 = 1; @@ -19,6 +20,7 @@ pub mod kind { pub const RESIZE: u16 = 0x0004; pub const SIGNAL: u16 = 0x0005; pub const CLOSE: u16 = 0x0006; + pub const CANCEL_WRITE: u16 = 0x0007; pub const READY: u16 = 0x8001; pub const SPAWNED: u16 = 0x8002; pub const ACK: u16 = 0x8003; @@ -100,6 +102,7 @@ pub struct Protocol { input_sequence: u32, output_sequence: u32, input: Vec, + output: VecDeque, } impl Protocol { @@ -108,6 +111,7 @@ impl Protocol { input_sequence: 0, output_sequence: 1, input: Vec::new(), + output: VecDeque::new(), } } @@ -164,13 +168,62 @@ impl Protocol { .output_sequence .checked_add(1) .ok_or(ProtocolError::InvalidFrame)?; - let mut stdout = io::stdout().lock(); - stdout.write_all(&header)?; - stdout.write_all(payload)?; - stdout.flush()?; + if self.output.len() + header.len() + payload.len() > 8 * 1024 * 1024 { + return Err(ProtocolError::InvalidPayload( + "host output queue exceeded limit", + )); + } + self.output.extend(header); + self.output.extend(payload); + Ok(()) + } + + pub fn has_output(&self) -> bool { + !self.output.is_empty() + } + pub fn can_read_pty(&self) -> bool { + self.output.len() < 4 * 1024 * 1024 + } + pub fn flush_output(&mut self) -> Result<(), ProtocolError> { + while !self.output.is_empty() { + let bytes = self.output.as_slices().0; + // SAFETY: the queue slice remains valid for this synchronous syscall. + let written = unsafe { + nix::libc::write(nix::libc::STDOUT_FILENO, bytes.as_ptr().cast(), bytes.len()) + }; + if written < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + if error.kind() == io::ErrorKind::WouldBlock { + return Ok(()); + } + return Err(error.into()); + } + if written == 0 { + return Err(io::Error::from(io::ErrorKind::WriteZero).into()); + } + self.output.drain(..written as usize); + } Ok(()) } + pub fn write_failed(&mut self, correlation: u32, written: usize) -> Result<(), ProtocolError> { + let mut encoder = Encoder::new(Vec::new()); + encoder + .map(4)? + .str("code")? + .str("GW_WRITE_INTERRUPTED")? + .str("fatal")? + .bool(false)? + .str("message")? + .str("PTY write interrupted")? + .str("bytesWritten")? + .u64(written as u64)?; + self.emit(kind::ERROR, correlation, &encoder.into_writer()) + } + pub fn ready(&mut self, correlation: u32) -> Result<(), ProtocolError> { let mut encoder = Encoder::new(Vec::new()); encoder @@ -342,6 +395,9 @@ pub fn decode_spawn(bytes: &[u8]) -> Result { _ => decoder.skip()?, } } + if decoder.position() != bytes.len() { + return Err(ProtocolError::InvalidPayload("trailing spawn data")); + } let command = command.ok_or(ProtocolError::InvalidPayload("missing command"))?; if command.is_empty() { return Err(ProtocolError::InvalidPayload("empty command")); @@ -357,7 +413,26 @@ pub fn decode_spawn(bytes: &[u8]) -> Result { } pub fn decode_viewport(bytes: &[u8]) -> Result { - decode_viewport_from(&mut Decoder::new(bytes)) + let mut decoder = Decoder::new(bytes); + let viewport = decode_viewport_from(&mut decoder)?; + if decoder.position() != bytes.len() { + return Err(ProtocolError::InvalidPayload("trailing viewport data")); + } + Ok(viewport) +} + +pub fn decode_cancel(bytes: &[u8]) -> Result { + let mut decoder = Decoder::new(bytes); + if definite_map(&mut decoder)? != 1 || decoder.str()? != "sequence" { + return Err(ProtocolError::InvalidPayload("invalid cancellation")); + } + let sequence = decoder.u32()?; + if sequence == 0 || decoder.position() != bytes.len() { + return Err(ProtocolError::InvalidPayload( + "invalid cancellation sequence", + )); + } + Ok(sequence) } pub fn decode_signal(bytes: &[u8]) -> Result { @@ -371,8 +446,15 @@ pub fn decode_signal(bytes: &[u8]) -> Result { _ => decoder.skip()?, } } + if decoder.position() != bytes.len() { + return Err(ProtocolError::InvalidPayload("trailing signal data")); + } + let target = target.ok_or(ProtocolError::InvalidPayload("missing target"))?; + if target != "child" && target != "process-group" { + return Err(ProtocolError::InvalidPayload("invalid signal target")); + } Ok(SignalRequest { signal: signal.ok_or(ProtocolError::InvalidPayload("missing signal"))?, - target: target.ok_or(ProtocolError::InvalidPayload("missing target"))?, + target, }) } diff --git a/experiments/ghostwright/native/pty-host-rust/src/session.rs b/experiments/ghostwright/native/pty-host-rust/src/session.rs index 00065db..862804f 100644 --- a/experiments/ghostwright/native/pty-host-rust/src/session.rs +++ b/experiments/ghostwright/native/pty-host-rust/src/session.rs @@ -5,6 +5,7 @@ use nix::pty::{openpty, Winsize}; use nix::sys::signal::Signal; use nix::sys::wait::{waitpid, WaitPidFlag, WaitStatus}; use nix::unistd::{fork, ForkResult, Pid}; +use std::collections::VecDeque; use std::ffi::CString; use std::io; use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; @@ -70,6 +71,12 @@ impl PreparedExec { } } +struct PendingWrite { + correlation: u32, + data: Vec, + offset: usize, +} + pub struct Session { master: Option, child: Option, @@ -78,6 +85,8 @@ pub struct Session { pty_eof: bool, exited_at: Option, cleanup: CleanupOptions, + writes: VecDeque, + queued_bytes: usize, } impl Session { @@ -90,6 +99,8 @@ impl Session { pty_eof: false, exited_at: None, cleanup: CleanupOptions::default(), + writes: VecDeque::new(), + queued_bytes: 0, } } @@ -147,6 +158,7 @@ impl Session { || nix::libc::dup2(slave_fd, nix::libc::STDOUT_FILENO) < 0 || nix::libc::dup2(slave_fd, nix::libc::STDERR_FILENO) < 0 { + write_exec_error(exec_error_write.as_raw_fd()); nix::libc::_exit(126); } if slave_fd > nix::libc::STDERR_FILENO { @@ -179,12 +191,14 @@ impl Session { nix::libc::_exit(127); }, ForkResult::Parent { child } => { + // Own the child before any fallible parent-side operation. + self.child = Some(child); + self.process_group = Some(child); drop(pty.slave); drop(barrier_read); drop(exec_error_write); + set_nonblocking(pty.master.as_raw_fd())?; self.master = Some(pty.master); - self.child = Some(child); - self.process_group = Some(child); self.cleanup = request.cleanup; protocol.spawned(correlation, child.as_raw(), child.as_raw())?; @@ -210,13 +224,89 @@ impl Session { } } - pub fn write(&self, data: &[u8]) -> Result { - let fd = self - .master - .as_ref() - .ok_or_else(|| io::Error::from(io::ErrorKind::BrokenPipe))? - .as_raw_fd(); - Ok(write_all_fd(fd, data)?) + pub fn wants_write(&self) -> bool { + !self.writes.is_empty() + } + + pub fn queue_write(&mut self, correlation: u32, data: Vec) -> Result<(), SessionError> { + if self.master.is_none() { + return Err(io::Error::from(io::ErrorKind::BrokenPipe).into()); + } + if self.queued_bytes + data.len() > 4 * 1024 * 1024 || self.writes.len() >= 1024 { + return Err(io::Error::other("PTY input queue limit exceeded").into()); + } + self.queued_bytes += data.len(); + self.writes.push_back(PendingWrite { + correlation, + data, + offset: 0, + }); + Ok(()) + } + + pub fn flush_writes(&mut self, protocol: &mut Protocol) -> Result<(), SessionError> { + let Some(master) = self.master.as_ref() else { + return Ok(()); + }; + while let Some(pending) = self.writes.front_mut() { + if pending.offset < pending.data.len() { + let bytes = &pending.data[pending.offset..]; + // SAFETY: bytes is a live slice and master is owned by this session. + let written = unsafe { + nix::libc::write(master.as_raw_fd(), bytes.as_ptr().cast(), bytes.len()) + }; + if written < 0 { + let error = io::Error::last_os_error(); + if error.kind() == io::ErrorKind::Interrupted { + continue; + } + if error.kind() == io::ErrorKind::WouldBlock { + return Ok(()); + } + self.cancel_writes(protocol)?; + return Ok(()); + } + if written == 0 { + return Ok(()); + } + pending.offset += written as usize; + self.queued_bytes -= written as usize; + } + if pending.offset == pending.data.len() { + let completed = self.writes.pop_front().unwrap(); + protocol.ack( + completed.correlation, + crate::protocol::kind::WRITE, + Some(completed.offset), + )?; + } + } + Ok(()) + } + + pub fn cancel_write( + &mut self, + sequence: u32, + protocol: &mut Protocol, + ) -> Result<(), SessionError> { + if let Some(index) = self + .writes + .iter() + .position(|write| write.correlation == sequence) + { + let pending = self.writes.remove(index).unwrap(); + self.queued_bytes -= pending.data.len() - pending.offset; + protocol.write_failed(pending.correlation, pending.offset)?; + } + Ok(()) + } + + fn cancel_writes(&mut self, protocol: &mut Protocol) -> Result<(), SessionError> { + while let Some(pending) = self.writes.pop_front() { + protocol.write_failed(pending.correlation, pending.offset)?; + } + self.queued_bytes = 0; + Ok(()) } pub fn resize(&self, viewport: Viewport) -> Result<(), SessionError> { @@ -269,11 +359,15 @@ impl Session { )) { self.master.take(); + self.cancel_writes(protocol)?; if !self.pty_eof { protocol.pty_eof()?; self.pty_eof = true; } - } else if io::Error::last_os_error().raw_os_error() != Some(nix::libc::EINTR) { + } else if !matches!( + io::Error::last_os_error().kind(), + io::ErrorKind::Interrupted | io::ErrorKind::WouldBlock + ) { return Err(io::Error::last_os_error().into()); } Ok(()) @@ -297,6 +391,7 @@ impl Session { pub fn cleanup(&mut self, protocol: &mut Protocol) -> Result<(), SessionError> { self.terminate_group(protocol)?; + self.cancel_writes(protocol)?; self.master.take(); if let Some(child) = self.child { if !self.child_exited { @@ -312,6 +407,7 @@ impl Session { } } } + self.process_group = None; Ok(()) } @@ -328,9 +424,9 @@ impl Session { WaitStatus::Signaled(_, signal, _) => (None, Some(signal as i32)), _ => return Ok(()), }; - protocol.process_exit(exit_code, signal)?; self.child_exited = true; self.exited_at = Some(Instant::now()); + protocol.process_exit(exit_code, signal)?; Ok(()) } @@ -395,6 +491,20 @@ impl Session { } } +// Protocol failures must not bypass process ownership. This fallback performs no +// allocation or output and runs even when normal cleanup cannot report an exit. +impl Drop for Session { + fn drop(&mut self) { + if let Some(group) = self.process_group { + unsafe { nix::libc::kill(-group.as_raw(), nix::libc::SIGKILL) }; + } + if let Some(child) = self.child.filter(|_| !self.child_exited) { + unsafe { nix::libc::kill(child.as_raw(), nix::libc::SIGKILL) }; + while let Err(nix::errno::Errno::EINTR) = waitpid(child, None) {} + } + } +} + fn parse_signal(name: &str) -> Result { match name { "SIGINT" | "INT" => Ok(Signal::SIGINT), @@ -407,6 +517,18 @@ fn parse_signal(name: &str) -> Result { } } +pub fn set_nonblocking(fd: i32) -> Result<(), io::Error> { + // SAFETY: fcntl only changes flags on the provided open descriptor. + unsafe { + let flags = nix::libc::fcntl(fd, nix::libc::F_GETFL); + if flags < 0 || nix::libc::fcntl(fd, nix::libc::F_SETFL, flags | nix::libc::O_NONBLOCK) < 0 + { + return Err(io::Error::last_os_error()); + } + } + Ok(()) +} + fn pipe_cloexec() -> Result<(OwnedFd, OwnedFd), io::Error> { let mut descriptors = [-1_i32; 2]; if unsafe { nix::libc::pipe(descriptors.as_mut_ptr()) } < 0 { diff --git a/experiments/ghostwright/package.json b/experiments/ghostwright/package.json index 1035f3d..569ade6 100644 --- a/experiments/ghostwright/package.json +++ b/experiments/ghostwright/package.json @@ -31,27 +31,69 @@ "./protocol": { "types": "./dist/types/pty/protocol.d.ts", "import": "./dist/pty/protocol.js" + }, + "./effection": { + "types": "./dist/types/effection/index.d.ts", + "import": "./dist/effection/index.js" + }, + "./matchers": { + "types": "./dist/types/runner-matchers.d.ts", + "import": "./dist/runner-matchers.js" + }, + "./vitest": { + "types": "./dist/types/vitest.d.ts", + "import": "./dist/vitest.js" + }, + "./jest": { + "types": "./dist/types/jest.d.ts", + "import": "./dist/jest.js" } }, "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", + "build": "rm -rf dist && bun build src/index.ts src/async.ts src/pty/protocol.ts src/effection/index.ts src/runner-matchers.ts src/vitest.ts src/jest.ts --outdir dist --target node --format esm --packages external --splitting --sourcemap=external && 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", - "build:host:c": "bun scripts/build-host-c.ts", "build:host:rust": "bun scripts/build-host-rust.ts", - "test:hosts": "bun test/host-contract.ts .cache/hosts/pty-host-c && bun test/host-contract.ts .cache/hosts/pty-host-rust", + "test:host": "bun test/host-contract.ts .cache/hosts/pty-host-rust", "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", + "typecheck": "tsc -p tsconfig.types.json", "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": "bun test && pnpm run test:runners", + "test:runners": "pnpm run build && node test/runner-contract.mjs", + "test:vitest": "vitest run --config test/runner-fixtures/vitest.config.ts", + "test:jest": "NODE_OPTIONS=--experimental-vm-modules jest --runInBand --config test/runner-fixtures/jest.config.mjs", "test:examples": "bun test examples" }, "dependencies": { "effection": "^4.0.2" }, + "devDependencies": { + "@jest/globals": "^30.2.0", + "@types/bun": "^1.3.9", + "expect": "^30.2.0", + "jest": "^30.2.0", + "typescript": "^5.9.3", + "vitest": "^4.1.9" + }, + "peerDependencies": { + "@jest/globals": ">=29", + "expect": ">=29", + "vitest": ">=3" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + }, + "@jest/globals": { + "optional": true + }, + "expect": { + "optional": true + } + }, "engines": { "bun": ">=1.2.0", "deno": ">=2.2.0", diff --git a/experiments/ghostwright/scripts/benchmark.ts b/experiments/ghostwright/scripts/benchmark.ts index 07b3ce7..c355e36 100644 --- a/experiments/ghostwright/scripts/benchmark.ts +++ b/experiments/ghostwright/scripts/benchmark.ts @@ -11,8 +11,8 @@ async function measure(params: { await params.operation(); samples.push(performance.now() - started); } - // oxlint-disable-next-line no-console -- benchmark script - console.log( + samples.sort((a, b) => a - b); + console.info( JSON.stringify({ name: params.name, iterations: params.iterations, @@ -23,19 +23,19 @@ async function measure(params: { ); } -await measure( - 'launch-exit-cleanup', - async () => { +await measure({ + name: 'launch-exit-cleanup', + operation: async () => { const terminal = await TerminalSession.launch({ command: '/usr/bin/true', trace: 'off' }); await terminal.process.waitForExit(); await terminal.close(); }, - 10, -); + iterations: 10, +}); -await measure( - 'one-megabyte-output', - async () => { +await measure({ + name: 'one-megabyte-output', + operation: async () => { const terminal = await TerminalSession.launch({ command: process.execPath, args: ['-e', `process.stdout.write("x".repeat(1024 * 1024))`], @@ -45,5 +45,5 @@ await measure( await terminal.process.waitForExit(); await terminal.close(); }, - 5, -); + iterations: 5, +}); diff --git a/experiments/ghostwright/scripts/build-artifacts.ts b/experiments/ghostwright/scripts/build-artifacts.ts index c2003d8..1fe99b0 100644 --- a/experiments/ghostwright/scripts/build-artifacts.ts +++ b/experiments/ghostwright/scripts/build-artifacts.ts @@ -3,9 +3,8 @@ import { $ } from 'bun'; const root = new URL('..', import.meta.url).pathname, artifacts = `${root}/artifacts`; -// The packaged default remains the pure-C implementation while the Rust host -// is evaluated side by side. This script never invokes Zig for PTY-host code. -await $`bun ${root}/scripts/build-host-c.ts`; +// Rust owns only PTY/process transport. Zig is used separately for Ghostty WASM. +await $`bun ${root}/scripts/build-host-rust.ts`; await $`rm -rf ${artifacts}/terminfo/67 ${artifacts}/terminfo/78`; await $`tic -x -o ${artifacts}/terminfo ${root}/native/terminfo/xterm-ghostty.src`; await $`bun ${root}/scripts/update-manifest.ts`; diff --git a/experiments/ghostwright/scripts/build-host-c.ts b/experiments/ghostwright/scripts/build-host-c.ts deleted file mode 100644 index 05fd8af..0000000 --- a/experiments/ghostwright/scripts/build-host-c.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { $ } from 'bun'; -import { mkdir } from 'node:fs/promises'; -import { GhostwrightError } from '../src/errors.ts'; - -const root = new URL('..', import.meta.url).pathname, - source = `${root}/native/pty-host-c`, - cache = `${root}/.cache/hosts`, - artifacts = `${root}/artifacts`, - sources = [`${source}/main.c`, `${source}/protocol.c`, `${source}/session.c`]; -await mkdir(cache, { recursive: true }); -await mkdir(artifacts, { recursive: true }); - -if (process.platform === 'darwin') { - for (const architecture of ['arm64', 'x86_64'] as const) { - const target = architecture === 'x86_64' ? 'x64' : architecture, - output = `${artifacts}/pty-host-darwin-${target}`; - await $`xcrun clang -std=c17 -O2 -Wall -Wextra -Werror -arch ${architecture} ${sources} -o ${output}`; - await $`chmod +x ${output}`; - } - await $`cp ${artifacts}/pty-host-darwin-${process.arch} ${cache}/pty-host-c`; -} else if (process.platform === 'linux') { - const compiler = process.env.CC ?? 'musl-gcc', - target = `linux-${process.arch}`, - output = `${artifacts}/pty-host-${target}`; - await $`${compiler} -std=c17 -O2 -Wall -Wextra -Werror -static ${sources} -o ${output}`; - await $`chmod +x ${output}`; - await $`cp ${output} ${cache}/pty-host-c`; -} else { - throw new GhostwrightError({ - code: 'GW_UNSUPPORTED_PLATFORM', - message: `unsupported C host build platform ${process.platform}-${process.arch}`, - }); -} - -// oxlint-disable-next-line no-console -- build script -console.log(`${cache}/pty-host-c`); diff --git a/experiments/ghostwright/scripts/build-host-rust.ts b/experiments/ghostwright/scripts/build-host-rust.ts index 895ce39..3c34881 100644 --- a/experiments/ghostwright/scripts/build-host-rust.ts +++ b/experiments/ghostwright/scripts/build-host-rust.ts @@ -1,19 +1,27 @@ import { $ } from 'bun'; -import { mkdir } from 'node:fs/promises'; +import { UnsupportedPlatformError } from '../src/errors.ts'; +import { copyFile, mkdir, chmod } from 'node:fs/promises'; -const root = new URL('..', import.meta.url).pathname, - crate = `${root}/native/pty-host-rust`, - cache = `${root}/.cache/hosts`, - target = process.env.GHOSTWRIGHT_RUST_TARGET; -await mkdir(cache, { recursive: true }); - -if (target) { - await $`cargo build --release --locked --target ${target}`.cwd(crate); - await $`cp ${crate}/target/${target}/release/ghostwright-pty-host ${cache}/pty-host-rust`; -} else { - await $`cargo build --release --locked`.cwd(crate); - await $`cp ${crate}/target/release/ghostwright-pty-host ${cache}/pty-host-rust`; -} -await $`chmod +x ${cache}/pty-host-rust`; -// oxlint-disable-next-line no-console -- build script -console.log(`${cache}/pty-host-rust`); +const root = new URL('..', import.meta.url).pathname; +const crate = `${root}/native/pty-host-rust`; +const targets: Record = { + 'aarch64-apple-darwin': 'darwin-arm64', + 'x86_64-apple-darwin': 'darwin-x64', + 'aarch64-unknown-linux-musl': 'linux-arm64', + 'x86_64-unknown-linux-musl': 'linux-x64', +}; +const local = `${process.platform}-${process.arch}`; +const target = + process.env.GHOSTWRIGHT_RUST_TARGET ?? + Object.keys(targets).find((candidate) => targets[candidate] === local); +if (!target || !targets[target]) + throw new UnsupportedPlatformError(`Unsupported Rust PTY target: ${target ?? local}`); +await mkdir(`${root}/artifacts`, { recursive: true }); +await mkdir(`${root}/.cache/hosts`, { recursive: true }); +await $`cargo build --release --locked --target ${target}`.cwd(crate); +const binary = `${crate}/target/${target}/release/ghostwright-pty-host`; +const output = `${root}/artifacts/pty-host-${targets[target]}`; +await copyFile(binary, output); +await chmod(output, 0o755); +if (targets[target] === local) await copyFile(output, `${root}/.cache/hosts/pty-host-rust`); +console.info(output); diff --git a/experiments/ghostwright/scripts/compare-hosts.ts b/experiments/ghostwright/scripts/compare-hosts.ts deleted file mode 100644 index 9b22406..0000000 --- a/experiments/ghostwright/scripts/compare-hosts.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { $ } from 'bun'; -import { readdir, readFile, stat, writeFile } from 'node:fs/promises'; -// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution -import { join } from 'node:path'; -import { TerminalSession } from '../src/terminal/session.ts'; -import { usePtyHostForTesting } from '../src/profile.ts'; -import { SidecarClient } from '../src/pty/client.ts'; -import { runHostContract } from '../test/host-contract.ts'; - -const root = new URL('..', import.meta.url).pathname, - hosts = [ - { name: 'Pure C', key: 'c', path: `${root}/.cache/hosts/pty-host-c` }, - { name: 'Rust', key: 'rust', path: `${root}/.cache/hosts/pty-host-rust` }, - ]; - -async function timed(operation: () => Promise): Promise { - const started = performance.now(); - await operation(); - return performance.now() - started; -} - -const buildTimes = { - c: await timed(() => $`bun ${root}/scripts/build-host-c.ts`.quiet()), - rust: await timed(() => $`bun ${root}/scripts/build-host-rust.ts`.quiet()), -}; - -for (const host of hosts) await runHostContract(host.path); - -async function launchSamples(hostPath: string): Promise { - const restore = usePtyHostForTesting(hostPath), - samples: number[] = []; - try { - for (let index = 0; index < 12; index++) { - const started = performance.now(), - terminal = await TerminalSession.launch({ command: '/usr/bin/true', trace: 'off' }); - await terminal.process.waitForExit(); - await terminal.close(); - samples.push(performance.now() - started); - } - } finally { - restore(); - } - samples.sort((a, b) => a - b); - return samples[Math.floor(samples.length / 2)]; -} - -async function transportThroughput( - hostPath: string, -): Promise<{ bytes: number; elapsed: number; mibPerSecond: number }> { - const environment = Object.fromEntries( - Object.entries(process.env).filter( - (entry): entry is [string, string] => entry[1] !== undefined, - ), - ), - client = await SidecarClient.start(hostPath), - started = performance.now(); - let bytes = 0, - exited = false, - eof = false, - resolve!: () => void; - const completed = new Promise((done) => (resolve = done)), - check = () => { - if (exited && eof) resolve(); - }; - client.on('output', (chunk) => (bytes += chunk.length)); - client.on('exit', () => { - exited = true; - check(); - }); - client.on('eof', () => { - eof = true; - check(); - }); - await client.spawn({ - command: process.execPath, - args: ['-e', `process.stdout.write("x".repeat(1024 * 1024))`], - cwd: process.cwd(), - env: environment, - viewport: { columns: 80, rows: 24, widthPixels: 800, heightPixels: 480 }, - cleanup: { hangupGraceMs: 50, terminateGraceMs: 50, postExitDrainMs: 100 }, - }); - await completed; - const elapsed = performance.now() - started; - await client.close(); - return { bytes, elapsed, mibPerSecond: bytes / (1024 * 1024) / (elapsed / 1000) }; -} - -async function sourceStats( - directory: string, -): Promise<{ files: number; lines: number; nonblank: number; unsafe: number }> { - const names = (await readdir(directory)).filter((name) => /\.(c|h|rs)$/.test(name)), - sources = await Promise.all(names.map((name) => readFile(join(directory, name), 'utf8'))); - return { - files: names.length, - lines: sources.reduce((total, source) => total + source.split('\n').length, 0), - nonblank: sources.reduce( - (total, source) => total + source.split('\n').filter((line) => line.trim()).length, - 0, - ), - unsafe: sources.reduce( - (total, source) => total + (source.match(/\bunsafe\b/g)?.length ?? 0), - 0, - ), - }; -} - -const results = []; -for (const host of hosts) { - const sourceDirectory = - host.key === 'c' ? `${root}/native/pty-host-c` : `${root}/native/pty-host-rust/src`, - source = await sourceStats(sourceDirectory), - binary = await stat(host.path), - launchMedianMs = await launchSamples(host.path), - throughput = await transportThroughput(host.path); - results.push({ - ...host, - source, - binaryBytes: binary.size, - buildMs: buildTimes[host.key as keyof typeof buildTimes], - launchMedianMs, - throughput, - }); -} - -const table = results - .map( - (result) => - `| ${result.name} | ${result.source.files} | ${result.source.nonblank} | ${result.source.unsafe} | ${(result.binaryBytes / 1024).toFixed(1)} KiB | ${result.buildMs.toFixed(1)} ms | ${result.launchMedianMs.toFixed(1)} ms | ${result.throughput.mibPerSecond.toFixed(1)} MiB/s |`, - ) - .join('\n'); -const document = `# PTY Host C vs. Rust Comparison - -Generated on ${new Date().toISOString()} by \`bun run compare:hosts\` on ${process.platform}-${process.arch}. - -Both candidates passed the same GWPT/PTY contract before measurement. Candidate outputs are generated under the ignored \`.cache/hosts\` directory and are not included in the npm artifact inventory. - -| Implementation | Source files | Nonblank LOC | \`unsafe\` tokens | Stripped binary | Warm build | Median launch/exit | Raw 1 MiB transport | -|---|---:|---:|---:|---:|---:|---:|---:| -${table} - -## Pure C - -- Compiler: Apple Clang on macOS; native \`musl-gcc\` on Linux release runners. -- Runtime dependencies: system libc on Darwin; static musl on Linux. -- The protocol, ownership rules, and cleanup are explicit, but allocation and file-descriptor cleanup remain manual. -- No Zig code or Zig C compiler is used for the PTY host. - -## Rust - -- Direct dependencies: \`nix\`, \`minicbor\`, and \`thiserror\`. -- The event loop is synchronous; there is no Tokio or async runtime. -- Owned file descriptors provide automatic parent-side closure. Unsafe code is concentrated around the post-fork child setup and exact ioctl/exec operations. -- The larger binary includes Rust runtime and formatting/panic support despite LTO, aborting panics, and stripping. - -## Notes - -- “Warm build” includes an incremental Cargo build; a clean Rust build also compiles dependencies and is intentionally reported separately during release evaluation. On macOS the C build command emits both arm64 and x64 binaries while the measured Rust command emits the native binary, so this number is not a single-target compiler comparison. -- Raw transport bypasses Ghostty screen extraction, isolating sidecar throughput. -- Zig remains a maintainer dependency only for building upstream \`ghostty-vt.wasm\`; it is absent from both PTY-host implementations. -`; -await writeFile(`${root}/HOST-COMPARISON.md`, document); -// oxlint-disable-next-line no-console -- comparison script -console.log(document); diff --git a/experiments/ghostwright/scripts/update-manifest.ts b/experiments/ghostwright/scripts/update-manifest.ts index dbf2c49..82ac4fc 100644 --- a/experiments/ghostwright/scripts/update-manifest.ts +++ b/experiments/ghostwright/scripts/update-manifest.ts @@ -66,8 +66,7 @@ for (let dir = new URL('./', lockUrl); ; dir = new URL('../', dir)) { if (dir.pathname === '/') break; } -// oxlint-disable-next-line no-console -- build script -console.log( +console.info( `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 bedfc12..c894cd5 100644 --- a/experiments/ghostwright/scripts/verify-artifacts.ts +++ b/experiments/ghostwright/scripts/verify-artifacts.ts @@ -84,8 +84,7 @@ if (lock.graphics?.kittyGraphics) { wasmExports.ghostty_wasm_free_u8_array(out, 1); } } -// oxlint-disable-next-line no-console -- verify script -console.log( +console.info( `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 e750bbd..264d5eb 100644 --- a/experiments/ghostwright/src/assertions/index.ts +++ b/experiments/ghostwright/src/assertions/index.ts @@ -2,6 +2,7 @@ import { StrictLocatorError, TerminalAssertionError } from '../errors.ts'; import type { AsyncLocatorExpectation, AsyncTerminalExpectation } from './types-internal.ts'; import type { AssertionOptions, + LocatorMatch, ScreenRevision, ScreenSnapshot, StableAssertionOptions, @@ -85,7 +86,7 @@ async function wait( } class LocatorExpectation implements AsyncLocatorExpectation { constructor(readonly locator: Locator) {} - async toBePresent(options: AssertionOptions = {}): Promise { + async toBePresent(options: AssertionOptions = {}): Promise { const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? @@ -104,7 +105,7 @@ class LocatorExpectation implements AsyncLocatorExpectation { ); } } - async toBeStable(options: StableAssertionOptions = {}): Promise { + async toBeStable(options: StableAssertionOptions = {}): Promise { const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? @@ -203,13 +204,13 @@ class LocatorExpectation implements AsyncLocatorExpectation { ); } } - async toHaveStyle(style: StyleQuery, options: AssertionOptions = {}): Promise { + async toHaveStyle(style: StyleQuery, options: AssertionOptions = {}): Promise { const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? DEFAULT_ASSERTION_TIMEOUT_MS, start = performance.now(), - satisfied = () => { + satisfied = (): boolean => { const m = this.locator.matches(); return m.length === 1 && cellsMatchStyle(m[0].cells, style); }; @@ -235,13 +236,13 @@ class LocatorExpectation implements AsyncLocatorExpectation { ); return this.locator.matches()[0]; } - async toContainCursor(options: AssertionOptions = {}): Promise { + async toContainCursor(options: AssertionOptions = {}): Promise { const timeout = options.timeoutMs ?? this.locator.session.options.assertionTimeoutMs ?? DEFAULT_ASSERTION_TIMEOUT_MS, start = performance.now(), - satisfied = () => { + satisfied = (): boolean => { const m = this.locator.matches(); if (m.length !== 1) return false; const { range } = m[0], @@ -328,7 +329,7 @@ class TerminalExpectation implements AsyncTerminalExpectation { this.session.lastAction?.screenSequenceBefore ?? this.session.screen.current().sequence); const safe = safePredicate(predicate), - find = () => + find = (): ScreenRevision | undefined => this.session.revisionsSince(baseline).find((revision) => safe.test(revision.snapshot)); let result = find(); if (!result) @@ -350,6 +351,11 @@ class TerminalExpectation implements AsyncTerminalExpectation { } } /** Create an async assertion expectation for a locator or terminal session. */ +export function expectTerminal(target: Locator): LocatorExpectation; +export function expectTerminal(target: TerminalSession): TerminalExpectation; +export function expectTerminal( + target: Locator | TerminalSession, +): LocatorExpectation | TerminalExpectation; export function expectTerminal( target: Locator | TerminalSession, ): LocatorExpectation | TerminalExpectation { diff --git a/experiments/ghostwright/src/async.ts b/experiments/ghostwright/src/async.ts index f404c86..9f7ff47 100644 --- a/experiments/ghostwright/src/async.ts +++ b/experiments/ghostwright/src/async.ts @@ -1,40 +1,88 @@ -import { call, run } from 'effection'; -import type { AsyncTerminal, TerminalLaunchOptions } from './types.ts'; -import { TerminalSession } from './terminal/session.ts'; -/** Launch a terminal session, run an async body, and clean up when done. */ -export async function withTerminalAsync( +import { createScope, suspend, useAbortSignal, useScope, type Scope } from 'effection'; +import type { TerminalSession } from './terminal/session.ts'; +import type { TerminalLaunchOptions, ActionReceipt } from './types.ts'; +import { AsyncExecution, useSession } from './execution.ts'; +import { recordFailure, recordSuccess } from './tracing/outcome.ts'; +import { SessionClosedError } from './errors.ts'; + +/** An owned execution scope. Locators and capture results do not own this lifetime. */ +export class Terminal extends AsyncExecution implements AsyncDisposable { + #disposing?: Promise; + #failureRecorded = false; + private readonly disposeScope: () => Promise; + constructor(owner: { + session: TerminalSession; + scope: Scope; + signal: AbortSignal; + dispose(): Promise; + }) { + super(owner.session, owner.scope, owner.signal); + this.disposeScope = owner.dispose; + } + /** Runner fixtures can report failures that async disposal cannot observe. */ + recordFailure = async (error: unknown): Promise => { + this.#failureRecorded = true; + await recordFailure(this.session, error); + }; + // Publish the disposal promise before abort listeners can reenter this method. + [Symbol.asyncDispose] = (): Promise => + (this.#disposing ??= Promise.resolve().then(() => this.#dispose())); + async #dispose(): Promise { + await this.disposeScope(); + if (!this.#failureRecorded) await recordSuccess(this.session); + } + override close = async (): Promise => { + await this[Symbol.asyncDispose](); + return this.session.close(); + }; +} + +/** Launch an owned terminal. Use await using, close(), or a runner fixture to release it. */ +export async function launchTerminal(options: TerminalLaunchOptions): Promise { + const scope = createScope(); + const controller = new AbortController(); + const dispose = async (): Promise => { + controller.abort(new SessionClosedError('Terminal scope disposed')); + await scope[Symbol.asyncDispose](); + }; + let resolveReady!: (terminal: Terminal) => void; + let rejectReady!: (error: unknown) => void; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const lifetime = scope.run(function* () { + const session = yield* useSession(options); + const terminal = new Terminal({ + session, + scope: yield* useScope(), + signal: AbortSignal.any([controller.signal, yield* useAbortSignal()]), + dispose, + }); + resolveReady(terminal); + yield* suspend(); + }); + // A failed launch rejects acquisition. Normal disposal halts this task after + // acquisition; its rejection is still observed, rather than left unhandled. + void lifetime.catch(rejectReady); + try { + return await ready; + } catch (error) { + await dispose(); + throw error; + } +} + +/** Run a test body with the same owned lifetime as launchTerminal. */ +export async function withTerminal( options: TerminalLaunchOptions, - body: (terminal: AsyncTerminal) => Promise, + body: (terminal: AsyncExecution) => Promise, ): Promise { - return run(function* () { - const session: TerminalSession = yield* call(() => TerminalSession.launch(options)); - try { - const result: T = yield* call(() => body(session)); - if (session.trace.policy === 'on') - yield* call(() => - session.trace.persist( - 'Session completed successfully', - session.screen.current(), - session.process.status(), - ), - ); - return result; - } catch (error) { - try { - const path = yield* call(() => - session.trace.persist(error, session.screen.current(), session.process.status()), - ); - if (path && error instanceof Error) { - (error as Error & { tracePath?: string }).tracePath = path; - error.message += `\ntrace artifact: ${path}`; - } - } catch (traceError) { - if (error instanceof Error) - (error as Error & { suppressed?: unknown[] }).suppressed = [traceError]; - } - throw error; - } finally { - yield* call(() => session.close()); - } - }); + await using terminal = await launchTerminal(options); + try { + return await body(terminal); + } catch (error) { + await terminal.recordFailure(error); + throw error; + } } diff --git a/experiments/ghostwright/src/conditions.ts b/experiments/ghostwright/src/conditions.ts new file mode 100644 index 0000000..a8b67bb --- /dev/null +++ b/experiments/ghostwright/src/conditions.ts @@ -0,0 +1,121 @@ +import { InvalidOptionsError, StrictLocatorError } from './errors.ts'; +import type { Observation } from './observations.ts'; +import type { RegionLocator } from './locators.ts'; + +/** Fresh state per wait/capture. A condition is reusable; its evaluator is not. */ +export interface ConditionState { + observe(observation: Observation): boolean; + baseline?(observation: Observation): void; + wakeAt?: number; + wake?(now: number): boolean; +} +export interface Condition { + create(startedAt: number): ConditionState; +} + +export function sequence(...conditions: readonly Condition[]): Condition { + if (!conditions.length) + throw new InvalidOptionsError('A transition requires at least one condition'); + return Object.freeze({ + create(startedAt) { + let index = 0, + current = conditions[0]!.create(startedAt); + let latest: Observation | undefined; + return { + baseline(observation) { + latest = observation; + current.baseline?.(observation); + }, + observe(observation) { + latest = observation; + if (current.observe(observation)) { + index++; + if (index === conditions.length) return true; + current = conditions[index]!.create(observation.timestamp); + current.baseline?.(observation); + } + return false; + }, + get wakeAt() { + return current.wakeAt; + }, + wake(now) { + if (!current.wake?.(now)) return false; + index++; + if (index === conditions.length) return true; + current = conditions[index]!.create(now); + if (latest) current.baseline?.(latest); + return false; + }, + }; + }, + }); +} +/** Explicit elapsed-time capture. Prefer a visible completion condition. */ +export function elapsed(milliseconds: number): Condition { + duration(milliseconds); + return Object.freeze({ + create: (started) => ({ + observe: () => false, + wakeAt: started + milliseconds, + wake: (now) => now >= started + milliseconds, + }), + }); +} +function duration(milliseconds: number): void { + if (!Number.isFinite(milliseconds) || milliseconds < 0) + throw new InvalidOptionsError('Duration must be nonnegative and finite'); +} +/** Defaults to region contents; unrelated screen animation cannot reset it. */ +// oxlint-disable-next-line bombshell-dev/max-params -- query, interval, and independent stability dimension +export function settled( + locator: RegionLocator, + milliseconds = 100, + kind: 'region' | 'geometry' = 'region', +): Condition { + duration(milliseconds); + return Object.freeze({ + create(startedAt) { + let key: string | undefined, + wakeAt: number | undefined, + screenSequence: number | undefined, + pending = false; + return { + baseline(observation) { + this.observe(observation); + if (wakeAt !== undefined) wakeAt = startedAt + milliseconds; + }, + get wakeAt() { + return pending ? undefined : wakeAt; + }, + observe(observation) { + if (!locator.accepts(observation)) { + if (observation.screen.sequence !== screenSequence) pending = true; + return false; + } + pending = false; + screenSequence = observation.screen.sequence; + const matches = locator.resolve(observation); + if (matches.length > 1) + throw new StrictLocatorError(`Ambiguous locator: ${locator.source}`); + const next = matches[0] + ? kind === 'geometry' + ? JSON.stringify(matches[0].bounds) + : matches[0].visualKey() + : undefined; + if (next === undefined) { + key = undefined; + wakeAt = undefined; + return false; + } + if (next !== key) { + key = next; + wakeAt = observation.timestamp + milliseconds; + } + return wakeAt !== undefined && observation.timestamp >= wakeAt; + }, + wake: (now) => !pending && wakeAt !== undefined && now >= wakeAt, + }; + }, + }); +} diff --git a/experiments/ghostwright/src/effection/index.ts b/experiments/ghostwright/src/effection/index.ts index 61f1485..de48afa 100644 --- a/experiments/ghostwright/src/effection/index.ts +++ b/experiments/ghostwright/src/effection/index.ts @@ -1,6 +1,7 @@ import { call, type Operation } from 'effection'; import type { AssertionOptions, + ActionReceipt, KeyName, HistoryQuery, HistorySearchOptions, @@ -26,7 +27,26 @@ import type { } from '../types.ts'; import { expectTerminal as expectAsync } from '../assertions/index.ts'; import type { Locator } from '../terminal/session.ts'; -import { TerminalSession } from '../terminal/session.ts'; +import { + execution, + useSession, + assertRegion, + captureOperation, + type CaptureOptions, + type AsyncExecution, +} from '../execution.ts'; +import { createExpect, type Matcher } from '../matchers.ts'; +import { recordFailure, recordSuccess } from '../tracing/outcome.ts'; +import type { ScreenQueries } from '../queries.ts'; +import type { WaitForOptions } from '../wait-for.ts'; +import type { MouseTarget, DragOffset } from '../mouse.ts'; +type OperationQueries = { + [K in keyof ScreenQueries]: ScreenQueries[K] extends (...args: infer A) => Promise + ? (...args: A) => Operation + : ScreenQueries[K]; +}; +import type { RegionLocator } from '../locators.ts'; +const expectRegion = createExpect(); const op = (fn: () => Promise): Operation => call(fn); /** Effection wrapper around an async Locator. */ export class EffectionLocator implements OperationLocator { @@ -40,13 +60,32 @@ export class EffectionLocator implements OperationLocator { matches(): readonly LocatorMatch[] { return this.inner.matches(); } - click(o?: MouseOptions): Operation { + click(o?: MouseOptions): Operation { return op(() => this.inner.click(o)); } } /** Effection wrapper around a TerminalSession. */ export class EffectionTerminal implements OperationTerminal { - constructor(readonly inner: TerminalSession) {} + constructor(readonly inner: AsyncExecution) {} + get signal(): AbortSignal { + return this.inner.signal; + } + assert(locator: RegionLocator, matcher: Matcher): ReturnType { + return assertRegion(this.inner.session, locator, matcher); + } + expect(locator: RegionLocator): ReturnType { + return expectRegion.operation(this, locator); + } + waitFor = (callback: () => T | PromiseLike, options?: WaitForOptions): Operation => + op(() => this.inner.waitFor(callback, options)); + capture( + options: CaptureOptions, + body: (terminal: EffectionTerminal) => Operation, + ): ReturnType { + return captureOperation(this.inner.session, options, (terminal) => + body(new EffectionTerminal(terminal)), + ); + } keyboard = { press: (k: KeyName | KeyPress) => op(() => this.inner.keyboard.press(k)), type: (t: string, o?: TraceableInputOptions) => op(() => this.inner.keyboard.type(t, o)), @@ -55,13 +94,15 @@ export class EffectionTerminal implements OperationTerminal { write: (d: Uint8Array) => op(() => this.inner.keyboard.write(d)), }; mouse = { - move: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.move(p, o)), - down: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.down(p, o)), - up: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.up(p, o)), - click: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.click(p, o)), - doubleClick: (p: Point, o?: MouseOptions) => op(() => this.inner.mouse.doubleClick(p, o)), + move: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.move(p, o)), + hover: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.hover(p, o)), + down: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.down(p, o)), + up: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.up(p, o)), + click: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.click(p, o)), + doubleClick: (p: MouseTarget, o?: MouseOptions) => op(() => this.inner.mouse.doubleClick(p, o)), // oxlint-disable-next-line bombshell-dev/max-params -- wraps mouse.drag(start, end, options) API - drag: (a: Point, b: Point, o?: MouseOptions) => op(() => this.inner.mouse.drag(a, b, o)), + drag: (a: MouseTarget, b: Point | DragOffset, o?: MouseOptions) => + op(() => this.inner.mouse.drag(a, b, o)), wheel: (o: WheelOptions) => op(() => this.inner.mouse.wheel(o)), }; process = { @@ -69,8 +110,23 @@ export class EffectionTerminal implements OperationTerminal { signal: (s: string, t?: 'child' | 'process-group') => op(() => this.inner.process.signal(s, t)), waitForExit: (o?: AssertionOptions) => op(() => this.inner.process.waitForExit(o)), }; - get screen() { - return this.inner.screen; + get screen(): OperationTerminal['screen'] & OperationQueries { + const screen = this.inner.screen; + return Object.freeze({ + ...screen, + findBy: (locator: RegionLocator, options?: WaitForOptions) => + op(() => screen.findBy(locator, options)), + findAllBy: (locator: RegionLocator, options?: WaitForOptions) => + op(() => screen.findAllBy(locator, options)), + findByText: (...args: Parameters) => + op(() => screen.findByText(...args)), + findAllByText: (...args: Parameters) => + op(() => screen.findAllByText(...args)), + findBySelector: (...args: Parameters) => + op(() => screen.findBySelector(...args)), + findAllBySelector: (...args: Parameters) => + op(() => screen.findAllBySelector(...args)), + }); } revisions = { collect: (options: RevisionCollectionOptions) => @@ -86,7 +142,7 @@ export class EffectionTerminal implements OperationTerminal { copyImageData: (id: number) => op(() => this.inner.graphics.copyImageData(id)), }; getByText(t: string, o?: TextLocatorOptions): EffectionLocator { - return new EffectionLocator(this.inner.getByText(t, o) as Locator); + return new EffectionLocator(this.inner.getByText(t, o)); } region(r: Rect): OperationRegion { const x = this.inner.region(r); @@ -95,46 +151,27 @@ export class EffectionTerminal implements OperationTerminal { snapshot: () => x.snapshot(), }; } - resize(v: Viewport): Operation { + resize(v: Viewport): Operation { return op(() => this.inner.resize(v)); } - close(): Operation { + close(): Operation { return op(() => this.inner.close()); } } /** Launch a terminal session, run an Effection operation body, and clean up when done. */ export function* withTerminal( options: TerminalLaunchOptions, - body: (terminal: OperationTerminal) => Operation, + body: (terminal: EffectionTerminal) => Operation, ): Operation { - const session: TerminalSession = yield* call(() => TerminalSession.launch(options)); + const session = yield* useSession(options); + const terminal = yield* execution(session); try { - const result: T = yield* body(new EffectionTerminal(session)); - if (session.trace.policy === 'on') - yield* call(() => - session.trace.persist( - 'Session completed successfully', - session.screen.current(), - session.process.status(), - ), - ); + const result: T = yield* body(new EffectionTerminal(terminal)); + yield* call(() => recordSuccess(session)); return result; } catch (error) { - try { - const path = yield* call(() => - session.trace.persist(error, session.screen.current(), session.process.status()), - ); - if (path && error instanceof Error) { - (error as Error & { tracePath?: string }).tracePath = path; - error.message += `\ntrace artifact: ${path}`; - } - } catch (traceError) { - if (error instanceof Error) - (error as Error & { suppressed?: unknown[] }).suppressed = [traceError]; - } + yield* call(() => recordFailure(session, error)); throw error; - } finally { - yield* call(() => session.close()); } } /** Effection locator assertion expectation. */ @@ -171,7 +208,7 @@ export function expectOperation( toContainCursor: (o?: AssertionOptions) => op(() => e.toContainCursor(o)), }; } - const e = expectAsync(target.inner); + const e = expectAsync(target.inner.session); return { toSatisfy: (predicate: (snapshot: ScreenSnapshot) => boolean, o?: StableAssertionOptions) => op(() => e.toSatisfy(predicate, o)), diff --git a/experiments/ghostwright/src/errors.ts b/experiments/ghostwright/src/errors.ts index 44a16f9..f91d371 100644 --- a/experiments/ghostwright/src/errors.ts +++ b/experiments/ghostwright/src/errors.ts @@ -11,7 +11,10 @@ export class GhostwrightError extends Error { this.sessionName = params.sessionName; } } -function errorType(name: T, code: string) { +function errorType( + name: string, + code: string, +): new (message: string, options?: ErrorOptions & { sessionName?: string }) => GhostwrightError { return class extends GhostwrightError { constructor(message: string, options?: ErrorOptions & { sessionName?: string }) { super({ code, message, ...options }); @@ -47,6 +50,16 @@ export class ExtensionOscLimitError extends errorType( 'ExtensionOscLimitError', 'GW_EXTENSION_OSC_LIMIT', ) {} +/** A cancelled or closed write with a known PTY-accepted prefix. */ +export class WriteInterruptedError extends GhostwrightError { + readonly bytesWritten: number; + constructor(bytesWritten: number, message = 'PTY write interrupted', options?: ErrorOptions) { + super({ code: 'GW_WRITE_INTERRUPTED', message, ...options }); + this.bytesWritten = bytesWritten; + } +} +/** Invalid execution or condition options. */ +export class InvalidOptionsError extends errorType('InvalidOptionsError', 'GW_INVALID_OPTIONS') {} /** Error when host command exceeds timeout. */ export class HostCommandTimeoutError extends errorType( 'HostCommandTimeoutError', diff --git a/experiments/ghostwright/src/execution.ts b/experiments/ghostwright/src/execution.ts new file mode 100644 index 0000000..41cb620 --- /dev/null +++ b/experiments/ghostwright/src/execution.ts @@ -0,0 +1,399 @@ +import { + action, + call, + race, + resource, + scoped, + sleep, + spawn, + useAbortSignal, + useScope, + withResolvers, + type Operation, + type Scope, +} from 'effection'; +import { + GhostwrightError, + InvalidOptionsError, + ProcessExitedError, + SessionClosedError, + StrictLocatorError, + TerminalAssertionError, +} from './errors.ts'; +import { createExpect, type Matcher, type MatchResult } from './matchers.ts'; +import type { RegionLocator } from './locators.ts'; +import type { RegionInspection } from './inspection.ts'; +import type { Condition } from './conditions.ts'; +import type { Observation } from './observations.ts'; +import { TerminalSession } from './terminal/session.ts'; +import type { ActionReceipt, AsyncTerminal, TerminalLaunchOptions } from './types.ts'; +import { createQueries, type ScreenQueries } from './queries.ts'; +import { waitForOperation, type WaitForOptions } from './wait-for.ts'; +import { locatedMouse, type LocatedMouse } from './mouse.ts'; + +export interface CaptureOptions { + readonly until: Condition; + readonly timeoutMs?: number; + readonly maxObservations?: number; + readonly maxBytes?: number; + readonly signal?: AbortSignal; +} +export interface Capture { + readonly baseline: Observation; + readonly startedAt: number; + readonly completedAt: number; + readonly observations: readonly Observation[]; +} +const expectRegion = createExpect(); +const error = (code: string, message: string): GhostwrightError => + new GhostwrightError({ code, message }); +function timeout(milliseconds: number, code: string): Operation { + if (!Number.isFinite(milliseconds) || milliseconds < 0) + throw new InvalidOptionsError('timeoutMs must be nonnegative and finite'); + return (function* () { + yield* sleep(milliseconds); + throw error(code, `Deadline exceeded after ${milliseconds} ms`); + })(); +} +function aborted(signal: AbortSignal): Operation { + return action((_resolve, reject) => { + const abort = (): void => reject(signal.reason); + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + return () => signal.removeEventListener('abort', abort); + }); +} +// oxlint-disable-next-line bombshell-dev/max-params -- internal deadline/error/cancellation boundary +function* bounded( + operation: Operation, + milliseconds: number, + code: string, + signal?: AbortSignal, +): Operation { + signal?.throwIfAborted(); + return yield* race([ + operation, + timeout(milliseconds, code), + ...(signal ? [aborted(signal)] : []), + ]); +} +function ended(session: TerminalSession): Error | undefined { + const status = session.process.status(); + if (status.state === 'closed' || status.state === 'failed') + return new SessionClosedError('Terminal closed before condition matched'); + if (status.ptyEof) return new ProcessExitedError('PTY reached EOF before condition matched'); +} + +/** All wait resources are Effection actions; cancellation always removes them. */ +// oxlint-disable-next-line bombshell-dev/max-params -- internal session/query/evidence boundary +function awaitMatch( + session: TerminalSession, + locator: RegionLocator, + matcher: Matcher, +): Operation { + return action((resolve, reject) => { + const check = (observation?: Observation): void => { + if (!observation || !locator.accepts(observation)) return; + try { + const matches = locator.resolve(observation); + if (matches.length > 1) + throw new StrictLocatorError(`${locator.source} matched ${matches.length} regions`); + if (matches[0]) { + const result = matcher(matches[0]); + if (result.pass) resolve(matches[0]); + } + } catch (cause) { + reject(cause as Error); + } + }; + const off = session.observations.subscribe(check); + const offStatus = session.subscribe(() => { + const cause = ended(session); + if (cause) reject(cause); + }); + check( + locator.extensionId === undefined + ? session.observations.currentScreen() + : session.observations.current(locator.extensionId), + ); + const cause = ended(session); + if (cause) reject(cause); + return () => { + off(); + offStatus(); + }; + }); +} + +/** A scope-bound executor. Queries and matchers themselves own no lifetime. */ +export class AsyncExecution implements AsyncTerminal { + readonly screen: AsyncTerminal['screen'] & ScreenQueries; + readonly keyboard: AsyncTerminal['keyboard']; + readonly mouse: LocatedMouse; + readonly process: AsyncTerminal['process']; + readonly revisions: AsyncTerminal['revisions']; + readonly history: AsyncTerminal['history']; + readonly graphics: AsyncTerminal['graphics']; + readonly session: TerminalSession; + private readonly scope: Scope; + readonly signal: AbortSignal; + constructor(session: TerminalSession, scope: Scope, signal: AbortSignal) { + this.session = session; + this.scope = scope; + this.signal = signal; + this.keyboard = this.#bind(session.keyboardFor(signal)); + this.mouse = locatedMouse(this.#bind(session.mouseFor(signal)), (locator, matcher) => + this.assert(locator, matcher), + ); + this.process = { + status: () => session.process.status(), + signal: (name, target) => this.#promise(() => session.signalProcess(name, target, signal)), + waitForExit: (...args) => this.#promise(() => session.process.waitForExit(...args)), + }; + this.revisions = this.#bind(session.revisions); + this.history = this.#bind(session.history); + this.graphics = this.#bind(session.graphics); + this.screen = Object.freeze({ + ...session.screen, + ...createQueries({ + current: (locator) => { + signal.throwIfAborted(); + if (locator.extensionId && !session.hasExtension(locator.extensionId)) + throw error( + 'GW_EXTENSION_NOT_REGISTERED', + `Locator requires extension ${locator.extensionId}`, + ); + return locator.extensionId === undefined + ? session.observations.currentScreen() + : session.observations.current(locator.extensionId); + }, + waitFor: this.waitFor, + selector: session.options.selector, + }), + }); + } + #bind Promise>>(methods: T): T { + const bind = + (method: (...args: Args) => Promise) => + (...args: Args): Promise => + this.#promise(() => method(...args)); + // Object.fromEntries loses the association between each key and its signature. + return Object.fromEntries( + Object.entries(methods).map(([name, method]) => [name, bind(method)]), + ) as T; + } + async #run(operation: () => Operation): Promise { + this.signal.throwIfAborted(); + // Return failures as data across Scope.run so a caller can catch an operation + // failure without poisoning the enclosing session's task group. + const signal = this.signal; + const outcome = await this.scope.run(function* () { + try { + return { ok: true as const, value: yield* race([scoped(operation), aborted(signal)]) }; + } catch (cause) { + return { ok: false as const, cause }; + } + }); + if (!outcome.ok) throw outcome.cause; + return outcome.value; + } + #promise(fn: () => Promise): Promise { + return this.#run(() => call(fn)); + } + waitFor = (callback: () => T | PromiseLike, options?: WaitForOptions): Promise => + this.#run(() => + waitForOperation( + callback, + { + timeoutMs: this.session.options.assertionTimeoutMs ?? 4000, + diagnostics: () => this.session.screen.getText(), + subscribe: (notify) => { + const offScreen = this.session.observations.subscribe(notify); + const offStatus = this.session.subscribe(notify); + return () => { + offScreen(); + offStatus(); + }; + }, + }, + options, + ), + ); + getByText = ( + ...args: Parameters + ): ReturnType => this.session.getByText(...args); + region = (...args: Parameters): ReturnType => + this.session.region(...args); + resize = (viewport: Parameters[0]): Promise => + this.#promise(() => this.session.resize(viewport, this.signal)); + close = (): Promise => this.#promise(() => this.session.close()); + expect = (locator: RegionLocator): ReturnType => expectRegion(this, locator); + assert = (locator: RegionLocator, matcher: Matcher): Promise => + this.#run(() => assertRegion(this.session, locator, matcher)); + capture = ( + options: CaptureOptions, + body: (execution: AsyncExecution) => Promise, + ): Promise => + this.#run(() => + captureOperation(this.session, options, (child) => + call(() => Promise.resolve().then(() => body(child))), + ), + ); +} + +// oxlint-disable-next-line bombshell-dev/max-params -- shared async/operation matcher executor +export function* assertRegion( + session: TerminalSession, + locator: RegionLocator, + matcher: Matcher, +): Operation { + if (locator.extensionId && !session.hasExtension(locator.extensionId)) + throw error('GW_EXTENSION_NOT_REGISTERED', `Locator requires extension ${locator.extensionId}`); + let last: MatchResult | undefined; + try { + return yield* bounded( + awaitMatch(session, locator, (actual) => (last = matcher(actual))), + session.options.assertionTimeoutMs ?? 4000, + 'GW_ASSERTION', + ); + } catch (cause) { + if (cause instanceof GhostwrightError && cause.code === 'GW_ASSERTION') + throw new TerminalAssertionError( + `${locator.source}: ${last ? JSON.stringify(last) : 'no located region'}\n${session.screen.getText()}`, + { cause }, + ); + if (cause instanceof ProcessExitedError || cause instanceof SessionClosedError) { + const ErrorType = + cause instanceof ProcessExitedError ? ProcessExitedError : SessionClosedError; + throw new ErrorType(`${locator.source}: ${cause.message}`, { cause }); + } + throw cause; + } +} + +export function* execution(session: TerminalSession): Operation { + return new AsyncExecution(session, yield* useScope(), yield* useAbortSignal()); +} + +// oxlint-disable-next-line bombshell-dev/max-params -- shared async/operation capture executor +export function* captureOperation( + session: TerminalSession, + options: CaptureOptions, + body: (execution: AsyncExecution) => Operation, +): Operation { + const max = options.maxObservations ?? 1000, + maxBytes = options.maxBytes ?? 64 * 1024 * 1024; + if (!Number.isSafeInteger(max) || max <= 0 || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) + throw new InvalidOptionsError('Capture limits must be positive safe integers'); + return yield* bounded( + scoped(function* () { + const child = yield* execution(session); + const startedAt = performance.now(), + baseline = session.observations.current()!; + const state = options.until.create(startedAt), + observations: Observation[] = []; + state.baseline?.(baseline); + const completion = withResolvers(); + let bytes = 0, + finished = false; + const recording = yield* resource<{ stop(): void }>(function* (provide) { + let timer: ReturnType | undefined; + let off = (): void => {}, + offStatus = (): void => {}; + const stop = (): void => { + finished = true; + off(); + offStatus(); + clearTimeout(timer); + }; + const finish = (): void => { + stop(); + completion.resolve( + Object.freeze({ + baseline, + startedAt, + completedAt: performance.now(), + observations: Object.freeze([...observations]), + }), + ); + }; + const fail = (cause: unknown): void => { + stop(); + completion.reject(cause as Error); + }; + const schedule = (): void => { + clearTimeout(timer); + if (!finished && state.wakeAt !== undefined) + timer = setTimeout( + () => { + try { + if (state.wake?.(performance.now())) finish(); + else schedule(); + } catch (cause) { + fail(cause); + } + }, + Math.max(0, state.wakeAt - performance.now()), + ); + }; + off = session.observations.subscribe((observation) => { + if (finished) return; + try { + bytes += JSON.stringify(observation).length * 2; + if (observations.length === max || bytes > maxBytes) + throw error( + 'GW_CAPTURE_LIMIT', + 'Capture storage limit exceeded; recording is incomplete', + ); + observations.push(observation); + if (state.observe(observation)) finish(); + else schedule(); + } catch (cause) { + fail(cause); + } + }); + offStatus = session.subscribe(() => { + const cause = ended(session); + if (!finished && cause) fail(cause); + }); + schedule(); + try { + yield* provide({ stop }); + } finally { + stop(); + } + }); + try { + const cause = ended(session); + if (cause) throw cause; + const task = yield* spawn(() => body(child)); + const result = yield* completion.operation; + yield* task; + return result; + } finally { + recording.stop(); + } + }), + options.timeoutMs ?? session.options.assertionTimeoutMs ?? 4000, + 'GW_CAPTURE_TIMEOUT', + options.signal, + ); +} + +/** Cancellation waits for bounded acquisition and closes even a late launch. */ +export function useSession(options: TerminalLaunchOptions): Operation { + return resource(function* (provide) { + const launching = TerminalSession.launch(options).then( + (session) => ({ ok: true as const, session }), + (cause) => ({ ok: false as const, cause }), + ); + try { + const result = yield* call(() => launching); + if (!result.ok) throw result.cause; + yield* provide(result.session); + } finally { + const result = yield* call(() => launching); + if (result.ok) yield* call(() => result.session.close()); + } + }); +} diff --git a/experiments/ghostwright/src/index.ts b/experiments/ghostwright/src/index.ts index 8c315e3..51a99a2 100644 --- a/experiments/ghostwright/src/index.ts +++ b/experiments/ghostwright/src/index.ts @@ -1,13 +1,24 @@ export * from './types.ts'; +export * from './observations.ts'; +export * from './inspection.ts'; +export * from './locators.ts'; +export * from './matchers.ts'; +export * from './conditions.ts'; +export { textLocator, type ScreenQueries, type TextQueryOptions } from './queries.ts'; +export type { WaitFor, WaitForOptions } from './wait-for.ts'; +export type { LocatedMouse, MouseTarget, DragOffset } from './mouse.ts'; +export { AsyncExecution, type Capture, type CaptureOptions } from './execution.ts'; +import { AsyncExecution } from './execution.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'; +export { launchTerminal, Terminal, withTerminal } from './async.ts'; +export type { EffectionTerminal } from './effection/index.ts'; +export { replayTrace, type ReplayResult, type ReplayOptions } from './tracing/replay.ts'; import { expectTerminal as expectAsync } from './assertions/index.ts'; import { EffectionLocator, EffectionTerminal, expectOperation } from './effection/index.ts'; -import { Locator, type TerminalSession } from './terminal/session.ts'; +import { InvalidOptionsError } from './errors.ts'; +import { Locator, TerminalSession } from './terminal/session.ts'; import type { AsyncLocator, AsyncLocatorExpectation, @@ -31,13 +42,10 @@ export function expectTerminal( | AsyncLocatorExpectation | OperationTerminalExpectation | AsyncTerminalExpectation { - return ( - target instanceof EffectionLocator || target instanceof EffectionTerminal - ? expectOperation(target) - : expectAsync(target instanceof Locator ? target : (target as unknown as TerminalSession)) - ) as - | OperationLocatorExpectation - | AsyncLocatorExpectation - | OperationTerminalExpectation - | AsyncTerminalExpectation; + if (target instanceof AsyncExecution) return expectAsync(target.session); + if (target instanceof EffectionLocator || target instanceof EffectionTerminal) + return expectOperation(target); + if (target instanceof Locator) return expectAsync(target); + if (target instanceof TerminalSession) return expectAsync(target); + throw new InvalidOptionsError('Expected a Ghostwright terminal or locator'); } diff --git a/experiments/ghostwright/src/inspection.ts b/experiments/ghostwright/src/inspection.ts new file mode 100644 index 0000000..e62a1cd --- /dev/null +++ b/experiments/ghostwright/src/inspection.ts @@ -0,0 +1,115 @@ +import { CoordinateRangeError } from './errors.ts'; +import type { Rect, ScreenCell, ScreenSnapshot } from './types.ts'; + +export type Edge = 'top' | 'bottom' | 'left' | 'right'; +export function intersect(a: Rect, b: Rect): Rect | undefined { + const column = Math.max(a.column, b.column), + row = Math.max(a.row, b.row); + const width = Math.min(a.column + a.width, b.column + b.width) - column; + const height = Math.min(a.row + a.height, b.row + b.height) - row; + return width > 0 && height > 0 ? Object.freeze({ column, row, width, height }) : undefined; +} +export function validateBounds(bounds: Rect): void { + if ( + ![bounds.column, bounds.row, bounds.width, bounds.height].every(Number.isSafeInteger) || + bounds.width < 0 || + bounds.height < 0 + ) + throw new CoordinateRangeError( + 'Region requires integer coordinates and nonnegative dimensions', + ); +} + +/** A view over one immutable snapshot. Bounds remain unclipped; cells are viewport-clipped. */ +export class RegionInspection { + readonly bounds: Readonly; + readonly visibleBounds: Readonly | undefined; + readonly screen: ScreenSnapshot; + constructor(screen: ScreenSnapshot, bounds: Rect) { + this.screen = screen; + validateBounds(bounds); + this.bounds = Object.freeze({ ...bounds }); + this.visibleBounds = intersect(bounds, { + column: 0, + row: 0, + width: screen.viewport.columns, + height: screen.viewport.rows, + }); + Object.freeze(this); + } + cells(): readonly ScreenCell[] { + const r = this.visibleBounds; + return Object.freeze( + r + ? this.screen.lines + .slice(r.row, r.row + r.height) + .flatMap((line) => line.cells.slice(r.column, r.column + r.width)) + : [], + ); + } + text(): string { + const r = this.visibleBounds; + return r + ? this.screen.lines + .slice(r.row, r.row + r.height) + .map((line) => + line.cells + .slice(r.column, r.column + r.width) + .map((cell) => + cell.continuation ? '' : cell.style.invisible ? ' ' : cell.text || ' ', + ) + .join(''), + ) + .join('\n') + : ''; + } + edge(edge: Edge): RegionInspection { + const r = this.bounds; + return new RegionInspection( + this.screen, + edge === 'top' || edge === 'bottom' + ? { + column: r.column, + row: edge === 'top' ? r.row : r.row + r.height - 1, + width: r.width, + height: r.height ? 1 : 0, + } + : { + column: edge === 'left' ? r.column : r.column + r.width - 1, + row: r.row, + width: r.width ? 1 : 0, + height: r.height, + }, + ); + } + cursor(): Readonly { + const cursor = this.screen.cursor, + r = this.visibleBounds; + return Object.freeze({ + ...cursor, + inside: + !!r && + cursor.column >= r.column && + cursor.column < r.column + r.width && + cursor.row >= r.row && + cursor.row < r.row + r.height, + }); + } + /** Region-relative contents. Movement is a separate geometry condition. */ + visualKey(): string { + const cursor = this.cursor(); + return JSON.stringify([ + this.bounds.width, + this.bounds.height, + this.cells().map((c) => [c.text, c.width, c.style]), + cursor.inside && cursor.visible + ? [cursor.column - this.bounds.column, cursor.row - this.bounds.row, cursor.shape] + : null, + ]); + } +} +export function inspect( + screen: ScreenSnapshot, +): Readonly<{ region(bounds: Rect): RegionInspection }> { + return Object.freeze({ region: (bounds: Rect) => new RegionInspection(screen, bounds) }); +} diff --git a/experiments/ghostwright/src/jest.ts b/experiments/ghostwright/src/jest.ts new file mode 100644 index 0000000..fbaf124 --- /dev/null +++ b/experiments/ghostwright/src/jest.ts @@ -0,0 +1,11 @@ +import { expect } from '@jest/globals'; +import { terminalMatchers, type RunnerAssertions } from './runner-matchers.ts'; +import type { builtInMatchers } from './matchers.ts'; + +expect.extend(terminalMatchers); +declare module 'expect' { + interface Matchers, T = unknown> extends RunnerAssertions< + typeof builtInMatchers, + R + > {} +} diff --git a/experiments/ghostwright/src/locators.ts b/experiments/ghostwright/src/locators.ts new file mode 100644 index 0000000..0641cb7 --- /dev/null +++ b/experiments/ghostwright/src/locators.ts @@ -0,0 +1,94 @@ +import { GhostwrightError, InvalidOptionsError } from './errors.ts'; +import { RegionInspection } from './inspection.ts'; +import type { Observation } from './observations.ts'; +import type { Rect, ScreenSnapshot } from './types.ts'; +import type { Matcher } from './matchers.ts'; +import type { Condition } from './conditions.ts'; + +/** Immutable query data and a pure resolver. No session, tasks, or cached geometry. */ +export interface RegionLocator { + readonly source: string; + readonly extensionId?: string; + accepts(observation: Observation): boolean; + resolve(observation: Observation): readonly RegionInspection[]; + nth(index: number): RegionLocator; + /** Resolve children from each parent in the same observation. Return absolute + * terminal bounds; this does not impose containment or clip to the parent. */ + derive(source: string, resolve: (parent: RegionInspection) => readonly Rect[]): RegionLocator; + satisfies(matcher: Matcher): Condition; +} +// oxlint-disable-next-line bombshell-dev/max-params -- immutable identity and pure resolution function +function query( + source: string, + extensionId: string | undefined, + resolve: (observation: Observation) => readonly Rect[], +): RegionLocator { + const locator: RegionLocator = { + source, + extensionId, + accepts: (o) => + extensionId === undefined + ? o.kind === 'screen' + : o.kind !== 'screen' && o.extensionId === extensionId, + resolve(observation) { + if (!locator.accepts(observation)) return []; + if (observation.kind === 'extension-error') throw observation.error; + return Object.freeze( + resolve(observation).map((bounds) => new RegionInspection(observation.screen, bounds)), + ); + }, + nth(index) { + if (!Number.isSafeInteger(index) || index < 0) + throw new InvalidOptionsError('Locator index must be nonnegative'); + return query(`${source}.nth(${index})`, extensionId, (o) => { + const bounds = resolve(o)[index]; + return bounds ? [bounds] : []; + }); + }, + derive(childSource, resolveChild) { + return query(`${source} >> ${childSource}`, extensionId, (observation) => + locator.resolve(observation).flatMap((parent) => resolveChild(parent)), + ); + }, + satisfies(matcher) { + return Object.freeze({ + create: () => ({ + observe: (o: Observation) => { + if (!locator.accepts(o)) return false; + const regions = locator.resolve(o); + if (regions.length > 1) + throw new GhostwrightError({ + code: 'GW_LOCATOR_STRICT', + message: `${source} matched ${regions.length} regions`, + }); + return regions.length === 1 && matcher(regions[0]!).pass; + }, + }), + }); + }, + }; + return Object.freeze(locator); +} +// oxlint-disable-next-line bombshell-dev/max-params -- immutable identity and pure resolution function +export function defineLocator( + extensionId: string, + source: string, + resolve: (description: T) => readonly Rect[], +): RegionLocator { + return query(source, extensionId, (o) => + o.kind === 'extension' ? resolve(o.description as T) : [], + ); +} +/** Resolve regions from terminal evidence alone, without an OSC description. */ +export function defineScreenLocator( + source: string, + resolve: (screen: ScreenSnapshot) => readonly Rect[], +): RegionLocator { + return query(source, undefined, (observation) => resolve(observation.screen)); +} + +/** Fixed coordinates are an explicit alternative to semantic location. */ +export function regionLocator(bounds: Rect): RegionLocator { + const copy = Object.freeze({ ...bounds }); + return defineScreenLocator(JSON.stringify(copy), () => [copy]); +} diff --git a/experiments/ghostwright/src/matchers.ts b/experiments/ghostwright/src/matchers.ts new file mode 100644 index 0000000..1b8a22b --- /dev/null +++ b/experiments/ghostwright/src/matchers.ts @@ -0,0 +1,168 @@ +import { InvalidOptionsError } from './errors.ts'; +import type { Operation } from 'effection'; +import { cellsMatchStyle } from './styles.ts'; +import type { Edge, RegionInspection } from './inspection.ts'; +import type { RegionLocator } from './locators.ts'; +import type { StyleQuery } from './types.ts'; + +export interface MatchResult { + readonly pass: boolean; + readonly expected: string; + readonly actual: unknown; + readonly details?: readonly MatchResult[]; +} +export type Matcher = (actual: RegionInspection) => MatchResult; +export const textContains = + (text: string): Matcher => + (actual) => ({ + pass: actual.text().includes(text), + expected: `text containing ${JSON.stringify(text)}`, + actual: actual.text(), + }); +export const cursorInside = + (options: { visible?: boolean } = { visible: true }): Matcher => + (actual) => { + const cursor = actual.cursor(); + return { + pass: cursor.inside && (options.visible === undefined || cursor.visible === options.visible), + expected: `cursor inside region${options.visible === undefined ? '' : `, visible=${options.visible}`}`, + actual: cursor, + }; + }; +export const edgeHasStyle = + (edge: Edge, style: StyleQuery): Matcher => + (actual) => { + const region = actual.edge(edge), + cells = region.cells(); + const complete = + cells.length === region.bounds.width * region.bounds.height && cells.length > 0; + return { + pass: complete && cellsMatchStyle(cells, style), + expected: `${edge} edge style ${JSON.stringify(style)}`, + actual: cells.map((cell) => cell.style), + }; + }; +export const textHasStyle = + (text: string, style: StyleQuery): Matcher => + (actual) => { + const r = actual.visibleBounds; + let pass = false; + if (r && text.length) + for (const line of actual.screen.lines.slice(r.row, r.row + r.height)) { + const cells = line.cells + .slice(r.column, r.column + r.width) + .filter((cell) => !cell.continuation); + const parts = cells.map((cell) => (cell.style.invisible ? ' ' : cell.text || ' ')); + const row = parts.join(''); + let at = row.indexOf(text); + while (at !== -1) { + let offset = 0; + const matched = cells.filter((_, index) => { + const start = offset; + offset += parts[index]!.length; + return start < at + text.length && offset > at; + }); + if (matched.length && cellsMatchStyle(matched, style)) pass = true; + at = row.indexOf(text, at + text.length); + } + } + return { + pass, + expected: `${JSON.stringify(text)} with style ${JSON.stringify(style)}`, + actual: actual.text(), + }; + }; +export const all = + (...matchers: readonly Matcher[]): Matcher => + (actual) => { + const details = matchers.map((matcher) => matcher(actual)); + return { + pass: details.every((result) => result.pass), + expected: details.map((result) => result.expected).join(' and '), + actual: actual.text(), + details, + }; + }; + +// Contravariant constraint for heterogeneous argument tuples. A definition is +// callable only after its own tuple has been inferred by bindMatcher. +export type MatcherDefinitions = Record< + string, + (actual: RegionInspection, ...args: never[]) => MatchResult +>; +export function defineMatchers(matchers: M): Readonly { + return Object.freeze({ ...matchers }); +} +export const builtInMatchers = defineMatchers({ + toBeVisible: (actual: RegionInspection): MatchResult => ({ + pass: !!actual.visibleBounds && actual.cells().some((cell) => !cell.style.invisible), + expected: 'a region with visible cells in the viewport', + actual: actual.visibleBounds ?? null, + }), + toContainText: (actual: RegionInspection, text: string) => textContains(text)(actual), + toContainCursor: (actual: RegionInspection, options?: { visible?: boolean }) => + cursorInside(options)(actual), + toHaveEdgeStyle: (actual: RegionInspection, edge: Edge, style: StyleQuery) => + edgeHasStyle(edge, style)(actual), + toHaveTextStyle: (actual: RegionInspection, text: string, style: StyleQuery) => + textHasStyle(text, style)(actual), + toSatisfy: (actual: RegionInspection, matcher: Matcher) => matcher(actual), +}); +export interface AssertionExecutor { + assert(locator: RegionLocator, matcher: Matcher): Promise; +} +type Args = F extends (actual: RegionInspection, ...args: infer A) => MatchResult ? A : never; +export type Expectations = { + [K in keyof M]: (...args: Args) => Promise; +}; +export type OperationExpectations = { + [K in keyof M]: (...args: Args) => Operation; +}; +export interface OperationAssertionExecutor { + assert(locator: RegionLocator, matcher: Matcher): Operation; +} +export interface ExpectFactory { + (executor: AssertionExecutor, locator: RegionLocator): Expectations; + operation(executor: OperationAssertionExecutor, locator: RegionLocator): OperationExpectations; + extend(matchers: N): ExpectFactory; +} +function bindMatcher( + definition: (actual: RegionInspection, ...args: Arguments) => MatchResult, + assert: (matcher: Matcher) => Result, +): (...args: Arguments) => Result { + return (...args) => assert((actual) => definition(actual, ...args)); +} + +function factory(definitions: M): ExpectFactory { + const expect = (executor: AssertionExecutor, locator: RegionLocator): Expectations => + Object.fromEntries( + Object.entries(definitions).map(([name, matcher]) => [ + name, + bindMatcher(matcher, (assertion) => executor.assert(locator, assertion)), + ]), + ) as unknown as Expectations; // Object.fromEntries erases each method's argument tuple. + return Object.freeze( + Object.assign(expect, { + operation( + executor: OperationAssertionExecutor, + locator: RegionLocator, + ): OperationExpectations { + return Object.fromEntries( + Object.entries(definitions).map(([name, matcher]) => [ + name, + bindMatcher(matcher, (assertion) => executor.assert(locator, assertion)), + ]), + ) as unknown as OperationExpectations; + }, + extend(next: N): ExpectFactory { + for (const name of Object.keys(next)) + if (name in definitions) + throw new InvalidOptionsError(`Matcher already registered: ${name}`); + return factory({ ...definitions, ...next }); + }, + }), + ); +} +export function createExpect(): ExpectFactory { + return factory(builtInMatchers); +} diff --git a/experiments/ghostwright/src/mouse.ts b/experiments/ghostwright/src/mouse.ts new file mode 100644 index 0000000..2633fd0 --- /dev/null +++ b/experiments/ghostwright/src/mouse.ts @@ -0,0 +1,63 @@ +import type { RegionLocator } from './locators.ts'; +import type { ActionReceipt, AsyncTerminal, MouseOptions, Point } from './types.ts'; +import type { RegionInspection } from './inspection.ts'; +import type { Matcher } from './matchers.ts'; + +export type MouseTarget = Point | RegionLocator; +export interface DragOffset { + readonly by: { readonly columns: number; readonly rows: number }; +} +export interface LocatedMouse { + move(target: MouseTarget, options?: MouseOptions): Promise; + hover(target: MouseTarget, options?: MouseOptions): Promise; + down(target: MouseTarget, options?: MouseOptions): Promise; + up(target: MouseTarget, options?: MouseOptions): Promise; + click(target: MouseTarget, options?: MouseOptions): Promise; + doubleClick(target: MouseTarget, options?: MouseOptions): Promise; + drag( + start: MouseTarget, + destination: Point | DragOffset, + options?: MouseOptions, + ): Promise; + wheel: AsyncTerminal['mouse']['wheel']; +} + +/** Resolve a recipe once per action. Input encoding remains owned by the terminal. */ +export function locatedMouse( + mouse: AsyncTerminal['mouse'], + assert: (locator: RegionLocator, matcher: Matcher) => Promise, +): LocatedMouse { + const point = async (target: MouseTarget): Promise => { + if (!('resolve' in target)) return target; + const region = await assert(target, (actual) => ({ + pass: !!actual.visibleBounds, + expected: 'on-screen region', + actual: actual.bounds, + })); + const bounds = region.visibleBounds!; + return { + column: bounds.column + Math.floor((bounds.width - 1) / 2), + row: bounds.row + Math.floor((bounds.height - 1) / 2), + }; + }; + const move = async (target: MouseTarget, options?: MouseOptions): Promise => + mouse.move(await point(target), options); + return Object.freeze({ + move, + hover: move, + down: async (target, options) => mouse.down(await point(target), options), + up: async (target, options) => mouse.up(await point(target), options), + click: async (target, options) => mouse.click(await point(target), options), + doubleClick: async (target, options) => mouse.doubleClick(await point(target), options), + drag: async (target, destination, options) => { + const start = await point(target); + const end = + 'by' in destination + ? { column: start.column + destination.by.columns, row: start.row + destination.by.rows } + : destination; + // Resolve the start once; never chase the moving divider during a drag. + return mouse.drag(start, end, options); + }, + wheel: mouse.wheel, + }); +} diff --git a/experiments/ghostwright/src/observations.ts b/experiments/ghostwright/src/observations.ts new file mode 100644 index 0000000..1810211 --- /dev/null +++ b/experiments/ghostwright/src/observations.ts @@ -0,0 +1,131 @@ +import { GhostwrightError } from './errors.ts'; +import type { ScreenSnapshot } from './types.ts'; + +export interface ScreenObservation { + readonly kind: 'screen'; + readonly sequence: number; + readonly timestamp: number; + readonly screen: ScreenSnapshot; +} +export interface DescribedObservation { + readonly kind: 'extension'; + readonly sequence: number; + readonly timestamp: number; + readonly screen: ScreenSnapshot; + readonly extensionId: string; + readonly protocolFrame: number; + readonly description: T; +} +export interface InvalidObservation { + readonly kind: 'extension-error'; + readonly sequence: number; + readonly timestamp: number; + readonly screen: ScreenSnapshot; + readonly extensionId: string; + readonly error: Error; +} +export type Observation = ScreenObservation | DescribedObservation | InvalidObservation; + +/** Descriptions cross an ownership boundary here. Never retain mutable producer state. */ +export function immutable(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + if (ArrayBuffer.isView(value)) + throw new GhostwrightError({ + code: 'GW_EXTENSION_DATA', + message: 'Descriptions must contain immutable data, not typed arrays', + }); + Object.freeze(value); + for (const child of Object.values(value)) immutable(child); + } + return value; +} + +/** Session-owned ordering. Active captures own their retention, independently of screen history. */ +export class Observations { + #sequence = 0; + #latest: Observation; + #extensions = new Map(); + #listeners = new Set<(observation: Observation) => void>(); + constructor(screen: ScreenSnapshot) { + this.#latest = Object.freeze({ + kind: 'screen', + sequence: 0, + timestamp: performance.now(), + screen, + }); + } + current(extensionId?: string): Observation | undefined { + if (extensionId === undefined) return this.#latest; + const paired = this.#extensions.get(extensionId); + return paired?.screen.sequence === this.#latest.screen.sequence ? paired : undefined; + } + /** Read current cells independently of whether the latest commit also describes them. */ + currentScreen(): ScreenObservation { + const latest = this.#latest; + return latest.kind === 'screen' + ? latest + : Object.freeze({ + kind: 'screen', + sequence: latest.sequence, + timestamp: latest.timestamp, + screen: latest.screen, + }); + } + get sequence(): number { + return this.#sequence; + } + subscribe(listener: (observation: Observation) => void): () => void { + this.#listeners.add(listener); + return () => { + this.#listeners.delete(listener); + }; + } + screen(screen: ScreenSnapshot): ScreenObservation { + const observation: ScreenObservation = Object.freeze({ + kind: 'screen', + sequence: ++this.#sequence, + timestamp: performance.now(), + screen, + }); + this.#publish(observation); + return observation; + } + // oxlint-disable-next-line bombshell-dev/max-params -- publication fixes protocol identity and paired evidence + describe( + extensionId: string, + protocolFrame: number, + description: T, + screen: ScreenSnapshot, + ): DescribedObservation { + const observation: DescribedObservation = Object.freeze({ + kind: 'extension', + sequence: ++this.#sequence, + timestamp: performance.now(), + extensionId, + protocolFrame, + description: immutable(structuredClone(description)), + screen, + }); + this.#extensions.set(extensionId, observation); + this.#publish(observation); + return observation; + } + // oxlint-disable-next-line bombshell-dev/max-params -- invalid evidence still retains its identity and screen + invalid(extensionId: string, error: Error, screen: ScreenSnapshot): void { + const observation: InvalidObservation = Object.freeze({ + kind: 'extension-error', + sequence: ++this.#sequence, + timestamp: performance.now(), + extensionId, + error, + screen, + }); + this.#extensions.set(extensionId, observation); + this.#publish(observation); + } + #publish(observation: Observation): void { + this.#latest = observation; + // oxlint-disable-next-line unicorn/no-useless-spread -- listeners may subscribe or unsubscribe while dispatching + for (const listener of [...this.#listeners]) listener(observation); + } +} diff --git a/experiments/ghostwright/src/profile.ts b/experiments/ghostwright/src/profile.ts index 9174bc5..dcf0a8e 100644 --- a/experiments/ghostwright/src/profile.ts +++ b/experiments/ghostwright/src/profile.ts @@ -5,6 +5,7 @@ import { dirname, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ReservedEnvironmentError, + CoordinateRangeError, UnsupportedPlatformError, AssetIntegrityError, DenoPermissionError, @@ -34,15 +35,21 @@ function versionAtLeast(actual: string, required: readonly [number, number]): bo const [major = 0, minor = 0] = actual.replace(/^v/, '').split('.').map(Number); return major > required[0] || (major === required[0] && minor >= required[1]); } +/** Read runtime identity from the Node-compatible API all supported runtimes provide. */ +export function currentRuntime(): { name: 'node' | 'bun' | 'deno'; version: string } { + if (process.versions.deno) return { name: 'deno', version: process.versions.deno }; + if (process.versions.bun) return { name: 'bun', version: process.versions.bun }; + return { name: 'node', version: process.version }; +} + /** Assert the current runtime meets Ghostwright minimum version requirements. */ export function assertSupportedRuntime(): void { - const deno = (globalThis as unknown as { Deno?: { version: { deno: string } } }).Deno, - bun = (globalThis as unknown as { Bun?: { version: string } }).Bun; - if (deno && !versionAtLeast(deno.version.deno, [2, 2])) - throw new LaunchError(`Ghostwright requires Deno 2.2 or newer; found ${deno.version.deno}`); - if (bun && !versionAtLeast(bun.version, [1, 2])) - throw new LaunchError(`Ghostwright requires Bun 1.2 or newer; found ${bun.version}`); - if (!deno && !bun && !versionAtLeast(process.versions.node, [22, 0])) + const runtime = currentRuntime(); + if (runtime.name === 'deno' && !versionAtLeast(runtime.version, [2, 2])) + throw new LaunchError(`Ghostwright requires Deno 2.2 or newer; found ${runtime.version}`); + if (runtime.name === 'bun' && !versionAtLeast(runtime.version, [1, 2])) + throw new LaunchError(`Ghostwright requires Bun 1.2 or newer; found ${runtime.version}`); + if (runtime.name === 'node' && !versionAtLeast(runtime.version, [22, 0])) throw new LaunchError(`Ghostwright requires Node 22 or newer; found ${process.versions.node}`); } /** Normalize a partial viewport to required dimensions with defaults. */ @@ -75,21 +82,25 @@ export function normalizeViewport(input?: Viewport): Required { export function profileEnvironment( explicit: Readonly> | undefined, terminfo: string, -) { +): Record { const bad = RESERVED_ENVIRONMENT.filter((k) => Object.hasOwn(explicit ?? {}, k)); if (bad.length) throw new ReservedEnvironmentError( `Terminal profile variables cannot be overridden: ${bad.join(', ')}`, ); + const inherited: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (value !== undefined) inherited[key] = value; + } return { - ...process.env, + ...inherited, ...explicit, TERM: 'xterm-ghostty', TERMINFO: terminfo, COLORTERM: 'truecolor', TERM_PROGRAM: 'ghostwright', TERM_PROGRAM_VERSION: PACKAGE_VERSION, - } as Record; + }; } /** Return the platform key for the current or specified OS/arch. */ export function target(os = process.platform, arch = process.arch): string { diff --git a/experiments/ghostwright/src/pty/client.ts b/experiments/ghostwright/src/pty/client.ts index 13c0e61..0fc1d93 100644 --- a/experiments/ghostwright/src/pty/client.ts +++ b/experiments/ghostwright/src/pty/client.ts @@ -3,9 +3,11 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; import { dirname } from 'node:path'; import { DenoPermissionError, + GhostwrightError, HostCommandTimeoutError, ProtocolError, SidecarExitedError, + WriteInterruptedError, } from '../errors.ts'; import { decodeCbor, @@ -50,11 +52,14 @@ export class SidecarClient { #closed = false; #applicationPid?: number; #applicationPgid?: number; + #exited: Promise; private constructor( child: ChildProcessWithoutNullStreams, readonly commandTimeoutMs: number, ) { this.#child = child; + this.#exited = new Promise((resolve) => child.once('close', () => resolve())); + child.stdin.on('error', (error) => this.#fail(error)); child.stdout.on('data', (b: Buffer) => { try { for (const f of this.#decoder.push(b)) this.#frame(f); @@ -131,8 +136,12 @@ export class SidecarClient { clearTimeout(p.timer); this.#pending.delete(f.correlation); if (f.kind === FrameKind.ERROR) { - const d = decodeCbor(f.payload) as { code: string; message: string }; - p.reject(new ProtocolError(`${d.code}: ${d.message}`)); + const d = decodeCbor(f.payload) as { code: string; message: string; bytesWritten?: number }; + p.reject( + d.code === 'GW_WRITE_INTERRUPTED' && d.bytesWritten !== undefined + ? new WriteInterruptedError(d.bytesWritten, d.message) + : new GhostwrightError({ code: d.code, message: d.message }), + ); } else { const response = f.payload.length ? decodeCbor(f.payload) : {}; p.resolve( @@ -149,6 +158,16 @@ export class SidecarClient { } this.#pending.clear(); this.#emit('fatal', error); + // A killed sidecar cannot run Rust Drop. Own this last-resort cleanup here. + if ( + this.#applicationPgid && + this.#applicationPgid === this.#applicationPid && + this.#applicationPgid > 1 + ) { + try { + process.kill(-this.#applicationPgid, 'SIGKILL'); + } catch {} + } this.#child.kill('SIGKILL'); } // oxlint-disable-next-line bombshell-dev/max-params -- request needs kind, value, raw flag, and timeout @@ -212,8 +231,20 @@ export class SidecarClient { this.#applicationPgid = result.processGroupId; return result; } - write(data: Uint8Array): Promise { - return this.request(FrameKind.WRITE, data, true); + async write(data: Uint8Array, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const sequence = this.#sequence; + const response = this.request(FrameKind.WRITE, data, true); + const abort = (): void => { + void this.request(FrameKind.CANCEL_WRITE, { sequence }).catch(() => undefined); + }; + signal?.addEventListener('abort', abort, { once: true }); + if (signal?.aborted) abort(); + try { + return await response; + } finally { + signal?.removeEventListener('abort', abort); + } } resize(value: unknown): Promise { return this.request(FrameKind.RESIZE, value); @@ -228,6 +259,12 @@ export class SidecarClient { } finally { this.#closed = true; this.#child.stdin.end(); + const timer = setTimeout(() => this.#child.kill('SIGKILL'), timeout ?? this.commandTimeoutMs); + try { + await this.#exited; + } finally { + clearTimeout(timer); + } } } forceKill(): void { diff --git a/experiments/ghostwright/src/pty/protocol.ts b/experiments/ghostwright/src/pty/protocol.ts index b61d9bd..e89e900 100644 --- a/experiments/ghostwright/src/pty/protocol.ts +++ b/experiments/ghostwright/src/pty/protocol.ts @@ -10,6 +10,7 @@ export const enum FrameKind { RESIZE = 0x0004, SIGNAL = 0x0005, CLOSE = 0x0006, + CANCEL_WRITE = 0x0007, READY = 0x8001, SPAWNED = 0x8002, ACK = 0x8003, @@ -77,7 +78,7 @@ const bad = (): ProtocolError => new ProtocolError('Truncated CBOR payload'); /** Decode a CBOR binary payload to a JavaScript value. */ export function decodeCbor(bytes: Uint8Array): unknown { let p = 0; - const readLen = (ai: number) => { + const readLen = (ai: number): number => { if (ai < 24) return ai; if (ai === 24) { if (p + 1 > bytes.length) throw bad(); @@ -176,7 +177,7 @@ export function encodeFrame(frame: Frame): Uint8Array { return out; } export class FrameDecoder { - #buffer = new Uint8Array(); + #buffer: Uint8Array = new Uint8Array(); #last = 0; push(chunk: Uint8Array): Frame[] { this.#buffer = concat([this.#buffer, chunk]); diff --git a/experiments/ghostwright/src/queries.ts b/experiments/ghostwright/src/queries.ts new file mode 100644 index 0000000..2e06b2d --- /dev/null +++ b/experiments/ghostwright/src/queries.ts @@ -0,0 +1,114 @@ +import { GhostwrightError, InvalidOptionsError, StrictLocatorError } from './errors.ts'; +import { defineScreenLocator, type RegionLocator } from './locators.ts'; +import type { Observation } from './observations.ts'; +import type { RegionInspection } from './inspection.ts'; +import type { TextLocatorOptions } from './types.ts'; +import type { WaitFor, WaitForOptions } from './wait-for.ts'; +import { findText } from './terminal/text.ts'; + +/** A pure text recipe. UTF-16 string offsets never become terminal coordinates. */ +export function textLocator(text: string, options: TextLocatorOptions = {}): RegionLocator { + if (!text.length) throw new InvalidOptionsError('Text queries require nonempty text'); + const settings = structuredClone({ exact: options.exact, style: options.style }); + return defineScreenLocator(`text ${JSON.stringify(text)}`, (screen) => + findText(screen, text, settings).map((match) => match.range), + ); +} +export interface QueryContext { + current(locator: RegionLocator): Observation | undefined; + waitFor: WaitFor; + selector?: (source: string) => RegionLocator; +} +export type TextQueryOptions = TextLocatorOptions & WaitForOptions; + +/** Every query family resolves through the same cardinality and waiting rules. */ +export function createQueries(context: QueryContext): ScreenQueries { + const queryAllBy = (locator: RegionLocator): readonly RegionInspection[] => { + const observation = context.current(locator); + if (!observation) + throw new GhostwrightError({ + code: 'GW_QUERY_UNAVAILABLE', + message: `No current observation for ${locator.source}; wait for a description paired with the current screen`, + }); + return Object.freeze([...locator.resolve(observation)]); + }; + const queryBy = (locator: RegionLocator): RegionInspection | null => { + const matches = queryAllBy(locator); + if (matches.length > 1) + throw new StrictLocatorError( + `${locator.source} matched ${matches.length} regions: ${JSON.stringify(matches.map((match) => match.bounds))}`, + ); + return matches[0] ?? null; + }; + const missing = (locator: RegionLocator): never => { + throw new GhostwrightError({ + code: 'GW_QUERY_MISSING', + message: `No region matched ${locator.source}`, + }); + }; + const getBy = (locator: RegionLocator): RegionInspection => queryBy(locator) ?? missing(locator); + const getAllBy = (locator: RegionLocator): readonly RegionInspection[] => { + const matches = queryAllBy(locator); + return matches.length ? matches : missing(locator); + }; + const findBy = (locator: RegionLocator, options?: WaitForOptions): Promise => + context.waitFor(() => getBy(locator), options); + const findAllBy = ( + locator: RegionLocator, + options?: WaitForOptions, + ): Promise => context.waitFor(() => getAllBy(locator), options); + const selector = (source: string): RegionLocator => { + if (!context.selector) + throw new InvalidOptionsError( + 'Selector queries require a selector adapter in launch options', + ); + return context.selector(source); + }; + return Object.freeze({ + getBy, + queryBy, + findBy, + getAllBy, + queryAllBy, + findAllBy, + getByText: (text: string, options?: TextLocatorOptions) => getBy(textLocator(text, options)), + queryByText: (text: string, options?: TextLocatorOptions) => + queryBy(textLocator(text, options)), + getAllByText: (text: string, options?: TextLocatorOptions) => + getAllBy(textLocator(text, options)), + queryAllByText: (text: string, options?: TextLocatorOptions) => + queryAllBy(textLocator(text, options)), + findByText: async (text: string, options?: TextQueryOptions) => + findBy(textLocator(text, options), options), + findAllByText: async (text: string, options?: TextQueryOptions) => + findAllBy(textLocator(text, options), options), + getBySelector: (source: string) => getBy(selector(source)), + queryBySelector: (source: string) => queryBy(selector(source)), + getAllBySelector: (source: string) => getAllBy(selector(source)), + queryAllBySelector: (source: string) => queryAllBy(selector(source)), + findBySelector: async (source: string, options?: WaitForOptions) => + findBy(selector(source), options), + findAllBySelector: async (source: string, options?: WaitForOptions) => + findAllBy(selector(source), options), + }); +} +export interface ScreenQueries { + getBy(locator: RegionLocator): RegionInspection; + queryBy(locator: RegionLocator): RegionInspection | null; + getAllBy(locator: RegionLocator): readonly RegionInspection[]; + queryAllBy(locator: RegionLocator): readonly RegionInspection[]; + findBy(locator: RegionLocator, options?: WaitForOptions): Promise; + findAllBy(locator: RegionLocator, options?: WaitForOptions): Promise; + getByText(text: string, options?: TextLocatorOptions): RegionInspection; + queryByText(text: string, options?: TextLocatorOptions): RegionInspection | null; + getAllByText(text: string, options?: TextLocatorOptions): readonly RegionInspection[]; + queryAllByText(text: string, options?: TextLocatorOptions): readonly RegionInspection[]; + findByText(text: string, options?: TextQueryOptions): Promise; + findAllByText(text: string, options?: TextQueryOptions): Promise; + getBySelector(source: string): RegionInspection; + queryBySelector(source: string): RegionInspection | null; + getAllBySelector(source: string): readonly RegionInspection[]; + queryAllBySelector(source: string): readonly RegionInspection[]; + findBySelector(source: string, options?: WaitForOptions): Promise; + findAllBySelector(source: string, options?: WaitForOptions): Promise; +} diff --git a/experiments/ghostwright/src/runner-matchers.ts b/experiments/ghostwright/src/runner-matchers.ts new file mode 100644 index 0000000..23a6cd3 --- /dev/null +++ b/experiments/ghostwright/src/runner-matchers.ts @@ -0,0 +1,54 @@ +import { RegionInspection } from './inspection.ts'; +import { InvalidOptionsError } from './errors.ts'; +import { builtInMatchers, type MatcherDefinitions, type MatchResult } from './matchers.ts'; + +export interface RunnerMatcherResult { + pass: boolean; + message(): string; +} +type Arguments = F extends (region: RegionInspection, ...args: infer A) => MatchResult + ? A + : never; +export type RunnerAssertions = { + [K in keyof M]: (...args: Arguments) => R; +}; +export type RunnerMatchers = { + [K in keyof M]: ( + this: { isNot?: boolean }, + actual: unknown, + ...args: Arguments + ) => RunnerMatcherResult; +}; + +function bind( + name: string, + definition: (actual: RegionInspection, ...args: Args) => MatchResult, +): (this: { isNot?: boolean }, actual: unknown, ...args: Args) => RunnerMatcherResult { + return function (actual, ...args) { + if (actual === null && name === 'toBeVisible') { + return { pass: false, message: () => 'Expected a visible region, received null' }; + } + if (!(actual instanceof RegionInspection)) + throw new InvalidOptionsError( + 'Terminal matchers require frozen region evidence from screen.getBy/findBy, not a locator recipe', + ); + const result = definition(actual, ...args); + const negated = this.isNot ? 'not ' : ''; + return { + pass: result.pass, + message: () => + `Expected ${negated}${result.expected}\nRegion: ${JSON.stringify(actual.bounds)}\nReceived: ${JSON.stringify(result.actual)}`, + }; + }; +} + +/** Adapt local pure matchers to expect.extend without adding retries or a global registry. */ +export function createRunnerMatchers( + definitions: M, +): RunnerMatchers { + // Dynamic enumeration erases the association between each method and its tuple. + return Object.fromEntries( + Object.entries(definitions).map(([name, definition]) => [name, bind(name, definition)]), + ) as unknown as RunnerMatchers; +} +export const terminalMatchers = createRunnerMatchers(builtInMatchers); diff --git a/experiments/ghostwright/src/terminal/extensions.ts b/experiments/ghostwright/src/terminal/extensions.ts index 8471592..0d909b1 100644 --- a/experiments/ghostwright/src/terminal/extensions.ts +++ b/experiments/ghostwright/src/terminal/extensions.ts @@ -9,7 +9,7 @@ export interface OscEvent { export type OscStreamItem = | { kind: 'ordinary'; bytes: Uint8Array } | { kind: 'event'; event: OscEvent } - | { kind: 'error'; error: Error }; + | { kind: 'error'; error: Error; registration: OscRegistration }; export interface OscStreamResult { items: readonly OscStreamItem[]; @@ -28,6 +28,7 @@ function bytes(parts: readonly number[]): Uint8Array { export class RegisteredOscStream { #state: 'normal' | 'escape' | 'osc' | 'discarding' = 'normal'; #candidate: number[] = []; + #registration?: OscRegistration; #discardPreviousEscape = false; constructor(readonly registrations: readonly OscRegistration[]) {} @@ -35,11 +36,11 @@ export class RegisteredOscStream { push(input: Uint8Array): OscStreamResult { const items: OscStreamItem[] = []; let ordinary: number[] = []; - const flush = () => { + const flush = (): void => { if (ordinary.length) items.push({ kind: 'ordinary', bytes: bytes(ordinary) }); ordinary = []; }; - const releaseCandidate = () => { + const releaseCandidate = (): void => { ordinary.push(...this.#candidate); this.#candidate = []; this.#state = 'normal'; @@ -71,37 +72,42 @@ export class RegisteredOscStream { } 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 (!this.#registration) { + const prefix = Buffer.from(this.#candidate).toString('latin1'); + this.#registration = this.registrations.find( + (entry) => prefix === `\u001b]${entry.number};${entry.namespace};`, + ); + if (!this.#registration) { + if ( + !this.registrations.some((entry) => + `\u001b]${entry.number};${entry.namespace};`.startsWith(prefix), + ) + ) + releaseCandidate(); + continue; + } } - if (!registration) continue; - if (this.#candidate.length > registration.maxBufferedBytes) { + const registration = this.#registration; + const length = this.#candidate.length; + const st = length >= 2 && this.#candidate[length - 2] === 0x1b && byte === 0x5c; + const bel = byte === 0x07; + if (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', + registration, error: new ExtensionOscLimitError( `Registered OSC ${registration.number};${registration.namespace} exceeded ${registration.maxBufferedBytes} buffered bytes`, ), }); this.#candidate = []; - this.#state = 'discarding'; - this.#discardPreviousEscape = false; + this.#registration = undefined; + this.#state = st || bel ? 'normal' : 'discarding'; + this.#discardPreviousEscape = byte === 0x1b; 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( @@ -126,6 +132,7 @@ export class RegisteredOscStream { }, }); this.#candidate = []; + this.#registration = undefined; this.#state = 'normal'; } flush(); diff --git a/experiments/ghostwright/src/terminal/output.ts b/experiments/ghostwright/src/terminal/output.ts new file mode 100644 index 0000000..07597c7 --- /dev/null +++ b/experiments/ghostwright/src/terminal/output.ts @@ -0,0 +1,66 @@ +import { ExtensionDuplicateError, GhostwrightError } from '../errors.ts'; +import { Observations } from '../observations.ts'; +import type { ScreenSnapshot, TerminalExtensionDefinition } from '../types.ts'; +import { RegisteredOscStream } from './extensions.ts'; + +/** The shared live/replay boundary. Descriptions never write terminal cells. */ +export class TerminalOutput { + readonly observations: Observations; + #osc: RegisteredOscStream; + #frames = new Map(); + readonly extensions: readonly TerminalExtensionDefinition[]; + private readonly current: () => ScreenSnapshot; + private readonly ordinary: (bytes: Uint8Array) => ScreenSnapshot; + private readonly diagnostic: (error: Error) => void; + // oxlint-disable-next-line bombshell-dev/max-params -- internal live/replay wiring + constructor( + extensions: readonly TerminalExtensionDefinition[], + current: () => ScreenSnapshot, + ordinary: (bytes: Uint8Array) => ScreenSnapshot, + diagnostic: (error: Error) => void = () => {}, + ) { + this.extensions = extensions; + this.current = current; + this.ordinary = ordinary; + this.diagnostic = diagnostic; + const ids = new Set(extensions.map((extension) => extension.id)); + const registrations = new Set( + extensions.map((extension) => `${extension.osc.number};${extension.osc.namespace}`), + ); + if (ids.size !== extensions.length || registrations.size !== extensions.length) + throw new ExtensionDuplicateError('Duplicate extension id or OSC registration'); + this.#osc = new RegisteredOscStream(extensions.map((extension) => extension.osc)); + this.observations = new Observations(current()); + } + push(bytes: Uint8Array): void { + for (const item of this.#osc.push(bytes).items) { + if (item.kind === 'ordinary') { + this.observations.screen(this.ordinary(item.bytes)); + continue; + } + const registration = item.kind === 'event' ? item.event.registration : item.registration; + const extension = this.extensions.find((candidate) => candidate.osc === registration)!; + try { + if (item.kind === 'error') throw item.error; + const commit = extension.osc.decode(item.event.message); + const previous = this.#frames.get(extension.id) ?? 0; + if (!Number.isSafeInteger(commit.protocolFrame) || commit.protocolFrame !== previous + 1) + throw new GhostwrightError({ + code: 'GW_EXTENSION_FRAME', + message: `${extension.id}: frame ${commit.protocolFrame} does not follow ${previous}`, + }); + this.#frames.set(extension.id, commit.protocolFrame); + this.observations.describe( + extension.id, + commit.protocolFrame, + commit.value, + this.current(), + ); + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + this.observations.invalid(extension.id, error, this.current()); + this.diagnostic(error); + } + } + } +} diff --git a/experiments/ghostwright/src/terminal/session.ts b/experiments/ghostwright/src/terminal/session.ts index ff54463..e2a1137 100644 --- a/experiments/ghostwright/src/terminal/session.ts +++ b/experiments/ghostwright/src/terminal/session.ts @@ -3,12 +3,12 @@ import { resolve } from 'node:path'; import { CoordinateRangeError, DenoPermissionError, - ExtensionDuplicateError, GhostwrightError, HistoryChangedError, HistoryEvictedError, LaunchError, ProcessExitedError, + WriteInterruptedError, ReservedEnvironmentError, SessionClosedError, StrictLocatorError, @@ -24,7 +24,6 @@ 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, @@ -48,24 +47,24 @@ import type { RevisionCollection, RevisionCollectionOptions, RevisionRangeQuery, - ScreenCell, ScreenReader, ScreenRevision, ScreenSnapshot, TerminalLaunchOptions, TextLocatorOptions, - TerminalExtensionDefinition, - ExtensionCommit, - ExtensionRevision, - ExtensionSessionContext, - RegisteredOscMessage, TraceableInputOptions, Viewport, WheelOptions, } from '../types.ts'; -import { RegisteredOscStream } from './extensions.ts'; +import { TerminalOutput } from './output.ts'; +import type { Observations } from '../observations.ts'; import { GhosttyWasmTerminal } from './wasm.ts'; -function concatBytes(parts: readonly Uint8Array[]) { +import { findText } from './text.ts'; +type ControlCommand = + | { kind: FrameKind.RESIZE; value: Required } + | { kind: FrameKind.SIGNAL; value: { signal: string; target: 'child' | 'process-group' } }; + +function concatBytes(parts: readonly Uint8Array[]): Uint8Array { const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); let offset = 0; for (const part of parts) { @@ -74,7 +73,7 @@ function concatBytes(parts: readonly Uint8Array[]) { } return result; } -function visualKey(s: ScreenSnapshot) { +function visualKey(s: ScreenSnapshot): string { return JSON.stringify([ s.lines.map((l) => l.cells.map((c) => [c.text, c.style])), s.cursor, @@ -84,15 +83,17 @@ function visualKey(s: ScreenSnapshot) { s.graphics.placements.filter((placement) => placement.viewport.visible), ]); } -function observableKey(s: ScreenSnapshot) { +function observableKey(s: ScreenSnapshot): string { return JSON.stringify([visualKey(s), s.graphics, s.modes, s.title, s.workingDirectory]); } export class TerminalSession implements AsyncTerminal { #host!: SidecarClient; + observations!: Observations; #engine!: GhosttyWasmTerminal; #snapshot!: ScreenSnapshot; #status: ProcessStatus = { state: 'starting', ptyEof: false }; #closed = false; + #closePromise?: Promise; #action = 0; #revision = 0; #history: ScreenRevision[] = []; @@ -104,47 +105,17 @@ export class TerminalSession implements AsyncTerminal { #listeners = new Set<() => void>(); #outputPump: Promise = Promise.resolve(); #commands: Promise = Promise.resolve(); + #queuedCommands = 0; #lastAction?: ActionReceipt; #mouseDown = false; #trace: SessionTrace; #viewport; #fatalError?: Error; - #extensions = new Map< - string, - { - definition: TerminalExtensionDefinition; - session: unknown; - revisions: ExtensionRevision[]; - sequence: number; - } - >(); - #osc?: RegisteredOscStream; + #outputStream!: TerminalOutput; + #sourceFrameSequence = 0; #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' @@ -157,7 +128,7 @@ export class TerminalSession implements AsyncTerminal { this.#trace = new SessionTrace({ options, policy: t, directory: dir }); this.#exitPromise = new Promise((r) => (this.#exitResolve = r)); } - static async launch(options: TerminalLaunchOptions) { + static async launch(options: TerminalLaunchOptions): Promise { assertSupportedRuntime(); if (!options.command || options.command.includes('\0')) throw new GhostwrightError({ @@ -210,7 +181,31 @@ export class TerminalSession implements AsyncTerminal { options.graphics?.storageLimitBytes ?? 64 * 1024 * 1024, ); self.#snapshot = self.#engine.snapshot(); - self.#initializeExtensions(); + try { + self.#outputStream = new TerminalOutput( + options.extensions ?? [], + () => self.#snapshot, + (bytes) => { + self.#engine.write(bytes); + self.#terminalHistoryGeneration++; + self.#publish('pty-output', self.#sourceFrameSequence); + return self.#snapshot; + }, + (error) => self.#trace.add('extension-diagnostic', { message: error.message }), + ); + } catch (error) { + self.#engine.free(); + throw error; + } + self.observations = self.#outputStream.observations; + self.observations.subscribe((observation) => { + self.#trace.add('observation', { + observation: observation.sequence, + kind: observation.kind, + screenSequence: observation.screen.sequence, + }); + self.#notify(); + }); self.#trace.add('kitty-capability', { supported: self.#snapshot.graphics.supported, storageLimitBytes: self.#snapshot.graphics.storageLimitBytes, @@ -288,130 +283,36 @@ export class TerminalSession implements AsyncTerminal { self.#trace.add('spawned', { pid: spawned.pid, processGroupId: spawned.processGroupId }); return self; } - get trace() { + get trace(): SessionTrace { return this.#trace; } - get revisionHistory() { + get revisionHistory(): ScreenRevision[] { return this.#history; } - get lastAction() { + get lastAction(): ActionReceipt | undefined { 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), - }); - }, - }); + hasExtension(id: string): boolean { + return (this.options.extensions ?? []).some((extension) => extension.id === id); } - #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() { + now(): number { return this.#engine.now(); } - #notify() { + #notify(): void { for (const f of this.#listeners) f(); } subscribe(f: () => void) { this.#listeners.add(f); return () => this.#listeners.delete(f); } - async #output(bytes: Uint8Array, sourceFrameSequence: number) { + async #output(bytes: Uint8Array, sourceFrameSequence: number): Promise { if (this.#closed) return; this.#trace.output(bytes, sourceFrameSequence); 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(); - 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), - }); - } - } + this.#sourceFrameSequence = sourceFrameSequence; + this.#outputStream.push(bytes); for (const effect of this.#engine.takeEffects()) { this.#trace.add('terminal-effect', { effect: effect.type, @@ -419,16 +320,32 @@ export class TerminalSession implements AsyncTerminal { }); if (effect.type === 'write-pty') { this.#trace.input(effect.data, 0, false); - await this.#command(() => this.#host.write(effect.data)); + // Parsing output must not wait for a child that has stopped reading replies. + void this.#command(() => this.#host.write(effect.data)).catch((error) => { + this.#fatalError = error; + this.#status = { ...this.#status, state: 'failed' }; + this.#notify(); + void this.close().catch(() => undefined); + }); } } } #command(operation: () => Promise): Promise { - const result = this.#commands.then(operation); - this.#commands = result; + if (this.#queuedCommands >= 1024) + return Promise.reject( + new GhostwrightError({ + code: 'GW_BACKPRESSURE', + message: 'Terminal command queue limit exceeded', + }), + ); + this.#queuedCommands++; + const result = this.#commands.then(operation).finally(() => { + this.#queuedCommands--; + }); + this.#commands = result.catch(() => undefined); return result; } - #publish(cause: 'pty-output' | 'resize' | 'reset', sourceFrameSequence?: number) { + #publish(cause: 'pty-output' | 'resize' | 'reset', sourceFrameSequence?: number): void { const decoded = this.#engine.snapshot(cause), lines = decoded.lines.map((line, index) => JSON.stringify(line) === JSON.stringify(this.#snapshot.lines[index]) @@ -490,47 +407,50 @@ export class TerminalSession implements AsyncTerminal { this.#notify(); } #ensure(op: string): void { - if (this.#closed) throw new SessionClosedError(`Cannot ${op}: terminal session is closed`); + if (this.#closed || this.#closePromise) + throw new SessionClosedError(`Cannot ${op}: terminal session is closed`); } - // oxlint-disable-next-line bombshell-dev/max-params -- internal method - async #send( - kind: FrameKind, - value: unknown, - raw = false, - delivered = true, - ): Promise { + async #send(command: ControlCommand, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); this.#ensure('perform action'); const before = this.#revision, sequence = ++this.#action; - let ack: { bytesWritten?: number }; - if (kind === FrameKind.WRITE) - ack = await this.#command(() => this.#host.write(value as Uint8Array)); - else if (kind === FrameKind.RESIZE) ack = await this.#command(() => this.#host.resize(value)); - else if (kind === FrameKind.SIGNAL) ack = await this.#command(() => this.#host.signal(value)); - else - throw new GhostwrightError({ code: 'GW_UNSUPPORTED_ACTION', message: 'Unsupported action' }); + const ack = await this.#command(() => { + signal?.throwIfAborted(); + if (command.kind === FrameKind.RESIZE) { + // Resize our terminal before notifying the child. Its repaint can + // arrive before the PTY acknowledgement reaches the caller. + this.#viewport = command.value; + this.#trace.add('resize', { viewport: command.value }); + this.#engine.resize(command.value); + this.#terminalHistoryGeneration++; + this.#publish('resize'); + this.observations.screen(this.#snapshot); + return this.#host.resize(command.value); + } + return this.#host.signal(command.value); + }); const receipt: Readonly = Object.freeze({ actionSequence: sequence, screenSequenceBefore: before, acknowledgedAt: this.#engine.now(), - deliveredToChild: delivered, bytesWritten: ack.bytesWritten ?? 0, }); - this.#lastAction = receipt as ActionReceipt; + this.#lastAction = receipt; this.#trace.add('action', { actionSequence: sequence, - kind, + kind: command.kind, bytesWritten: ack.bytesWritten ?? 0, - ...(kind === FrameKind.RESIZE ? { viewport: value } : {}), }); - return receipt as ActionReceipt; + return receipt; } // oxlint-disable-next-line bombshell-dev/max-params -- internal method async #write( data: Uint8Array, - delivered = data.length > 0, traceMode: 'record' | 'redact' = 'record', - ) { + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); this.#ensure('write input'); const before = this.#revision, sequence = ++this.#action; @@ -538,14 +458,25 @@ export class TerminalSession implements AsyncTerminal { this.#trace.input(data, sequence, traceMode === 'redact'); for (let offset = 0; offset < data.length; offset += 65_536) { const chunk = data.slice(offset, offset + 65_536); - const ack = await this.#command(() => this.#host.write(chunk)); - total += ack.bytesWritten ?? 0; + try { + const ack = await this.#command(() => { + if (signal?.aborted) + throw new WriteInterruptedError(0, 'Input cancelled before the next chunk', { + cause: signal.reason, + }); + return this.#host.write(chunk, signal); + }); + total += ack.bytesWritten ?? 0; + } catch (cause) { + if (cause instanceof WriteInterruptedError) + throw new WriteInterruptedError(total + cause.bytesWritten, cause.message, { cause }); + throw cause; // Transport failure cannot prove the current chunk's delivery. + } } const receipt = Object.freeze({ actionSequence: sequence, screenSequenceBefore: before, acknowledgedAt: this.#engine.now(), - deliveredToChild: delivered, bytesWritten: total, }); this.#lastAction = receipt; @@ -556,23 +487,29 @@ export class TerminalSession implements AsyncTerminal { }); return receipt; } - keyboard = { - 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))), - true, - options?.trace ?? 'record', - ), - paste: async (text: string, options?: TraceableInputOptions) => - this.#write(this.#engine.encodePaste(text), true, options?.trace ?? 'record'), - focus: async (state: 'in' | 'out') => { - const b = this.#engine.encodeFocus(state); - return this.#write(b); - }, - write: async (data: Uint8Array) => this.#write(data), - }; - #point(p: Point) { + keyboardFor(signal?: AbortSignal): AsyncTerminal['keyboard'] { + return { + press: async (key: KeyName | KeyPress) => { + signal?.throwIfAborted(); + return this.#write(this.#engine.encodeKey(parseKey(key)), 'record', signal); + }, + type: async (text: string, options?: TraceableInputOptions) => + this.#write( + concatBytes(Array.from(text, (key) => this.#engine.encodeKey(key))), + options?.trace ?? 'record', + signal, + ), + paste: async (text: string, options?: TraceableInputOptions) => + this.#write(this.#engine.encodePaste(text), options?.trace ?? 'record', signal), + focus: async (state: 'in' | 'out') => { + const b = this.#engine.encodeFocus(state); + return this.#write(b, 'record', signal); + }, + write: async (data: Uint8Array) => this.#write(data, 'record', signal), + }; + } + keyboard = this.keyboardFor(); + #point(p: Point): void { if ( !Number.isInteger(p.column) || !Number.isInteger(p.row) || @@ -586,7 +523,19 @@ export class TerminalSession implements AsyncTerminal { ); } // oxlint-disable-next-line bombshell-dev/max-params -- internal method - #mouse(action: 'move' | 'down' | 'up', p: Point, o: MouseOptions = {}): Promise { + #mouse( + action: 'move' | 'down' | 'up', + p: Point, + o: MouseOptions = {}, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + this.#ensure('perform mouse action'); + if ('super' in o && o.super) + throw new GhostwrightError({ + code: 'GW_UNSUPPORTED_MODIFIER', + message: 'Terminal mouse reports cannot encode Super/Command', + }); this.#point(p); const wasDown = this.#mouseDown; if (action === 'down') this.#mouseDown = true; @@ -597,45 +546,65 @@ export class TerminalSession implements AsyncTerminal { o, action === 'up' ? wasDown : this.#mouseDown, ); - return this.#write(bytes, bytes.length > 0); + return this.#write(bytes, 'record', signal); + } + mouseFor(signal?: AbortSignal): AsyncTerminal['mouse'] { + const mouse = { + move: (p: Point, o?: MouseOptions) => this.#mouse('move', p, o, signal), + down: (p: Point, o?: MouseOptions) => this.#mouse('down', p, o, signal), + up: (p: Point, o?: MouseOptions) => this.#mouse('up', p, o, signal), + click: async (p: Point, o?: MouseOptions) => { + await this.#mouse('down', p, o, signal); + return this.#mouse('up', p, o, signal); + }, + doubleClick: async (p: Point, o?: MouseOptions) => { + await mouse.click(p, o); + return mouse.click(p, o); + }, + // oxlint-disable-next-line bombshell-dev/max-params -- wraps mouse API + drag: async (a: Point, b: Point, o?: MouseOptions) => { + this.#point(a); + this.#point(b); + await this.#mouse('down', a, o, signal); + await this.#mouse('move', b, o, signal); + return this.#mouse('up', b, o, signal); + }, + wheel: (o: WheelOptions) => { + this.#point(o); + if (!Number.isInteger(o.deltaRows) || !Number.isInteger(o.deltaColumns ?? 0)) + throw new CoordinateRangeError('Wheel deltas must be integers'); + const parts: Uint8Array[] = []; + for (let index = 0; index < Math.abs(o.deltaRows); index++) + parts.push( + this.#engine.encodeMouse('down', o, { button: o.deltaRows < 0 ? 4 : 5 }, false), + ); + for (let index = 0; index < Math.abs(o.deltaColumns ?? 0); index++) + parts.push( + this.#engine.encodeMouse( + 'down', + o, + { button: (o.deltaColumns ?? 0) < 0 ? 6 : 7 }, + false, + ), + ); + const bytes = concatBytes(parts); + return this.#write(bytes, 'record', signal); + }, + }; + return mouse; + } + mouse = this.mouseFor(); + signalProcess( + signal: string, + target: 'child' | 'process-group' = 'process-group', + abort?: AbortSignal, + ): Promise { + return this.#send({ kind: FrameKind.SIGNAL, value: { signal, target } }, abort); } - mouse = { - move: (p: Point, o?: MouseOptions) => this.#mouse('move', p, o), - down: (p: Point, o?: MouseOptions) => this.#mouse('down', p, o), - up: (p: Point, o?: MouseOptions) => this.#mouse('up', p, o), - click: async (p: Point, o?: MouseOptions) => { - await this.#mouse('down', p, o); - return this.#mouse('up', p, o); - }, - doubleClick: async (p: Point, o?: MouseOptions) => { - await this.mouse.click(p, o); - return this.mouse.click(p, o); - }, - // oxlint-disable-next-line bombshell-dev/max-params -- wraps mouse API - drag: async (a: Point, b: Point, o?: MouseOptions) => { - await this.#mouse('down', a, o); - await this.#mouse('move', b, o); - return this.#mouse('up', b, o); - }, - wheel: (o: WheelOptions) => { - this.#point(o); - if (!Number.isInteger(o.deltaRows) || !Number.isInteger(o.deltaColumns ?? 0)) - throw new CoordinateRangeError('Wheel deltas must be integers'); - const parts: Uint8Array[] = []; - for (let index = 0; index < Math.abs(o.deltaRows); index++) - parts.push(this.#engine.encodeMouse('down', o, { button: o.deltaRows < 0 ? 4 : 5 }, false)); - for (let index = 0; index < Math.abs(o.deltaColumns ?? 0); index++) - parts.push( - this.#engine.encodeMouse('down', o, { button: (o.deltaColumns ?? 0) < 0 ? 6 : 7 }, false), - ); - const bytes = concatBytes(parts); - return this.#write(bytes, bytes.length > 0); - }, - }; process = { status: () => ({ ...this.#status }), signal: (signal: string, target: 'child' | 'process-group' = 'process-group') => - this.#send(FrameKind.SIGNAL, { signal, target }), + this.#send({ kind: FrameKind.SIGNAL, value: { signal, target } }), waitForExit: async (options?: { timeoutMs?: number }) => this.#timeout( this.#exitPromise, @@ -660,7 +629,7 @@ export class TerminalSession implements AsyncTerminal { return this.#engine.copyImageData(id); }, }; - getByText(text: string, options?: TextLocatorOptions) { + getByText(text: string, options?: TextLocatorOptions): Locator { return new Locator(this, text, options); } region(rect: Rect): AsyncRegion { @@ -670,31 +639,27 @@ export class TerminalSession implements AsyncTerminal { snapshot: () => this.#snapshot, }; } - validateRegion(r: Rect) { + validateRegion(r: Rect): void { this.#rect(r); } - #rect(r: Rect) { + #rect(r: Rect): void { if (!Number.isInteger(r.width) || !Number.isInteger(r.height) || r.width <= 0 || r.height <= 0) this.#point({ column: -1, row: -1 }); this.#point(r); this.#point({ column: r.column + r.width - 1, row: r.row + r.height - 1 }); } - async resize(v: Viewport) { - const viewport = normalizeViewport(v); - const receipt = await this.#send(FrameKind.RESIZE, viewport); - this.#viewport = viewport; - this.#engine.resize(viewport); - this.#terminalHistoryGeneration++; - this.#publish('resize'); - return receipt; + resize(v: Viewport, signal?: AbortSignal): Promise { + return this.#send({ kind: FrameKind.RESIZE, value: normalizeViewport(v) }, signal); + } + close(): Promise { + return (this.#closePromise ??= this.#close()); } - async close() { + async #close(): Promise { if (this.#closed) return Object.freeze({ actionSequence: ++this.#action, screenSequenceBefore: this.#revision, acknowledgedAt: this.#engine.now(), - deliveredToChild: false, bytesWritten: 0, }); const before = this.#revision, @@ -708,14 +673,14 @@ export class TerminalSession implements AsyncTerminal { (c.postExitDrainMs ?? 1000) + 1000, ); + // CLOSE must overtake blocked writes; the host reports their partial delivery. + await this.#host.close(timeout); await this.#outputPump; await this.#commands; - await this.#host.close(timeout); const receipt = Object.freeze({ actionSequence: sequence, screenSequenceBefore: before, acknowledgedAt: this.#engine.now(), - deliveredToChild: true, bytesWritten: 0, }); this.#lastAction = receipt; @@ -729,7 +694,7 @@ export class TerminalSession implements AsyncTerminal { this.#notify(); } } - async waitForChange(test: () => boolean, timeout: number) { + async waitForChange(test: () => boolean, timeout: number): Promise { if (test()) return; await new Promise((resolvePromise, reject) => { const off = this.subscribe(() => { @@ -829,14 +794,15 @@ export class TerminalSession implements AsyncTerminal { if (!Number.isSafeInteger(timeout) || timeout < 0) throw new CoordinateRangeError('timeoutMs must be nonnegative'); const samples = [...this.revisionsSince(baseline)].slice(0, max); - const complete = () => samples.some((revision) => options.until(revision.snapshot, revision)); + const complete = (): boolean => + samples.some((revision) => options.until(revision.snapshot, revision)); if (!complete() && samples.length === max) throw new HistoryEvictedError( `Revision collection reached its ${max} sample limit before its predicate matched`, ); if (!complete()) await new Promise((resolvePromise, reject) => { - const finish = (timer: ReturnType, error?: Error) => { + const finish = (timer: ReturnType, error?: Error): void => { clearTimeout(timer); off(); if (error) reject(error); @@ -1070,74 +1036,14 @@ export class Locator implements AsyncLocator { return new Locator(this.session, this.query, this.options, this.index, rect); } matches(): readonly LocatorMatch[] { - const s = this.session.screen.current(), - out: LocatorMatch[] = []; - for (const line of s.lines) { - if ( - this.bounds && - (line.row < this.bounds.row || line.row >= this.bounds.row + this.bounds.height) - ) - continue; - const start = this.bounds?.column ?? 0, - end = this.bounds ? this.bounds.column + this.bounds.width : s.viewport.columns, - segments: Array<{ start: number; end: number; cell: ScreenCell }> = []; - let row = ''; - for (const cell of line.cells.slice(start, end)) { - if (cell.continuation) continue; - const text = cell.style.invisible ? ' ' : cell.text || ' ', - offset = row.length; - row += text; - segments.push({ start: offset, end: row.length, cell }); - } - const rangeFor = (from: number, to: number): Rect => { - const first = segments.find((segment) => from < segment.end) ?? segments.at(-1), - last = [...segments].toReversed().find((segment) => to > segment.start) ?? first, - column = first?.cell.column ?? start, - 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) { - 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) { - 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); - } - } - } + const out = findText(this.session.screen.current(), this.query, this.options, this.bounds); const chosen = this.index === undefined ? out : out[this.index] ? [out[this.index]] : []; return Object.freeze(chosen); } - async unique(timeout = this.session.options.assertionTimeoutMs ?? DEFAULT_ASSERTION_TIMEOUT_MS) { - const get = () => this.matches(); + async unique( + timeout = this.session.options.assertionTimeoutMs ?? DEFAULT_ASSERTION_TIMEOUT_MS, + ): Promise { + const get = (): readonly LocatorMatch[] => this.matches(); let m = get(); if (m.length > 1) throw new StrictLocatorError( @@ -1155,7 +1061,7 @@ export class Locator implements AsyncLocator { } return m[0]; } - async click(options?: MouseOptions) { + async click(options?: MouseOptions): Promise { const m = await this.unique(), p = { column: Math.floor((m.range.column + m.range.column + m.range.width - 1) / 2), diff --git a/experiments/ghostwright/src/terminal/text.ts b/experiments/ghostwright/src/terminal/text.ts new file mode 100644 index 0000000..163e7f9 --- /dev/null +++ b/experiments/ghostwright/src/terminal/text.ts @@ -0,0 +1,60 @@ +import type { + LocatorMatch, + Rect, + ScreenCell, + ScreenSnapshot, + TextLocatorOptions, +} from '../types.ts'; +import { cellsMatchStyle } from '../styles.ts'; + +/** Match rendered text once. Both query APIs use these cell-aware ranges. */ +// oxlint-disable-next-line bombshell-dev/max-params -- immutable screen, search, style, and optional bounds +export function findText( + screen: ScreenSnapshot, + text: string, + options: TextLocatorOptions = {}, + bounds?: Rect, +): readonly LocatorMatch[] { + const matches: LocatorMatch[] = []; + for (const line of screen.lines) { + if (bounds && (line.row < bounds.row || line.row >= bounds.row + bounds.height)) continue; + const start = bounds?.column ?? 0; + const end = bounds ? bounds.column + bounds.width : screen.viewport.columns; + const segments: { start: number; end: number; cell: ScreenCell }[] = []; + let row = ''; + for (const cell of line.cells.slice(start, end)) { + if (cell.continuation) continue; + const part = cell.style.invisible ? ' ' : cell.text || ' '; + const offset = row.length; + row += part; + segments.push({ start: offset, end: row.length, cell }); + } + const add = (from: number, to: number): void => { + const cells = segments + .filter((segment) => from < segment.end && to > segment.start) + .map((segment) => segment.cell); + if (options.style && !cellsMatchStyle(cells, options.style)) return; + const first = cells[0]; + const last = cells.at(-1) ?? first; + const column = first?.column ?? start; + const right = last ? last.column + Math.max(1, last.width) : column + 1; + matches.push({ + text: row.slice(from, to), + rowText: row, + range: { column, row: line.row, width: Math.max(1, right - column), height: 1 }, + cells: Object.freeze(cells), + }); + }; + if (options.exact) { + const trimmed = row.replace(/ +$/g, ''); + if (trimmed === text) add(0, trimmed.length); + } else { + let at = 0; + while (text.length && (at = row.indexOf(text, at)) >= 0) { + add(at, at + text.length); + at += text.length; + } + } + } + return Object.freeze(matches); +} diff --git a/experiments/ghostwright/src/terminal/wasm.ts b/experiments/ghostwright/src/terminal/wasm.ts index 084d493..9c755fd 100644 --- a/experiments/ghostwright/src/terminal/wasm.ts +++ b/experiments/ghostwright/src/terminal/wasm.ts @@ -18,8 +18,9 @@ import type { } from '../types.ts'; import { AssetIntegrityError, GhostwrightError } from '../errors.ts'; -type Fn = (...args: any[]) => number; -type Exports = Record & { +// This VT ABI passes numeric pointers and scalar values, never JS objects. +type Fn = (...args: (number | bigint)[]) => number; +type Exports = Record<`ghostty_${string}`, Fn> & { memory: WebAssembly.Memory; __indirect_function_table: WebAssembly.Table; }; @@ -44,12 +45,12 @@ function unsignedLeb(value: number): number[] { return bytes; } function callbackModule(parameterCount: number, returnsInt = false): WebAssembly.Module { - const section = (id: number, payload: number[]) => [ + const section = (id: number, payload: number[]): number[] => [ id, ...unsignedLeb(payload.length), ...payload, ], - name = (value: string) => { + name = (value: string): number[] => { const bytes = [...encoder.encode(value)]; return [...unsignedLeb(bytes.length), ...bytes]; }, @@ -104,7 +105,7 @@ const defaultStyle: CellStyle = Object.freeze({ background: Object.freeze({ kind: 'default' as const }), }); let compiled: Promise | undefined; -async function moduleFor(url: URL) { +async function moduleFor(url: URL): Promise { return (compiled ??= WebAssembly.compile(await readFile(url))); } function freeze(value: T): T { @@ -150,7 +151,10 @@ export class GhosttyWasmTerminal { private constructor(viewport: Required) { this.#viewport = viewport; } - static async create(viewport: Required, storageLimitBytes = 64 * 1024 * 1024) { + static async create( + viewport: Required, + storageLimitBytes = 64 * 1024 * 1024, + ): Promise { const self = new GhosttyWasmTerminal(viewport); const url = new URL( import.meta.url.includes('/dist/') @@ -165,34 +169,36 @@ export class GhosttyWasmTerminal { throw new AssetIntegrityError(`Unable to load ${url.pathname}`, { cause }); } const instance = await WebAssembly.instantiate(mod, { env: { log() {} } }); - self.#e = instance.exports as unknown as Exports; + // The pinned, hash-checked module supplies this C ABI. WebAssembly's + // standard typings do not describe individual exported signatures. + self.#e = instance.exports as Exports; self.#initialize(storageLimitBytes); return self; } - #view() { + #view(): DataView { return new DataView(this.#e.memory.buffer); } - #bytes() { + #bytes(): Uint8Array { return new Uint8Array(this.#e.memory.buffer); } - #opaque() { + #opaque(): number { const p = this.#e.ghostty_wasm_alloc_opaque(); if (!p) throw new AssetIntegrityError('WASM allocation failed'); this.#opaqueAllocations.push(p); return p; } - #alloc(n: number) { + #alloc(n: number): number { const p = this.#e.ghostty_wasm_alloc_u8_array(n); if (!p) throw new AssetIntegrityError('WASM allocation failed'); this.#allocations.push({ pointer: p, size: n }); return p; } - #release(pointer: number, size: number) { + #release(pointer: number, size: number): void { this.#e.ghostty_wasm_free_u8_array(pointer, size); const index = this.#allocations.findIndex((item) => item.pointer === pointer); if (index >= 0) this.#allocations.splice(index, 1); } - #readTypeLayouts() { + #readTypeLayouts(): LayoutMap { const pointer = this.#e.ghostty_type_json(); const bytes = this.#bytes(); let end = pointer; @@ -203,7 +209,7 @@ export class GhosttyWasmTerminal { throw new AssetIntegrityError('Unable to decode libghostty-vt ABI metadata', { cause }); } } - #initialize(storageLimitBytes: number) { + #initialize(storageLimitBytes: number): void { this.#layouts = this.#readTypeLayouts(); const terminalLayout = this.#layouts.GhosttyTerminalOptions; if (!terminalLayout || terminalLayout.size !== 8) @@ -300,17 +306,17 @@ export class GhosttyWasmTerminal { this.#configureEffects(); this.#lastVisual = this.now(); } - now() { + now(): number { return performance.now() - this.#started; } - write(data: Uint8Array) { + write(data: Uint8Array): void { if (!data.length) return; const p = this.#alloc(data.length); this.#bytes().set(data, p); this.#e.ghostty_terminal_vt_write(this.#terminal, p, data.length); this.#e.ghostty_wasm_free_u8_array(p, data.length); } - resize(v: Required) { + resize(v: Required): void { this.#viewport = v; if (this.#e.ghostty_terminal_resize(this.#terminal, v.columns, v.rows, 10, 20) !== 0) throw new GhostwrightError({ @@ -325,7 +331,7 @@ export class GhosttyWasmTerminal { parameterCount: number, callback: (...args: number[]) => number | void, returnsInt = false, - ) { + ): void { const instance = new WebAssembly.Instance(callbackModule(parameterCount, returnsInt), { env: { callback }, }), @@ -338,7 +344,7 @@ export class GhosttyWasmTerminal { if (this.#e.ghostty_terminal_set(this.#terminal, option, index) !== 0) throw new AssetIntegrityError(`Unable to configure Ghostty terminal effect ${option}`); } - #configureEffects() { + #configureEffects(): void { // oxlint-disable-next-line bombshell-dev/max-params -- ghostty write-pty callback API this.#installCallback(1, 4, (_terminal, _userdata, data, length) => { this.#effects.push({ type: 'write-pty', data: this.#bytes().slice(data, data + length) }); @@ -366,7 +372,7 @@ export class GhosttyWasmTerminal { // oxlint-disable-next-line bombshell-dev/max-params -- ghostty size report callback API (_terminal, _userdata, output) => { const layout = this.#layouts.GhosttySizeReportSize, - field = (name: string) => layout.fields[name].offset, + field = (name: string): number => layout.fields[name].offset, view = this.#view(); view.setUint16(output + field('rows'), this.#viewport.rows, true); view.setUint16(output + field('columns'), this.#viewport.columns, true); @@ -379,8 +385,9 @@ export class GhosttyWasmTerminal { this.#installCallback( 7, 3, - // oxlint-disable-next-line bombshell-dev/max-params -- ghostty terminal mode query callback API - (_terminal, _userdata, _output) => { + // oxlint-disable-next-line bombshell-dev/max-params -- ghostty color scheme callback API + (_terminal, _userdata, output) => { + this.#view().setInt32(output, 1, true); // GHOSTTY_COLOR_SCHEME_DARK return 1; }, true, @@ -433,19 +440,19 @@ export class GhosttyWasmTerminal { true, ); } - takeEffects() { + takeEffects(): TerminalEffect[] { return this.#effects.splice(0); } - clipboard() { + clipboard(): string { return this.#clipboard; } - #configureMouseSize() { + #configureMouseSize(): void { if (!this.#mouseEncoder) return; const layout = this.#layouts.GhosttyMouseEncoderSize; if (!layout) throw new AssetIntegrityError('Missing GhosttyMouseEncoderSize ABI metadata'); const pointer = this.#alloc(layout.size), view = this.#view(), - field = (name: string) => layout.fields[name].offset; + field = (name: string): number => layout.fields[name].offset; view.setUint32(pointer + field('size'), layout.size, true); view.setUint32(pointer + field('screen_width'), this.#viewport.widthPixels, true); view.setUint32(pointer + field('screen_height'), this.#viewport.heightPixels, true); @@ -456,7 +463,7 @@ export class GhosttyWasmTerminal { this.#e.ghostty_mouse_encoder_setopt(this.#mouseEncoder, 2, pointer); this.#release(pointer, layout.size); } - #get(kind: number, size = 4) { + #get(kind: number, size = 4): number { const p = this.#alloc(size); try { this.#e.ghostty_terminal_get(this.#terminal, kind, p); @@ -469,7 +476,7 @@ export class GhosttyWasmTerminal { this.#release(p, size); } } - mode(n: number) { + mode(n: number): boolean { const p = this.#alloc(1); try { return ( @@ -480,7 +487,7 @@ export class GhosttyWasmTerminal { this.#release(p, 1); } } - text() { + text(): string { const lp = this.#alloc(4); let p = 0, n = 0; @@ -533,7 +540,7 @@ export class GhosttyWasmTerminal { privateModes, }; } - #renderGet(kind: number, size = 4) { + #renderGet(kind: number, size = 4): number { const pointer = this.#alloc(size); try { if (this.#e.ghostty_render_state_get(this.#renderState, kind, pointer) !== 0) return 0; @@ -564,7 +571,7 @@ export class GhosttyWasmTerminal { underlineColor: this.#color(stylePointer, 'underline_color'), }); } - #terminalString(kind: number) { + #terminalString(kind: number): string { const layout = this.#layouts.GhosttyString, pointer = this.#alloc(layout.size); try { @@ -577,7 +584,10 @@ export class GhosttyWasmTerminal { this.#release(pointer, layout.size); } } - #color(stylePointer: number, fieldName: 'fg_color' | 'bg_color' | 'underline_color') { + #color( + stylePointer: number, + fieldName: 'fg_color' | 'bg_color' | 'underline_color', + ): CellStyle['foreground'] { const style = this.#layouts.GhosttyStyle, color = this.#layouts.GhosttyStyleColor, base = stylePointer + style.fields[fieldName].offset, @@ -593,7 +603,7 @@ export class GhosttyWasmTerminal { }; return { kind: 'default' as const }; } - scrollbackRows() { + scrollbackRows(): number { return this.#get(15); } /** Copies a bounded oldest-based scrollback range without moving Ghostty's viewport. */ @@ -721,7 +731,7 @@ export class GhosttyWasmTerminal { const image = this.#e.ghostty_kitty_graphics_image(graphics, id); if (!image) return undefined; const output = this.#alloc(8); - const getU32 = (kind: number) => { + const getU32 = (kind: number): number => { if (this.#e.ghostty_kitty_graphics_image_get(image, kind, output) !== 0) return 0; return this.#view().getUint32(output, true); }; @@ -762,7 +772,7 @@ export class GhosttyWasmTerminal { this.#release(output, 8); } } - #pruneKittyImages() { + #pruneKittyImages(): void { for (const key of this.#images.keys()) if (!this.#currentImageKeys.has(key)) this.#images.delete(key); } @@ -791,7 +801,7 @@ export class GhosttyWasmTerminal { if (this.#e.ghostty_kitty_graphics_get(graphics, 1, iteratorOutput) !== 0) throw new AssetIntegrityError('Unable to initialize Kitty placement iterator'); while (this.#e.ghostty_kitty_graphics_placement_next(iterator)) { - const get = (kind: number, signed = false) => { + const get = (kind: number, signed = false): number => { if (this.#e.ghostty_kitty_graphics_placement_get(iterator, kind, value) !== 0) throw new AssetIntegrityError(`Unable to read Kitty placement field ${kind}`); return signed @@ -904,7 +914,7 @@ export class GhosttyWasmTerminal { this.#release(graphicsOutput, 4); } } - inspectImage(id: number) { + inspectImage(id: number): KittyImageSnapshot | undefined { const graphics = this.#alloc(4); try { if (!this.#kittySupported || this.#e.ghostty_terminal_get(this.#terminal, 30, graphics) !== 0) @@ -920,7 +930,7 @@ export class GhosttyWasmTerminal { this.#release(graphics, 4); } } - copyImageData(id: number) { + copyImageData(id: number): Uint8Array | undefined { const graphics = this.#alloc(4); try { if (!this.#kittySupported || this.#e.ghostty_terminal_get(this.#terminal, 30, graphics) !== 0) @@ -949,12 +959,12 @@ export class GhosttyWasmTerminal { this.#release(graphics, 4); } } - cachedImage(id: number) { + cachedImage(id: number): KittyImageSnapshot | undefined { return [...this.#images.entries()].find( ([key, image]) => image.id === id && this.#currentImageKeys.has(key), )?.[1]; } - snapshot(cause?: 'pty-output' | 'resize' | 'reset') { + snapshot(cause?: 'pty-output' | 'resize' | 'reset'): ScreenSnapshot { const pointLayout = this.#layouts.GhosttyPoint, coordinateLayout = this.#layouts.GhosttyPointCoordinate, refLayout = this.#layouts.GhosttyGridRef, @@ -1215,7 +1225,7 @@ export class GhosttyWasmTerminal { ...(workingDirectory ? { workingDirectory } : {}), } satisfies ScreenSnapshot); } - encodeKey(input: KeyName | KeyPress) { + encodeKey(input: KeyName | KeyPress): Uint8Array { const event = typeof input === 'string' ? { key: input } : input, name = event.key, functional: Record = FUNCTIONAL_KEYS; @@ -1277,7 +1287,7 @@ export class GhosttyWasmTerminal { point: Point, options: MouseOptions = {}, anyButtonPressed = false, - ) { + ): Uint8Array { this.#e.ghostty_mouse_encoder_setopt_from_terminal(this.#mouseEncoder, this.#terminal); this.#configureMouseSize(); this.#e.ghostty_mouse_event_set_action( @@ -1301,7 +1311,6 @@ export class GhosttyWasmTerminal { if (options.shift) modifiers |= 1; if (options.control) modifiers |= 2; if (options.alt) modifiers |= 4; - if (options.super) modifiers |= 8; this.#e.ghostty_mouse_event_set_mods(this.#mouseEvent, modifiers); const positionLayout = this.#layouts.GhosttyMousePosition, position = this.#alloc(positionLayout.size), @@ -1336,7 +1345,7 @@ export class GhosttyWasmTerminal { this.#release(length, 4); } } - encodePaste(text: string) { + encodePaste(text: string): Uint8Array { const data = encoder.encode(text), p = this.#alloc(data.length || 1), lp = this.#alloc(4); @@ -1358,7 +1367,7 @@ export class GhosttyWasmTerminal { this.#release(out, n || 1); } } - encodeFocus(state: 'in' | 'out') { + encodeFocus(state: 'in' | 'out'): Uint8Array { if (!this.mode(1004)) return new Uint8Array(); const out = this.#alloc(8), lp = this.#alloc(4); @@ -1371,7 +1380,7 @@ export class GhosttyWasmTerminal { this.#release(lp, 4); } } - free() { + free(): void { if (this.#mouseEvent) this.#e.ghostty_mouse_event_free(this.#mouseEvent); if (this.#mouseEncoder) this.#e.ghostty_mouse_encoder_free(this.#mouseEncoder); if (this.#keyEvent) this.#e.ghostty_key_event_free(this.#keyEvent); diff --git a/experiments/ghostwright/src/tracing/outcome.ts b/experiments/ghostwright/src/tracing/outcome.ts new file mode 100644 index 0000000..669b42e --- /dev/null +++ b/experiments/ghostwright/src/tracing/outcome.ts @@ -0,0 +1,28 @@ +import type { TerminalSession } from '../terminal/session.ts'; + +/** Preserve the original failure even when writing its diagnostic artifacts fails. */ +export async function recordFailure(session: TerminalSession, error: unknown): Promise { + try { + const path = await session.trace.persist( + error, + session.screen.current(), + session.process.status(), + ); + if (path && error instanceof Error) { + Object.assign(error, { tracePath: path }); + error.message += `\ntrace artifact: ${path}`; + } + } catch (traceError) { + if (error instanceof Error) Object.assign(error, { suppressed: [traceError] }); + } +} + +/** Persist successful sessions only when recording was explicitly requested. */ +export async function recordSuccess(session: TerminalSession): Promise { + if (session.trace.policy === 'on') + await session.trace.persist( + 'Terminal scope completed', + session.screen.current(), + session.process.status(), + ); +} diff --git a/experiments/ghostwright/src/tracing/replay.ts b/experiments/ghostwright/src/tracing/replay.ts index 207e428..1f24740 100644 --- a/experiments/ghostwright/src/tracing/replay.ts +++ b/experiments/ghostwright/src/tracing/replay.ts @@ -1,95 +1,150 @@ import { readFile } from 'node:fs/promises'; -// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution +// oxlint-disable-next-line no-restricted-imports -- Resolve files within a caller-supplied trace directory. import { join } from 'node:path'; import { AssetIntegrityError } from '../errors.ts'; -import type { ScreenRevision, ScreenSnapshot, Viewport } from '../types.ts'; +import type { + ScreenRevision, + ScreenSnapshot, + TerminalExtensionDefinition, + Viewport, +} from '../types.ts'; +import type { Observation } from '../observations.ts'; +import { TerminalOutput } from '../terminal/output.ts'; +import { TRACE_SCHEMA_VERSION } from './trace.ts'; import { GhosttyWasmTerminal } from '../terminal/wasm.ts'; export interface ReplayResult { - revisions: readonly ScreenRevision[]; - finalSnapshot: ScreenSnapshot; + readonly revisions: readonly ScreenRevision[]; + readonly observations: readonly Observation[]; + readonly finalSnapshot: ScreenSnapshot; } - -function observable(snapshot: ScreenSnapshot): string { - return JSON.stringify({ - viewport: snapshot.viewport, - activeBuffer: snapshot.activeBuffer, - cursor: snapshot.cursor, - lines: snapshot.lines, - modes: snapshot.modes, - title: snapshot.title, - workingDirectory: snapshot.workingDirectory, - }); +export interface ReplayOptions { + readonly extensions?: readonly TerminalExtensionDefinition[]; } -/** Replay a trace directory and return the screen revisions and final snapshot. */ -export async function replayTrace(directory: string): Promise { +/** Replay uses the same byte splitter, pure extension decoders, and pairing pipeline as live capture. */ +export async function replayTrace( + directory: string, + options: ReplayOptions = {}, +): Promise { const metadata = JSON.parse(await readFile(join(directory, 'metadata.json'), 'utf8')); - if (metadata.schemaVersion !== 1) - throw new AssetIntegrityError(`Unsupported Ghostwright trace schema ${metadata.schemaVersion}`); - const lockUrl = new URL( - import.meta.url.includes('/dist/') ? '../ghostty.lock.json' : '../../ghostty.lock.json', - import.meta.url, + if (metadata.schemaVersion !== TRACE_SCHEMA_VERSION) + throw new AssetIntegrityError('Unsupported trace schema'); + const lock = JSON.parse( + await readFile( + new URL( + import.meta.url.includes('/dist/') ? '../ghostty.lock.json' : '../../ghostty.lock.json', + import.meta.url, + ), + 'utf8', ), - lock = JSON.parse(await readFile(lockUrl, 'utf8')), - expectedWasm = lock.artifacts['artifacts/ghostty-vt.wasm']?.sha256; - if (!expectedWasm || metadata.ghostty?.wasmSha256 !== expectedWasm) - throw new AssetIntegrityError('Trace Ghostty artifact is incompatible with this package'); + ); + if (metadata.ghostty?.wasmSha256 !== lock.artifacts['artifacts/ghostty-vt.wasm']?.sha256) + throw new AssetIntegrityError('Trace Ghostty artifact is incompatible'); const viewport = metadata.profile?.viewport as Required | undefined; - if (!viewport) - throw new AssetIntegrityError('Trace metadata does not contain the initial viewport'); - const raw = new Uint8Array(await readFile(join(directory, 'output.bin'))), - events = (await readFile(join(directory, 'trace.jsonl'), 'utf8')) - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)), - terminal = await GhosttyWasmTerminal.create(viewport), - revisions: ScreenRevision[] = []; - let previous = terminal.snapshot(), - sequence = 0; - try { - for (const event of events) { - let cause: 'pty-output' | 'resize' | undefined; - if (event.type === 'output' && event.raw?.direction === 'from-pty') { - terminal.write(raw.slice(event.raw.offset, event.raw.offset + event.raw.length)); - cause = 'pty-output'; - } else if (event.type === 'action' && event.viewport) { - terminal.resize(event.viewport); - cause = 'resize'; - } - if (!cause) continue; - const snapshot = terminal.snapshot(cause); - if (observable(snapshot) === observable(previous)) continue; - sequence++; - const changedRows = snapshot.lines - .map((line, row) => - JSON.stringify(line) === JSON.stringify(previous.lines[row]) ? -1 : row, - ) - .filter((row) => row >= 0); - const visualChange = + if (!viewport) throw new AssetIntegrityError('Trace lacks its initial viewport'); + const extensions = options.extensions ?? []; + for (const id of metadata.extensions ?? []) + if (!extensions.some((extension) => extension.id === id)) + throw new AssetIntegrityError(`Replay requires extension decoder ${id}`); + const raw = new Uint8Array(await readFile(join(directory, 'output.bin'))); + const events = (await readFile(join(directory, 'trace.jsonl'), 'utf8')) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)); + if (events[0]?.sequence !== 1) + throw new AssetIntegrityError('Trace beginning was evicted; replay would be incomplete'); + const engine = await GhosttyWasmTerminal.create(viewport, metadata.graphics?.storageLimitBytes); + const revisions: ScreenRevision[] = [], + observations: Observation[] = []; + let previous = engine.snapshot(), + sequence = 0, + sourceFrameSequence = 0, + timestamp = 0; + const publish = (cause: ScreenRevision['cause']): ScreenSnapshot => { + const next = engine.snapshot(cause); + const observable = (s: ScreenSnapshot): string => + JSON.stringify([ + s.lines, + s.cursor, + s.viewport, + s.activeBuffer, + s.graphics, + s.modes, + s.title, + s.workingDirectory, + ]); + if (observable(previous) !== observable(next)) { + const changedRows = next.lines.flatMap((line, row) => + JSON.stringify(line) === JSON.stringify(previous.lines[row]) ? [] : [row], + ); + const visual = (s: ScreenSnapshot): string => JSON.stringify([ - snapshot.lines, - snapshot.cursor, - snapshot.activeBuffer, - snapshot.viewport, - ]) !== - JSON.stringify([previous.lines, previous.cursor, previous.activeBuffer, previous.viewport]); - const sequenced = Object.freeze({ ...snapshot, sequence }); + s.lines, + s.cursor, + s.viewport, + s.activeBuffer, + s.graphics.placements.filter((p) => p.viewport.visible), + ]); + const visualChange = visual(previous) !== visual(next); + previous = Object.freeze({ + ...next, + sequence: ++sequence, + timestamp, + lastVisualChangeAt: visualChange ? timestamp : previous.lastVisualChangeAt, + }); revisions.push( Object.freeze({ sequence, - timestamp: event.timestamp, + timestamp, cause, - sourceFrameSequence: event.frameSequence, + sourceFrameSequence, changedRows: Object.freeze(changedRows), visualChange, - snapshot: sequenced, + snapshot: previous, }), ); - previous = sequenced; } - return { revisions: Object.freeze(revisions), finalSnapshot: previous }; + return previous; + }; + try { + const output = new TerminalOutput( + extensions, + () => previous, + (bytes) => { + engine.write(bytes); + return publish('pty-output'); + }, + ); + output.observations.subscribe((observation) => + observations.push(Object.freeze({ ...observation, timestamp })), + ); + for (const event of events) { + timestamp = event.timestamp; + if (event.type === 'output' && event.raw?.direction === 'from-pty') { + const { offset, length } = event.raw; + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(length) || + offset < 0 || + length < 0 || + offset + length > raw.length + ) + throw new AssetIntegrityError('Trace raw range is incomplete'); + sourceFrameSequence = event.frameSequence; + output.push(raw.slice(offset, offset + length)); + engine.takeEffects(); // Responses are already present in the recorded transport. + } else if (event.type === 'resize' && event.viewport) { + engine.resize(event.viewport); + output.observations.screen(publish('resize')); + } + } + return Object.freeze({ + revisions: Object.freeze(revisions), + observations: Object.freeze(observations), + finalSnapshot: previous, + }); } finally { - terminal.free(); + engine.free(); } } diff --git a/experiments/ghostwright/src/tracing/trace.ts b/experiments/ghostwright/src/tracing/trace.ts index 610513a..05f46c8 100644 --- a/experiments/ghostwright/src/tracing/trace.ts +++ b/experiments/ghostwright/src/tracing/trace.ts @@ -4,9 +4,11 @@ import { resolve } from 'node:path'; import { randomBytes } from 'node:crypto'; import type { ProcessStatus, ScreenSnapshot, TerminalLaunchOptions } from '../types.ts'; import { TraceWriteError } from '../errors.ts'; +import { currentRuntime, normalizeViewport } from '../profile.ts'; +export const TRACE_SCHEMA_VERSION = 1; export interface TraceEvent { - schemaVersion: 1; + schemaVersion: typeof TRACE_SCHEMA_VERSION; sequence: number; timestamp: number; type: string; @@ -40,7 +42,7 @@ export class SessionTrace { add(type: string, data: Record = {}): void { if (this.policy === 'off') return; this.#events.push({ - schemaVersion: 1, + schemaVersion: TRACE_SCHEMA_VERSION, sequence: ++this.#seq, timestamp: this.now(), type, @@ -97,16 +99,11 @@ export class SessionTrace { typeof this.options.trace === 'object' ? new Set(this.options.trace.redactArgumentIndexes ?? []) : new Set(), - deno = (globalThis as unknown as { Deno?: { version: { deno: string } } }).Deno, - bun = (globalThis as unknown as { Bun?: { version: string } }).Bun, metadata = { - schemaVersion: 1, + schemaVersion: TRACE_SCHEMA_VERSION, sessionName: name, startedAt: new Date().toISOString(), - runtime: { - name: deno ? 'deno' : bun ? 'bun' : 'node', - version: deno ? deno.version.deno : bun ? bun.version : process.version, - }, + runtime: currentRuntime(), platform: { os: process.platform, arch: process.arch }, ghostwrightVersion: '0.1.0', ghostty: { @@ -119,8 +116,10 @@ export class SessionTrace { term: 'xterm-ghostty', cellWidth: 10, cellHeight: 20, - viewport: snapshot.viewport, + viewport: normalizeViewport(this.options.viewport), }, + extensions: this.options.extensions?.map((extension) => extension.id) ?? [], + graphics: this.options.graphics, command: this.options.command, args: (this.options.args ?? []).map((argument, index) => redactedIndexes.has(index) ? '' : argument, diff --git a/experiments/ghostwright/src/types.ts b/experiments/ghostwright/src/types.ts index b2eefe2..7ad418c 100644 --- a/experiments/ghostwright/src/types.ts +++ b/experiments/ghostwright/src/types.ts @@ -1,5 +1,5 @@ import type { Operation } from 'effection'; -import type { GhostwrightError } from './errors.ts'; +import type { RegionLocator } from './locators.ts'; export interface Viewport { columns: number; @@ -45,32 +45,15 @@ export interface OscRegistration { 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 { +/** Pure protocol decoder. Core owns ordering, publication, and retention. */ +export interface TerminalExtensionDefinition { readonly id: string; - readonly osc?: OscRegistration; - createSession(context: ExtensionSessionContext): TSession; - accept?(session: TSession, commit: TCommit, context: ExtensionSessionContext): void; + readonly osc: OscRegistration>; } export interface TerminalLaunchOptions { @@ -88,7 +71,9 @@ export interface TerminalLaunchOptions { trace?: TracePolicy | TraceOptions; name?: string; /** Optional framework-specific extensions receiving ordered in-band OSC commits. */ - extensions?: readonly TerminalExtensionDefinition[]; + extensions?: readonly TerminalExtensionDefinition[]; + /** Adapter-owned selector syntax; protocol decoders remain pure. */ + selector?: (source: string) => RegionLocator; } export interface Point { column: number; @@ -102,7 +87,7 @@ export interface ActionReceipt { actionSequence: number; screenSequenceBefore: number; acknowledgedAt: number; - deliveredToChild: boolean; + /** Bytes accepted by the PTY. This does not prove application processing. */ bytesWritten: number; } /** @@ -144,7 +129,6 @@ export interface MouseOptions { shift?: boolean; control?: boolean; alt?: boolean; - super?: boolean; } export interface WheelOptions extends Point { deltaRows: number; @@ -424,8 +408,6 @@ 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; diff --git a/experiments/ghostwright/src/vitest.ts b/experiments/ghostwright/src/vitest.ts new file mode 100644 index 0000000..0ef6818 --- /dev/null +++ b/experiments/ghostwright/src/vitest.ts @@ -0,0 +1,65 @@ +import { expect, test as base } from 'vitest'; +import { launchTerminal, type Terminal } from './async.ts'; +import type { TerminalLaunchOptions } from './types.ts'; +import { terminalMatchers, type RunnerAssertions } from './runner-matchers.ts'; +import { SessionClosedError } from './errors.ts'; + +expect.extend(terminalMatchers); +declare module 'vitest' { + interface Assertion extends RunnerAssertions {} +} + +class TerminalFixtureCleanupError extends AggregateError { + constructor(errors: unknown[]) { + super(errors, 'Terminal fixture cleanup failed'); + this.name = 'TerminalFixtureCleanupError'; + } +} + +/** A runner-owned launch fixture. Report the test outcome before disposing its terminals. */ +export const test = base.extend<{ + launchTerminal: (options: TerminalLaunchOptions) => Promise; +}>({ + launchTerminal: async ({ task }, use) => { + const acquisitions: Promise[] = []; + let closed = false; + await using ownership = { + async [Symbol.asyncDispose](): Promise { + closed = true; + const failures: unknown[] = []; + // Acquisition belongs to the fixture even if the test forgot to await it. + const results = await Promise.allSettled(acquisitions); + for (const result of results.toReversed()) { + if (result.status === 'rejected') continue; // The acquisition promise reports its own error. + const terminal = result.value; + try { + if (task.result?.state === 'fail') { + const failure = task.result.errors?.[0]; + const error = new Error(failure?.message ?? 'Vitest test failed'); + if (failure?.stack) error.stack = failure.stack; + await terminal.recordFailure(error); + if (failure) failure.message = error.message; + } + } catch (error) { + failures.push(error); + } finally { + try { + await terminal[Symbol.asyncDispose](); + } catch (error) { + failures.push(error); + } + } + } + if (failures.length) throw new TerminalFixtureCleanupError(failures); + }, + }; + void ownership; + await use((options) => { + if (closed) + return Promise.reject(new SessionClosedError('Vitest terminal fixture has ended')); + const acquiring = launchTerminal(options); + acquisitions.push(acquiring); + return acquiring; + }); + }, +}); diff --git a/experiments/ghostwright/src/wait-for.ts b/experiments/ghostwright/src/wait-for.ts new file mode 100644 index 0000000..f11ec4a --- /dev/null +++ b/experiments/ghostwright/src/wait-for.ts @@ -0,0 +1,103 @@ +import { action, type Operation } from 'effection'; +import { GhostwrightError, InvalidOptionsError } from './errors.ts'; + +export interface WaitForOptions { + readonly timeoutMs?: number; + readonly intervalMs?: number; + readonly signal?: AbortSignal; +} +export interface WaitSource { + subscribe(callback: () => void): () => void; + diagnostics(): string; + readonly timeoutMs: number; +} +export type WaitFor = ( + callback: () => T | PromiseLike, + options?: WaitForOptions, +) => Promise; + +/** Retry assertions, not actions. Observations accelerate the interval fallback. */ +// oxlint-disable-next-line bombshell-dev/max-params -- callback, event source, and wait policy +export function waitForOperation( + callback: () => T | PromiseLike, + source: WaitSource, + options: WaitForOptions = {}, +): Operation { + const timeoutMs = options.timeoutMs ?? source.timeoutMs; + const intervalMs = options.intervalMs ?? 50; + // Node-compatible timers clamp larger delays to 1 ms rather than waiting. + if ( + ![timeoutMs, intervalMs].every( + (value) => Number.isFinite(value) && value >= 0 && value <= 2_147_483_647, + ) + ) + throw new InvalidOptionsError( + 'Wait timeoutMs and intervalMs must be finite and between 0 and 2147483647', + ); + return action((resolve, reject) => { + let finished = false; + let active = false; + let dirty = false; + let lastError: unknown; + let retry: ReturnType | undefined; + const attempt = (): void => { + if (finished) return; + if (active) { + dirty = true; + return; + } + clearTimeout(retry); + active = true; + dirty = false; + // Run user code outside Effection's dispatcher, including runner assertions. + void Promise.resolve().then(async () => { + if (finished) return; + try { + const value = await callback(); + if (!finished) { + finished = true; + resolve(value); + } + } catch (error) { + active = false; + if (finished) return; + lastError = error; + if (dirty) queueMicrotask(attempt); + else retry = setTimeout(attempt, intervalMs); + } + }); + }; + const off = source.subscribe(attempt); + const deadline = setTimeout(() => { + if (finished) return; + finished = true; + const detail = + lastError instanceof Error + ? lastError.message + : String(lastError ?? 'Callback has not completed'); + reject( + new GhostwrightError({ + code: 'GW_WAIT_TIMEOUT', + message: `waitFor timed out after ${timeoutMs} ms: ${detail}\n${source.diagnostics()}`, + cause: lastError, + }), + ); + }, timeoutMs); + const abort = (): void => { + if (!finished) { + finished = true; + reject(options.signal!.reason); + } + }; + options.signal?.addEventListener('abort', abort, { once: true }); + if (options.signal?.aborted) abort(); + else attempt(); + return () => { + finished = true; + off(); + clearTimeout(retry); + clearTimeout(deadline); + options.signal?.removeEventListener('abort', abort); + }; + }); +} diff --git a/experiments/ghostwright/test/assertions-trace.test.ts b/experiments/ghostwright/test/assertions-trace.test.ts index 0b80340..7f0ab1d 100644 --- a/experiments/ghostwright/test/assertions-trace.test.ts +++ b/experiments/ghostwright/test/assertions-trace.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; // oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { join } from 'node:path'; @@ -9,13 +9,13 @@ import { replayTrace, StrictLocatorError, TerminalAssertionError, - withTerminalAsync, + withTerminal, } from '../src/index.ts'; const node = process.execPath; test('lazy locators preserve wide-cell geometry and strictness', async () => { - await withTerminalAsync( + await withTerminal( { command: node, args: ['-e', `setTimeout(() => process.stdout.write("文字 unique duplicate duplicate"), 20)`], @@ -37,7 +37,7 @@ test('lazy locators preserve wide-cell geometry and strictness', async () => { }); test('visual stability uses the existing visual-change timestamp', async () => { - await withTerminalAsync( + await withTerminal( { command: node, args: ['-e', `process.stdout.write("stable"); setTimeout(() => {}, 250)`], @@ -54,7 +54,7 @@ test('visual stability uses the existing visual-change timestamp', async () => { }); test('history eviction is explicit', async () => { - await withTerminalAsync( + await withTerminal( { command: node, args: [ @@ -78,7 +78,7 @@ test('trace-on artifacts replay the same final terminal state', async () => { const directory = await mkdtemp(join(tmpdir(), 'ghostwright-replay-test-')); try { let expected = ''; - await withTerminalAsync( + await withTerminal( { command: node, args: ['-e', `process.stdout.write("first\\rsecond")`], @@ -98,10 +98,92 @@ test('trace-on artifacts replay the same final terminal state', async () => { } }); +test('resize repaints and replay use the new viewport before child output', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ghostwright-resize-replay-')); + try { + const expected = await withTerminal( + { + command: node, + args: [ + '-e', + String.raw` + process.stdin.setRawMode(true); + process.stdout.on('resize', () => { + const { columns, rows } = process.stdout; + const lines = Array.from({ length: rows }, (_, row) => + (row === 0 ? 'TOP' : row === rows - 1 ? 'BOTTOM' : 'body').padEnd(columns, '.')); + process.stdout.write('\x1b[2J\x1b[H' + lines.join('\r\n')); + }); + process.stdin.once('data', () => process.exit(0)); + process.stdout.write('\x1b[?1049hREADY'); + `, + ], + viewport: { columns: 80, rows: 10 }, + trace: { policy: 'on', directory }, + }, + async (terminal) => { + await expectTerminal(terminal.getByText('READY')).toBePresent(); + await terminal.resize({ columns: 36, rows: 4 }); + await expectTerminal(terminal).toSatisfy( + (screen) => + screen.lines[0].text.startsWith('TOP') && screen.lines[3].text.startsWith('BOTTOM'), + ); + await terminal.keyboard.press('Enter'); + await terminal.process.waitForExit(); + return terminal.screen.getText(); + }, + ); + const [artifact] = await readdir(directory); + const replay = await replayTrace(join(directory, artifact)); + expect(replay.finalSnapshot.viewport).toMatchObject({ columns: 36, rows: 4 }); + expect(replay.finalSnapshot.lines.map((line) => line.text).join('\n')).toBe(expected); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('replay rejects incomplete recordings instead of reconstructing a plausible screen', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ghostwright-incomplete-replay-')); + // Keep the files if any assertion fails, including failures during offline replay. + await withTerminal( + { + command: node, + args: ['-e', 'process.stdout.write("complete terminal output")'], + trace: { policy: 'on', directory }, + }, + async (terminal) => { + await terminal.process.waitForExit(); + }, + ); + const path = join(directory, (await readdir(directory))[0]!); + const eventsPath = join(path, 'trace.jsonl'); + const outputPath = join(path, 'output.bin'); + const events = await readFile(eventsPath, 'utf8'); + const output = await readFile(outputPath); + + await writeFile(eventsPath, events.split('\n').slice(1).join('\n')); + await expect(replayTrace(path)).rejects.toMatchObject({ + code: 'GW_ASSET_INTEGRITY', + message: expect.stringContaining('Trace beginning was evicted'), + }); + await writeFile(eventsPath, events); + + await writeFile(outputPath, output.subarray(0, output.length - 1)); + await expect(replayTrace(path)).rejects.toMatchObject({ + code: 'GW_ASSET_INTEGRITY', + message: expect.stringContaining('Trace raw range is incomplete'), + }); + await writeFile(outputPath, output); + + const replay = await replayTrace(path); + expect(replay.finalSnapshot.lines[0]!.text).toContain('complete terminal output'); + await rm(directory, { recursive: true, force: true }); +}); + test('marked input is redacted from trace bytes', async () => { const directory = await mkdtemp(join(tmpdir(), 'ghostwright-redaction-test-')); try { - await withTerminalAsync( + await withTerminal( { command: node, args: [ @@ -133,7 +215,7 @@ test('retain-on-failure writes private complete artifacts and preserves the prim try { let failure: unknown; try { - await withTerminalAsync( + await withTerminal( { command: node, args: ['-e', `process.stdout.write("actual")`], diff --git a/experiments/ghostwright/test/backpressure.test.ts b/experiments/ghostwright/test/backpressure.test.ts new file mode 100644 index 0000000..aff71bb --- /dev/null +++ b/experiments/ghostwright/test/backpressure.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from 'bun:test'; +import { withTerminal, regionLocator, textContains } from '../src/index.ts'; +import { SidecarClient } from '../src/pty/client.ts'; +import { resolveAssets, normalizeViewport, profileEnvironment } from '../src/profile.ts'; + +test('a capture timeout cancels a blocked write without closing its parent session', async () => { + const viewport = regionLocator({ column: 0, row: 0, width: 80, height: 24 }); + await withTerminal( + { + command: process.execPath, + args: [ + '-e', + 'process.stdin.setRawMode(true); process.stdout.write("READY"); setInterval(() => {}, 1000)', + ], + trace: 'off', + }, + async (t) => { + await t.expect(viewport).toContainText('READY'); + await expect( + t.capture( + { timeoutMs: 30, until: viewport.satisfies(textContains('NEVER')) }, + async (capture) => { + await capture.keyboard.write(new Uint8Array(1024 * 1024)); + }, + ), + ).rejects.toMatchObject({ code: 'GW_CAPTURE_TIMEOUT' }); + expect(t.process.status().state).toBe('running'); + await t.process.signal('SIGTERM'); + await t.process.waitForExit(); + }, + ); +}); + +test('a child that does not read input cannot block administrative close', async () => { + const assets = await resolveAssets({ command: process.execPath }); + const client = await SidecarClient.start(assets.host, 3000); + try { + const ready = new Promise((resolve) => { + let output = ''; + client.on('output', (bytes) => { + output += Buffer.from(bytes).toString(); + if (output.includes('READY')) resolve(); + }); + }); + await client.spawn({ + command: process.execPath, + args: [ + '-e', + 'process.stdin.setRawMode(true); process.stdout.write("READY"); setInterval(() => {}, 1000)', + ], + cwd: process.cwd(), + env: profileEnvironment(undefined, assets.terminfo), + viewport: normalizeViewport(), + cleanup: { hangupGraceMs: 10, terminateGraceMs: 10, postExitDrainMs: 20 }, + }); + await ready; + // Exceed the kernel input capacity, not the host's bounded input budget. + // Observe all rejections immediately while close overtakes blocked writes. + const writes = Promise.allSettled( + Array.from({ length: 32 }, () => client.write(new Uint8Array(65536))), + ); + await client.close(3000); + const outcomes = await writes; + expect(outcomes.some((result) => result.status === 'rejected')).toBe(true); + expect( + outcomes + .filter((result) => result.status === 'rejected') + .every((result) => result.reason.code === 'GW_WRITE_INTERRUPTED'), + ).toBe(true); + } finally { + client.forceKill(); + } +}); diff --git a/experiments/ghostwright/test/child-locator.test.ts b/experiments/ghostwright/test/child-locator.test.ts new file mode 100644 index 0000000..a73554a --- /dev/null +++ b/experiments/ghostwright/test/child-locator.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from 'bun:test'; +import { + defineLocator, + defineScreenLocator, + textContains, + withTerminal, + type Rect, + type TerminalExtensionDefinition, +} from '../src/index.ts'; + +// Enter advances a small panel through movement, removal, and replacement. +// The child renders through a real PTY. Descriptions carry geometry, not proof +// of the text that an assertion expects to see. +const application = String.raw` +process.stdin.setRawMode(true); +const slides = [ + { column: 2, text: 'Before' }, + { column: 20, text: 'After' }, + { column: null, text: 'No panel' }, + { column: 8, text: 'Back' }, +]; +let index = 0; +function render() { + const slide = slides[index]; + const bounds = slide.column === null ? null : { column: slide.column, row: 1, width: slide.text.length + 2, height: 1 }; + let output = '\x1b[2J\x1b[H' + slide.text; + if (bounds) output += '\x1b[2;' + (bounds.column + 1) + 'H[' + slide.text + ']'; + output += '\x1b[4;1HBefore After Back'; // matching text outside the panel + if (process.env.DESCRIBE === '1') { + output += '\x1b]7777;panel;' + Buffer.from(JSON.stringify({ frame: index + 1, bounds })).toString('base64url') + '\x1b\\'; + } + process.stdout.write(output); +} +process.stdin.on('data', bytes => { + for (const key of bytes.toString()) if (key === '\r' && index < slides.length - 1) { index++; render(); } +}); +render(); +`; +interface Description { + frame: number; + bounds: Rect | null; +} +const extension: TerminalExtensionDefinition = { + id: 'panel', + osc: { + number: 7777, + namespace: 'panel', + maxBufferedBytes: 4096, + decode(message) { + const value: Description = JSON.parse( + Buffer.from(Buffer.from(message.payload).toString(), 'base64url').toString(), + ); + return { protocolFrame: value.frame, value }; + }, + }, +}; + +for (const described of [false, true]) { + test(`a child follows its parent across ${described ? 'described' : 'screen'} observations`, async () => { + const parent = described + ? defineLocator('panel', 'panel', (description) => + description.bounds ? [description.bounds] : [], + ) + : defineScreenLocator('panel', (screen) => + screen.lines.flatMap((line) => { + const left = line.cells.find((cell) => cell.text === '['); + const right = line.cells.find((cell) => cell.text === ']'); + return left && right + ? [ + { + column: left.column, + row: line.row, + width: right.column - left.column + 1, + height: 1, + }, + ] + : []; + }), + ); + // Construct the whole path before launching. No coordinates are captured. + const text = parent.derive('contents', (region) => [ + { + column: region.bounds.column + 1, + row: region.bounds.row, + width: region.bounds.width - 2, + height: 1, + }, + ]); + const initial = text.derive('initial', (region) => [{ ...region.bounds, width: 1 }]); + const statusBounds = { column: 0, row: 0, width: 40, height: 1 }; + const status = described + ? defineLocator('panel', 'status', () => [statusBounds]) + : defineScreenLocator('status', () => [statusBounds]); + + await withTerminal( + { + command: process.execPath, + args: ['-e', application], + env: { DESCRIBE: described ? '1' : '0' }, + extensions: described ? [extension] : [], + trace: 'off', + }, + async (ui) => { + await ui.expect(text).toContainText('Before'); + const movement = await ui.capture( + { until: text.satisfies(textContains('After')) }, + async (capture) => { + await capture.keyboard.press('Enter'); + }, + ); + await ui.expect(initial).toContainText('A'); + + // Evaluate history after the live parent has moved. Each path still + // resolves against its supplied observation, not the current screen. + const before = text.resolve(movement.baseline)[0]!; + const moved = text.resolve(movement.observations.at(-1)!)[0]!; + expect(before.text()).toBe('Before'); + expect(before.bounds.column).toBe(3); + expect(moved.text()).toBe('After'); + expect(moved.bounds.column).toBe(21); + expect(before.screen).toBe(parent.resolve(movement.baseline)[0]!.screen); + expect(initial.resolve(movement.baseline)[0]!.text()).toBe('B'); + + const removal = await ui.capture( + { until: status.satisfies(textContains('No panel')) }, + async (capture) => { + await capture.keyboard.press('Enter'); + }, + ); + expect(text.resolve(removal.observations.at(-1)!)).toEqual([]); + expect(initial.resolve(removal.observations.at(-1)!)).toEqual([]); + + await ui.keyboard.press('Enter'); + const restored = await ui.expect(text).toContainText('Back'); + expect(restored.bounds.column).toBe(9); + expect(text.resolve(movement.baseline)[0]!.text()).toBe('Before'); + }, + ); + }); +} diff --git a/experiments/ghostwright/test/conformance.test.ts b/experiments/ghostwright/test/conformance.test.ts index ab95608..acc8a96 100644 --- a/experiments/ghostwright/test/conformance.test.ts +++ b/experiments/ghostwright/test/conformance.test.ts @@ -3,7 +3,7 @@ import { expectTerminal, LaunchError, ReservedEnvironmentError, - withTerminalAsync, + withTerminal, } from '../src/index.ts'; const node = process.execPath; @@ -13,7 +13,7 @@ function evalArgs(source: string): string[] { } test('profile, TTY descriptors, geometry, styles, Unicode, and clipboard use Ghostty state', async () => { - await withTerminalAsync( + await withTerminal( { command: node, args: evalArgs(` @@ -56,7 +56,7 @@ test('profile, TTY descriptors, geometry, styles, Unicode, and clipboard use Gho }); test('Ghostty effects answer DA, size, color-scheme, ENQ, and XTVERSION queries', async () => { - await withTerminalAsync( + await withTerminal( { command: node, args: evalArgs(` @@ -91,7 +91,7 @@ test('Ghostty effects answer DA, size, color-scheme, ENQ, and XTVERSION queries' }); test('mode-aware keyboard, paste, focus, mouse, and large raw input are acknowledged', async () => { - await withTerminalAsync( + await withTerminal( { command: node, args: evalArgs(` @@ -120,7 +120,7 @@ test('mode-aware keyboard, paste, focus, mouse, and large raw input are acknowle await terminal.mouse.move({ column: 2, row: 3 }), await terminal.keyboard.write(new Uint8Array(70_000)), ]; - expect(receipts.every((receipt) => receipt.deliveredToChild)).toBe(true); + expect(receipts.every((receipt) => receipt.bytesWritten > 0)).toBe(true); expect(receipts.at(-1)?.bytesWritten).toBe(70_000); await terminal.process.waitForExit({ timeoutMs: 2_000 }); const expectedPrefix = Buffer.from( @@ -134,17 +134,17 @@ test('mode-aware keyboard, paste, focus, mouse, and large raw input are acknowle test('reserved profile environment and exec failures are typed', async () => { await expect( - withTerminalAsync({ command: node, env: { TERM: 'bad' }, trace: 'off' }, async () => undefined), + withTerminal({ command: node, env: { TERM: 'bad' }, trace: 'off' }, async () => undefined), ).rejects.toBeInstanceOf(ReservedEnvironmentError); await expect( - withTerminalAsync({ command: '/definitely/missing', trace: 'off' }, async () => undefined), + withTerminal({ command: '/definitely/missing', trace: 'off' }, async () => undefined), ).rejects.toBeInstanceOf(LaunchError); }); test('parallel sessions own isolated WASM and PTY state', async () => { const values = await Promise.all( Array.from({ length: 8 }, (_, index) => - withTerminalAsync( + withTerminal( { command: node, args: evalArgs(`process.stdout.write("session-${index}")`), diff --git a/experiments/ghostwright/test/disposal.test.ts b/experiments/ghostwright/test/disposal.test.ts new file mode 100644 index 0000000..04530b6 --- /dev/null +++ b/experiments/ghostwright/test/disposal.test.ts @@ -0,0 +1,103 @@ +import { expect, test } from 'bun:test'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { launchTerminal, expectTerminal, regionLocator, textContains } from '../src/index.ts'; + +const options = { + command: process.execPath, + args: [ + '-e', + 'process.stdin.setRawMode(true); process.stdin.resume(); process.stdout.write("READY")', + ], + trace: 'off' as const, +}; + +test('await using owns pending work, the child process, and escaped handles', async () => { + const terminal = await launchTerminal(options); + await expectTerminal(terminal.getByText('READY')).toBePresent(); + const pending = terminal + .capture( + { + until: regionLocator({ column: 0, row: 0, width: 10, height: 1 }).satisfies( + textContains('never'), + ), + }, + async () => {}, + ) + .catch((error: unknown) => error); + { + await using owned = terminal; + expect(owned.signal.aborted).toBe(false); + } + expect(await pending).toBeInstanceOf(Error); + expect(terminal.signal.aborted).toBe(true); + expect(terminal.process.status().state).toBe('closed'); + expect(() => process.kill(terminal.process.status().pid!, 0)).toThrow(); + await expect(terminal.keyboard.type('late')).rejects.toBeInstanceOf(Error); + await terminal[Symbol.asyncDispose](); + await terminal.close(); +}); + +test('an abort listener can reenter disposal without persisting the trace twice', async () => { + const directory = await mkdtemp(`${tmpdir()}/ghostwright-dispose-`); + const terminal = await launchTerminal({ ...options, trace: { policy: 'on', directory } }); + let reentered: Promise | undefined; + terminal.signal.addEventListener('abort', () => { + reentered = terminal[Symbol.asyncDispose](); + }); + await terminal[Symbol.asyncDispose](); + await reentered; + expect(await readdir(directory)).toHaveLength(1); + await rm(directory, { recursive: true, force: true }); +}); + +test('an assertion failure still disposes an await-using terminal', async () => { + let terminal: Awaited> | undefined; + const failure = new Error('test body failed'); + await expect( + (async () => { + await using owned = await launchTerminal(options); + terminal = owned; + await expectTerminal(owned.getByText('READY')).toBePresent(); + throw failure; + })(), + ).rejects.toBe(failure); + expect(terminal!.signal.aborted).toBe(true); + expect(terminal!.process.status().state).toBe('closed'); +}); + +test('failed acquisition releases its scope and does not poison another terminal', async () => { + await expect( + launchTerminal({ command: '/definitely/missing', trace: 'off' }), + ).rejects.toMatchObject({ code: 'GW_LAUNCH' }); + await using terminal = await launchTerminal(options); + await expectTerminal(terminal.getByText('READY')).toBePresent(); +}); + +test('disposal overtakes a blocked write and awaits process cleanup', async () => { + await using terminal = await launchTerminal({ + ...options, + args: [ + '-e', + String.raw` + process.stdin.setRawMode(true); + process.stdin.once('data', () => { + process.stdin.pause(); + process.stdout.write('\r\nPAUSED'); + }); + process.stdout.write('READY'); + setInterval(() => {}, 1000); + `, + ], + }); + await expectTerminal(terminal.getByText('READY')).toBePresent(); + const writing = terminal.keyboard + .write(new Uint8Array(4 * 1024 * 1024).fill(120)) + .catch((error: unknown) => error); + // The child has received input and stopped reading. The remaining payload + // cannot fit in the PTY queue; disposal must interrupt real pending I/O. + await terminal.screen.findByText('PAUSED'); + await terminal[Symbol.asyncDispose](); + expect(await writing).toBeInstanceOf(Error); + expect(terminal.process.status().state).toBe('closed'); +}); diff --git a/experiments/ghostwright/test/effection.test.ts b/experiments/ghostwright/test/effection.test.ts index 3f3f943..6f192f5 100644 --- a/experiments/ghostwright/test/effection.test.ts +++ b/experiments/ghostwright/test/effection.test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'bun:test'; import { run } from 'effection'; -import { expectTerminal, withTerminal } from '../src'; +import { expectTerminal } from '../src'; +import { withTerminal } from '../src/effection/index.ts'; test('Effection facade shares scoped session behavior', async () => { const result = await run(function* () { return yield* withTerminal( @@ -14,6 +15,22 @@ test('Effection facade shares scoped session behavior', async () => { expect(result).toBe('generator'); }); +test('Effection queries and waitFor use the same frozen evidence contracts', async () => { + const result = await run(function* () { + return yield* withTerminal( + { command: '/bin/sh', args: ['-c', 'printf generator'], trace: 'off' }, + function* (terminal) { + const found = yield* terminal.screen.findByText('generator'); + const value = yield* terminal.waitFor(() => false); + expect(value).toBe(false); + expect(terminal.screen.queryByText('missing')).toBeNull(); + return found.text(); + }, + ); + }); + expect(result).toBe('generator'); +}); + test('Effection cancellation closes the PTY scope and application process', async () => { let pid = 0, started!: () => void; diff --git a/experiments/ghostwright/test/extensions.test.ts b/experiments/ghostwright/test/extensions.test.ts index 24111b6..79360e8 100644 --- a/experiments/ghostwright/test/extensions.test.ts +++ b/experiments/ghostwright/test/extensions.test.ts @@ -1,5 +1,5 @@ import { expect, test } from 'bun:test'; -import { RegisteredOscStream } from '../src/terminal/extensions.ts'; +import { RegisteredOscStream, type OscEvent } from '../src/terminal/extensions.ts'; import type { OscRegistration } from '../src/types.ts'; const registration: OscRegistration = { @@ -10,7 +10,9 @@ const registration: OscRegistration = { }; const frame = new TextEncoder().encode('\u001b]7777;test.semantic;v=1;payload\u001b\\'); -function events(items: ReturnType['items']) { +function events( + items: ReturnType['items'], +): { kind: 'event'; event: OscEvent }[] { return items.filter((item) => item.kind === 'event'); } @@ -48,6 +50,13 @@ test('oversized registered OSC discards its complete payload through ST', () => expect(new TextDecoder().decode((items[1] as { bytes: Uint8Array }).bytes)).toBe('VISIBLE'); }); +test('an over-limit terminator does not swallow the next observation', () => { + const stream = new RegisteredOscStream([{ ...registration, maxBufferedBytes: frame.length - 1 }]); + const next = new TextEncoder().encode('\x1b]7777;test.semantic;v=1;x\x1b\\'); + const items = stream.push(Uint8Array.from([...frame, ...next])).items; + expect(items.map((item) => item.kind)).toEqual(['error', 'event']); +}); + 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; diff --git a/experiments/ghostwright/test/host-contract.ts b/experiments/ghostwright/test/host-contract.ts index 0f828b1..1b78abc 100644 --- a/experiments/ghostwright/test/host-contract.ts +++ b/experiments/ghostwright/test/host-contract.ts @@ -1,6 +1,6 @@ // oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution import { resolve } from 'node:path'; -import { expectTerminal, withTerminalAsync } from '../src/index.ts'; +import { expectTerminal, withTerminal } from '../src/index.ts'; import { usePtyHostForTesting } from '../src/profile.ts'; import { SidecarClient } from '../src/pty/client.ts'; import { GhostwrightError } from '../src/errors.ts'; @@ -10,7 +10,7 @@ export async function runHostContract(hostPath: string): Promise { const absolute = resolve(hostPath), restore = usePtyHostForTesting(absolute); try { - await withTerminalAsync( + await withTerminal( { command: '/bin/sh', args: ['-c', `test -t 0 && test -t 1 && test -t 2 && printf 'TTY READY'`], @@ -27,7 +27,7 @@ export async function runHostContract(hostPath: string): Promise { }, ); - await withTerminalAsync( + await withTerminal( { command: '/bin/sh', args: ['-c', `trap 'stty size; exit' WINCH; printf READY; while :; do sleep 1; done`], @@ -41,7 +41,7 @@ export async function runHostContract(hostPath: string): Promise { }, ); - await withTerminalAsync( + await withTerminal( { command: '/bin/sh', args: [ @@ -63,7 +63,7 @@ export async function runHostContract(hostPath: string): Promise { }, ); - await withTerminalAsync( + await withTerminal( { command: process.execPath, args: [ @@ -85,7 +85,7 @@ export async function runHostContract(hostPath: string): Promise { ); const started = performance.now(); - await withTerminalAsync( + await withTerminal( { command: '/bin/sh', args: ['-c', `node -e 'setInterval(()=>{}, 1000)' & printf FINAL; exit 0`], @@ -137,6 +137,5 @@ if (import.meta.main) { message: 'usage: bun test/host-contract.ts ', }); await runHostContract(contractPath); - // oxlint-disable-next-line no-console -- test script - console.log(`host contract passed: ${contractPath}`); + console.info(`host contract passed: ${contractPath}`); } diff --git a/experiments/ghostwright/test/locator-style.test.ts b/experiments/ghostwright/test/locator-style.test.ts index ed8c4d7..c4a8d8b 100644 --- a/experiments/ghostwright/test/locator-style.test.ts +++ b/experiments/ghostwright/test/locator-style.test.ts @@ -4,7 +4,7 @@ import { expectTerminal, InvalidKeyError, TerminalAssertionError, - withTerminalAsync, + withTerminal, } from '../src/index.ts'; /** Emits red "ALERT", plain "READY", then parks the cursor on a known cell. */ @@ -19,14 +19,14 @@ const coloured = { }; test('toHaveStyle matches a foreground colour', async () => { - await withTerminalAsync(coloured, async (terminal) => { + await withTerminal(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 withTerminal(coloured, async (terminal) => { await expectTerminal(terminal.getByText('READY')).toBePresent(); let error: unknown; try { @@ -45,7 +45,7 @@ test('toHaveStyle fails when the colour differs', async () => { }); test('style-filtered locators disambiguate identical text', async () => { - await withTerminalAsync( + await withTerminal( { command: '/bin/sh', args: ['-c', `printf '\\033[38;2;255;0;0mSAVE\\033[0m\\r\\nSAVE\\r\\n'; sleep 30`], @@ -64,7 +64,7 @@ test('style-filtered locators disambiguate identical text', async () => { }); test('toContainCursor tracks where the terminal cursor sits', async () => { - await withTerminalAsync(coloured, async (terminal) => { + await withTerminal(coloured, async (terminal) => { // The trailing escape parks the cursor at row 0, column 0, inside "ALERT". await expectTerminal(terminal.getByText('ALERT')).toContainCursor(); @@ -80,7 +80,7 @@ test('toContainCursor tracks where the terminal cursor sits', async () => { }); test('locator matches expose their backing cells', async () => { - await withTerminalAsync(coloured, async (terminal) => { + await withTerminal(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'); @@ -89,7 +89,7 @@ test('locator matches expose their backing cells', async () => { }); test('screen.getCells returns a rectangle of cells', async () => { - await withTerminalAsync(coloured, async (terminal) => { + await withTerminal(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'); @@ -98,7 +98,7 @@ test('screen.getCells returns a rectangle of cells', async () => { }); test('screen.snapshot aliases screen.current', async () => { - await withTerminalAsync(coloured, async (terminal) => { + await withTerminal(coloured, async (terminal) => { await expectTerminal(terminal.getByText('READY')).toBePresent(); expect(terminal.screen.snapshot()).toBe(terminal.screen.current()); }); @@ -106,7 +106,7 @@ test('screen.snapshot aliases screen.current', async () => { 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) => { + await withTerminal(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'))) @@ -133,14 +133,14 @@ test('a throwing predicate counts as unsatisfied and is reported', async () => { }); test('unknown key names fail fast instead of timing out', async () => { - await withTerminalAsync(coloured, async (terminal) => { + await withTerminal(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 withTerminal(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'); diff --git a/experiments/ghostwright/test/observability.test.ts b/experiments/ghostwright/test/observability.test.ts index 770d005..730be79 100644 --- a/experiments/ghostwright/test/observability.test.ts +++ b/experiments/ghostwright/test/observability.test.ts @@ -4,13 +4,13 @@ import { HistoryChangedError, TerminalAssertionError, expectTerminal, - withTerminalAsync, + withTerminal, } from '../src/index.ts'; const node = process.execPath; test('retained ranges use exclusive baselines and bounded live collection', async () => { - await withTerminalAsync( + await withTerminal( { command: node, args: [ @@ -37,7 +37,7 @@ test('retained ranges use exclusive baselines and bounded live collection', asyn }); test('history is immutable, paginated, searchable, and generation guarded', async () => { - await withTerminalAsync( + await withTerminal( { command: node, args: ['-e', `for (let i = 0; i < 30; i++) console.log("HISTORY-" + i)`], @@ -65,7 +65,7 @@ test('history is immutable, paginated, searchable, and generation guarded', asyn }); test('history page boundaries retain soft-wrap continuation metadata', async () => { - await withTerminalAsync( + await withTerminal( { command: node, args: [ @@ -106,7 +106,7 @@ test('raw Kitty graphics expose renderer-ready copied placement metadata', async 0, // transparent ]); const payload = Buffer.from(pixels).toString('base64'); - await withTerminalAsync( + await withTerminal( { command: node, args: [ @@ -154,7 +154,7 @@ test('raw Kitty graphics expose renderer-ready copied placement metadata', async test('inspected unplaced Kitty images survive later snapshots without retaining pixels', async () => { const pixels = new Uint8Array([255, 0, 0, 255]); const payload = Buffer.from(pixels).toString('base64'); - await withTerminalAsync( + await withTerminal( { command: node, args: [ @@ -177,7 +177,7 @@ test('inspected unplaced Kitty images survive later snapshots without retaining }); test('revision collection reports timeout distinctly from process exit', async () => { - await withTerminalAsync( + await withTerminal( { command: node, args: ['-e', 'setTimeout(() => process.exit(0), 200)'], trace: 'off' }, async (terminal) => { await expect( diff --git a/experiments/ghostwright/test/process.test.ts b/experiments/ghostwright/test/process.test.ts index 2827d20..d1c4595 100644 --- a/experiments/ghostwright/test/process.test.ts +++ b/experiments/ghostwright/test/process.test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'bun:test'; -import { expectTerminal, withTerminalAsync } from '../src/index.ts'; +import { expectTerminal, withTerminal } from '../src/index.ts'; test('user Control-C travels through PTY line discipline', async () => { - await withTerminalAsync( + await withTerminal( { command: '/bin/sh', args: [ @@ -21,7 +21,7 @@ test('user Control-C travels through PTY line discipline', async () => { }); test('raw Control-C remains input while administrative signals target the OS process', async () => { - await withTerminalAsync( + await withTerminal( { command: process.execPath, args: [ @@ -44,7 +44,7 @@ test('raw Control-C remains input while administrative signals target the OS pro test('natural direct-child exit drains and then owns a PTY-holding descendant', async () => { const started = performance.now(); - await withTerminalAsync( + await withTerminal( { command: '/bin/sh', args: ['-c', `node -e 'setInterval(()=>{}, 1000)' & child=$!; printf FINAL; exit 0`], diff --git a/experiments/ghostwright/test/queries.test.ts b/experiments/ghostwright/test/queries.test.ts new file mode 100644 index 0000000..e02d8c5 --- /dev/null +++ b/experiments/ghostwright/test/queries.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from 'bun:test'; +import { + defineLocator, + launchTerminal, + regionLocator, + textLocator, + type TerminalExtensionDefinition, +} from '../src/index.ts'; + +interface Frame { + frame: number; + count: number; +} +const extension: TerminalExtensionDefinition = { + id: 'query.fixture', + osc: { + number: 7777, + namespace: 'query.fixture', + maxBufferedBytes: 4096, + decode(message) { + const value: Frame = JSON.parse(new TextDecoder().decode(message.payload)); + return { protocolFrame: value.frame, value }; + }, + }, +}; +const targets = defineLocator('query.fixture', 'target', (frame) => + Array.from({ length: frame.count }, (_, index) => ({ + column: index * 7, + row: 1, + width: 6, + height: 1, + })), +); +const options = { + command: process.execPath, + args: [ + '-e', + String.raw` + process.stdin.setRawMode(true); + let frame = 0; + function paint(count) { + process.stdout.write('\x1b[2J\x1b[HREADY\r\n' + 'target '.repeat(count) + + '\x1b]7777;query.fixture;v=1;' + JSON.stringify({frame: ++frame, count}) + '\x1b\\'); + } + process.stdin.on('data', bytes => { + for (const key of bytes.toString()) { + if (key === 'x') process.exit(0); + else if (key === 's') process.stdout.write('\x1b[2J\x1b[HUNPAIRED'); + else paint(Number(key)); + } + }); + paint(0); + `, + ], + extensions: [extension], + selector: (_source: string) => targets, + trace: 'off' as const, +}; + +for (const kind of ['recipe', 'text', 'selector'] as const) { + test(`${kind} queries share cardinality, waiting, and frozen evidence`, async () => { + await using terminal = await launchTerminal(options); + const { screen, keyboard, waitFor } = terminal; + await screen.findByText('READY'); + await waitFor(() => screen.queryAllBy(targets)); + const get = (): ReturnType => + kind === 'recipe' + ? screen.getBy(targets) + : kind === 'text' + ? screen.getByText('target') + : screen.getBySelector('target'); + const query = (): ReturnType => + kind === 'recipe' + ? screen.queryBy(targets) + : kind === 'text' + ? screen.queryByText('target') + : screen.queryBySelector('target'); + const all = (): ReturnType => + kind === 'recipe' + ? screen.getAllBy(targets) + : kind === 'text' + ? screen.getAllByText('target') + : screen.getAllBySelector('target'); + const queryAll = (): ReturnType => + kind === 'recipe' + ? screen.queryAllBy(targets) + : kind === 'text' + ? screen.queryAllByText('target') + : screen.queryAllBySelector('target'); + const find = (): ReturnType => + kind === 'recipe' + ? screen.findBy(targets) + : kind === 'text' + ? screen.findByText('target') + : screen.findBySelector('target'); + const findAll = (): ReturnType => + kind === 'recipe' + ? screen.findAllBy(targets) + : kind === 'text' + ? screen.findAllByText('target') + : screen.findAllBySelector('target'); + + expect(query()).toBeNull(); + expect(queryAll()).toEqual([]); + expect(get).toThrow('No region matched'); + expect(all).toThrow('No region matched'); + + const appearing = findAll(); + await keyboard.type('2'); + expect(await appearing).toHaveLength(2); + expect(all()).toHaveLength(2); + expect(queryAll()).toHaveLength(2); + expect(get).toThrow('matched 2'); + expect(query).toThrow('matched 2'); + + // findBy must retry ambiguity rather than selecting one or failing early. + const unique = find(); + await keyboard.type('1'); + const saved = await unique; + expect(saved.text()).toBe('target'); + expect(get().bounds).toEqual(saved.bounds); + expect(query()?.bounds).toEqual(saved.bounds); + expect(Object.isFrozen(saved)).toBe(true); + expect(Object.isFrozen(queryAll())).toBe(true); + await keyboard.type('0'); + await waitFor(() => expect(query()).toBeNull()); + expect(saved.text()).toBe('target'); + }); +} + +test('unpaired output cannot make a described control appear absent', async () => { + await using terminal = await launchTerminal(options); + await terminal.waitFor(() => terminal.screen.queryAllBy(targets)); + await terminal.keyboard.type('s'); + await terminal.screen.findByText('UNPAIRED'); + expect(() => terminal.screen.queryBy(targets)).toThrow('No current observation'); + expect(() => terminal.screen.queryAllBy(targets)).toThrow('No current observation'); + const following = terminal.screen.findBy(targets); + await terminal.keyboard.type('1'); + expect((await following).text()).toBe('target'); + await terminal.keyboard.type('0'); + await terminal.waitFor(() => expect(terminal.screen.queryBy(targets)).toBeNull()); +}); + +test('find timeouts retain the failing query and the last rendered screen', async () => { + await using terminal = await launchTerminal(options); + await terminal.screen.findByText('READY'); + await terminal.waitFor(() => terminal.screen.queryAllBy(targets)); + await expect(terminal.screen.findBy(targets, { timeoutMs: 20 })).rejects.toMatchObject({ + code: 'GW_WAIT_TIMEOUT', + message: expect.stringContaining('target'), + cause: expect.objectContaining({ code: 'GW_QUERY_MISSING' }), + }); +}); + +test('querying a region does not claim visibility or require a live child', async () => { + await using terminal = await launchTerminal(options); + await terminal.screen.findByText('READY'); + // A screen-derived input target still resolves when a description is latest. + // This fixture has not enabled mouse reporting; hovering must not enable it. + expect((await terminal.mouse.hover(textLocator('READY'))).bytesWritten).toBe(0); + const unsupported = { control: true, super: true }; + await expect(terminal.mouse.hover(textLocator('READY'), unsupported)).rejects.toMatchObject({ + code: 'GW_UNSUPPORTED_MODIFIER', + }); + const offscreen = regionLocator({ column: 200, row: 0, width: 5, height: 1 }); + expect(terminal.screen.getBy(offscreen).visibleBounds).toBeUndefined(); + await terminal.keyboard.type('x'); + await terminal.process.waitForExit(); + expect((await terminal.screen.findByText('READY')).text()).toBe('READY'); + await expect(terminal.screen.findByText('never', { timeoutMs: 20 })).rejects.toMatchObject({ + code: 'GW_WAIT_TIMEOUT', + }); +}); + +test('text recipes retain cell geometry for wide characters and exact matching', async () => { + await using terminal = await launchTerminal({ + command: process.execPath, + args: ['-e', 'process.stdout.write("界a 界a")'], + trace: 'off', + }); + await terminal.process.waitForExit(); + const matches = terminal.screen.getAllBy(textLocator('界a')); + expect(matches.map((match) => match.bounds)).toEqual([ + { column: 0, row: 0, width: 3, height: 1 }, + { column: 4, row: 0, width: 3, height: 1 }, + ]); + expect(terminal.screen.queryByText('界a', { exact: true })).toBeNull(); + expect(terminal.screen.getByText('界a 界a', { exact: true }).bounds.width).toBe(7); + expect(() => terminal.screen.getBySelector('anything')).toThrow('selector adapter'); + await expect(terminal.screen.findBySelector('anything')).rejects.toMatchObject({ + code: 'GW_INVALID_OPTIONS', + }); + await expect(terminal.screen.findByText('')).rejects.toMatchObject({ + code: 'GW_INVALID_OPTIONS', + }); +}); diff --git a/experiments/ghostwright/test/runner-contract.mjs b/experiments/ghostwright/test/runner-contract.mjs new file mode 100644 index 0000000..094bf09 --- /dev/null +++ b/experiments/ghostwright/test/runner-contract.mjs @@ -0,0 +1,45 @@ +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import assert from 'node:assert/strict'; + +const cwd = fileURLToPath(new URL('..', import.meta.url)); +const runners = [ + ['Vitest', ['run', 'test:vitest']], + ['Jest', ['run', 'test:jest']], +]; +for (const [name, args] of runners) { + for (const negative of [false, true]) { + const directory = await mkdtemp(`${tmpdir()}/ghostwright-runner-`); + const result = spawnSync('pnpm', args, { + cwd, + encoding: 'utf8', + timeout: 30_000, + env: { + ...process.env, + NODE_OPTIONS: '--experimental-vm-modules', + GHOSTWRIGHT_NEGATIVE: negative ? '1' : '', + GHOSTWRIGHT_TRACES: directory, + GHOSTWRIGHT_CLEANUP_REPORT: `${directory}.cleanup.json`, + }, + }); + const output = result.stdout + result.stderr; + assert.equal(result.status, negative ? 1 : 0, `${name}: ${output}`); + if (name === 'Vitest') { + const cleanup = JSON.parse(await readFile(`${directory}.cleanup.json`, 'utf8')); + assert.equal(cleanup.closed, negative ? 3 : 1, output); + await rm(`${directory}.cleanup.json`); + } + const artifacts = await readdir(directory); + assert.equal(artifacts.length, negative ? 1 : 0, `${name}: artifacts ${artifacts}; ${output}`); + if (negative) { + assert.match(output, /Expected text containing/); + assert.match(output, /actual terminal text/); + const failure = await readFile(`${directory}/${artifacts[0]}/failure.txt`, 'utf8'); + assert.match(failure, /expected terminal text/); + } + await rm(directory, { recursive: true, force: true }); + } + console.info(`${name} integration: positive assertions and failure artifacts passed`); +} diff --git a/experiments/ghostwright/test/runner-fixtures/jest.config.mjs b/experiments/ghostwright/test/runner-fixtures/jest.config.mjs new file mode 100644 index 0000000..f092dd3 --- /dev/null +++ b/experiments/ghostwright/test/runner-fixtures/jest.config.mjs @@ -0,0 +1,5 @@ +export default { + testMatch: ['**/runner-fixtures/jest.fixture.mjs'], + transform: {}, + rootDir: '../..', +}; diff --git a/experiments/ghostwright/test/runner-fixtures/jest.fixture.mjs b/experiments/ghostwright/test/runner-fixtures/jest.fixture.mjs new file mode 100644 index 0000000..1ba6ca6 --- /dev/null +++ b/experiments/ghostwright/test/runner-fixtures/jest.fixture.mjs @@ -0,0 +1,37 @@ +import { expect, test } from '@jest/globals'; +// oxlint-disable-next-line import/no-unassigned-import -- Register Jest's terminal matchers. +import 'ghostwright/jest'; +import { withTerminal, regionLocator } from 'ghostwright'; + +test('real Jest assertions inspect terminal evidence', async () => { + await withTerminal( + { command: process.execPath, args: ['-e', 'process.stdout.write("hello Ryan")'], trace: 'off' }, + async ({ screen }) => { + const greeting = await screen.findByText('hello Ryan'); + expect(greeting).toBeVisible(); + expect(greeting).toContainText('Ryan'); + expect(greeting).not.toContainText('Ada'); + expect(screen.queryByText('missing')).not.toBeVisible(); + expect( + screen.getBy(regionLocator({ column: 200, row: 0, width: 1, height: 1 })), + ).not.toBeVisible(); + }, + ); +}); + +if (process.env.GHOSTWRIGHT_NEGATIVE) { + test('failure artifacts follow the callback outcome', async () => { + await withTerminal( + { + command: process.execPath, + args: ['-e', 'process.stdout.write("actual terminal text")'], + trace: { policy: 'retain-on-failure', directory: process.env.GHOSTWRIGHT_TRACES }, + }, + async ({ screen }) => { + expect(await screen.findByText('actual terminal text')).toContainText( + 'expected terminal text', + ); + }, + ); + }); +} diff --git a/experiments/ghostwright/test/runner-fixtures/vitest.config.ts b/experiments/ghostwright/test/runner-fixtures/vitest.config.ts new file mode 100644 index 0000000..e3e3e38 --- /dev/null +++ b/experiments/ghostwright/test/runner-fixtures/vitest.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from 'vitest/config'; +export default defineConfig({ + test: { include: ['test/runner-fixtures/vitest.fixture.mjs'], maxWorkers: 1 }, +}); diff --git a/experiments/ghostwright/test/runner-fixtures/vitest.fixture.mjs b/experiments/ghostwright/test/runner-fixtures/vitest.fixture.mjs new file mode 100644 index 0000000..e171572 --- /dev/null +++ b/experiments/ghostwright/test/runner-fixtures/vitest.fixture.mjs @@ -0,0 +1,100 @@ +import { expect, afterAll } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { writeFile } from 'node:fs/promises'; +import { test } from 'ghostwright/vitest'; +import { regionLocator, defineMatchers } from 'ghostwright'; +import { createRunnerMatchers } from 'ghostwright/matchers'; + +const owned = []; +let escapedLaunch; + +afterAll(async () => { + for (const acquiring of owned) { + const terminal = await acquiring; + expect(terminal.signal.aborted).toBe(true); + expect(terminal.process.status().state).toBe('closed'); + } + await expect(escapedLaunch({ command: '/bin/sh' })).rejects.toMatchObject({ + code: 'GW_SESSION_CLOSED', + }); + if (process.env.GHOSTWRIGHT_CLEANUP_REPORT) + await writeFile( + process.env.GHOSTWRIGHT_CLEANUP_REPORT, + JSON.stringify({ closed: owned.length }), + ); +}); + +test('the fixture owns acquisition before it completes', ({ launchTerminal }) => { + escapedLaunch = launchTerminal; + owned.push( + launchTerminal({ + command: process.execPath, + args: ['-e', 'process.stdin.resume()'], + trace: 'off', + }), + ); +}); + +expect.extend( + createRunnerMatchers( + defineMatchers({ + toShowGreeting: (region, name) => ({ + pass: region.text() === `hello ${name}`, + expected: `greeting for ${name}`, + actual: region.text(), + }), + }), + ), +); + +test('real Vitest assertions use frozen terminal evidence and local matchers', async ({ + launchTerminal, +}) => { + const terminal = await launchTerminal({ + command: process.execPath, + args: ['-e', 'process.stdout.write("hello Ryan")'], + trace: 'off', + }); + const { screen } = terminal; + const greeting = await screen.findByText('hello Ryan'); + expect(greeting).toBeVisible(); + expect(greeting).toShowGreeting('Ryan'); + expect(greeting).not.toContainText('Ada'); + expect(screen.queryByText('missing')).not.toBeVisible(); + expect( + screen.getBy(regionLocator({ column: 200, row: 0, width: 1, height: 1 })), + ).not.toBeVisible(); +}); + +if (process.env.GHOSTWRIGHT_NEGATIVE) { + test('one cleanup failure does not leak another terminal', async ({ launchTerminal }) => { + owned.push( + Promise.resolve( + await launchTerminal({ + command: process.execPath, + args: ['-e', 'process.stdin.resume()'], + trace: 'off', + }), + ), + ); + owned.push( + Promise.resolve( + await launchTerminal({ + command: process.execPath, + args: ['-e', 'process.stdin.resume()'], + trace: { policy: 'on', directory: fileURLToPath(import.meta.url) }, + }), + ), + ); + }); + test('failure artifacts follow the runner outcome', async ({ launchTerminal }) => { + const terminal = await launchTerminal({ + command: process.execPath, + args: ['-e', 'process.stdout.write("actual terminal text")'], + trace: { policy: 'retain-on-failure', directory: process.env.GHOSTWRIGHT_TRACES }, + }); + expect(await terminal.screen.findByText('actual terminal text')).toContainText( + 'expected terminal text', + ); + }); +} diff --git a/experiments/ghostwright/test/runtime-smoke.mjs b/experiments/ghostwright/test/runtime-smoke.mjs index cdf7fd0..b9494c8 100644 --- a/experiments/ghostwright/test/runtime-smoke.mjs +++ b/experiments/ghostwright/test/runtime-smoke.mjs @@ -1,7 +1,7 @@ -import { expectTerminal, withTerminalAsync } from '../dist/index.js'; -import { GhostwrightError } from '../src/errors.ts'; +import { expectTerminal, GhostwrightError, withTerminal } from '../dist/index.js'; +import { launchTerminal } from '../dist/async.js'; -await withTerminalAsync( +await withTerminal( { command: '/bin/sh', args: ['-c', 'printf runtime-smoke'], trace: 'off' }, async (terminal) => { await expectTerminal(terminal.getByText('runtime-smoke')).toBePresent(); @@ -14,5 +14,23 @@ await withTerminalAsync( } }, ); -// oxlint-disable-next-line no-console -- test script -console.log('Ghostwright runtime smoke passed'); +// Import acquisition through a different entry point to verify shared runtime identity. +const terminal = await launchTerminal({ + command: '/bin/sh', + args: ['-c', 'printf owned-smoke'], + trace: 'off', +}); +try { + await expectTerminal(terminal).toSatisfy((screen) => + screen.lines[0].text.includes('owned-smoke'), + ); + const found = await terminal.screen.findByText('owned-smoke'); + if (found.text() !== 'owned-smoke' || (await terminal.waitFor(() => false)) !== false) + throw new GhostwrightError({ + code: 'GW_RUNTIME_SMOKE', + message: 'Owned terminal query/wait contract failed', + }); +} finally { + await terminal[Symbol.asyncDispose](); +} +console.info('Ghostwright runtime smoke passed'); diff --git a/experiments/ghostwright/test/scoped.test.ts b/experiments/ghostwright/test/scoped.test.ts new file mode 100644 index 0000000..df8fa4f --- /dev/null +++ b/experiments/ghostwright/test/scoped.test.ts @@ -0,0 +1,389 @@ +import { expect, test } from 'bun:test'; +import { run } from 'effection'; +import { withTerminal as withEffectionTerminal } from '../src/effection/index.ts'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +// oxlint-disable-next-line no-restricted-imports -- mkdtemp and readdir return filesystem paths. +import { join } from 'node:path'; +import { + withTerminal, + defineLocator, + defineMatchers, + createExpect, + all, + textContains, + cursorInside, + edgeHasStyle, + sequence, + settled, + replayTrace, + type TerminalExtensionDefinition, + type TerminalLaunchOptions, + type RegionInspection, +} from '../src/index.ts'; + +// A protocol-compatible application, not a mocked terminal or client. Each key +// causes named render commits on the real PTY. No timers drive the scenario. +const application = String.raw` +process.stdin.setRawMode(true); +function render(frame, column, text, focused = false) { + const paint = '\x1b[2J\x1b[1;' + (column + 1) + 'H' + text; + const description = { frame, bounds: { column, row: 0, width: 8, height: 1 }, focused }; + const osc = '\x1b]7777;rig;v=1;' + Buffer.from(JSON.stringify(description)).toString('base64url') + '\x1b\\'; + return paint + osc; +} +process.stdin.on('data', bytes => { + for (const key of bytes.toString()) { + if (key === 'm') process.stdout.write(render(2, 10, 'Loading') + render(3, 20, 'Saved')); + if (key === 'f') process.stdout.write(render(2, 0, 'Ready', true)); + if (key === 'x') process.exit(0); + } +}); +process.stdout.write(render(1, 0, 'Ready')); +`; +interface Description { + frame: number; + bounds: { column: number; row: number; width: number; height: number }; + focused: boolean; +} +const extension: TerminalExtensionDefinition = { + id: 'rig', + osc: { + number: 7777, + namespace: 'rig', + maxBufferedBytes: 4096, + decode(message) { + const description: Description = JSON.parse( + Buffer.from(Buffer.from(message.payload).toString(), 'base64url').toString(), + ); + return { protocolFrame: description.frame, value: description }; + }, + }, +}; +const field = defineLocator('rig', 'field', (description) => [description.bounds]); +const launch = (): TerminalLaunchOptions => ({ + command: process.execPath, + args: ['-e', application], + extensions: [extension], + trace: 'off' as const, +}); + +test('capture preserves paired moving geometry and historical cursor evidence', async () => { + await withTerminal(launch(), async (t) => { + const before = await t.expect(field).toContainText('Ready'); + const recording = await t.capture( + { until: field.satisfies(textContains('Saved')) }, + async (scope) => { + await scope.keyboard.type('m'); + }, + ); + const described = recording.observations.filter((o) => o.kind === 'extension'); + expect(described.map((o) => field.resolve(o)[0]!.bounds.column)).toEqual([10, 20]); + expect(field.resolve(described[0]!)[0]!.text()).toContain('Loading'); + expect(field.resolve(described[1]!)[0]!.text()).toContain('Saved'); + expect(field.resolve(recording.baseline!)[0]!.text()).toContain('Ready'); + const current = await t.expect(field).toContainText('Saved'); + + // Saved cursor evidence stays paired with its original input geometry, + // even after the live application moves the field and cursor elsewhere. + const loading = field.resolve(described[0]!)[0]!; + expect(before.cursor().column).toBe('Ready'.length); + expect(loading.cursor().column).toBe(loading.bounds.column + 'Loading'.length); + expect(current.cursor().column).toBe(current.bounds.column + 'Saved'.length); + }); +}); + +test('a description cannot make a false visual assertion pass', async () => { + await withTerminal(launch(), async (t) => { + await t.expect(field).toContainText('Ready'); + const recording = await t.capture( + { until: field.satisfies(textContains('Ready')) }, + async (scope) => { + await scope.keyboard.type('f'); + }, + ); + const observation = recording.observations.at(-1)!; + const region = field.resolve(observation)[0]!; + expect(edgeHasStyle('top', { foreground: '#ffffff' })(region).pass).toBe(false); + }); +}); + +test('custom matchers compose terminal evidence and preserve typed arguments', async () => { + const expectRegion = createExpect().extend( + defineMatchers({ + toShow(actual: RegionInspection, text: string) { + return all(textContains(text), cursorInside({ visible: true }))(actual); + }, + }), + ); + await withTerminal(launch(), async (t) => { + await expectRegion(t, field).toShow('Ready'); + await t.expect(field).toContainText('Ready'); + }); +}); + +test('transition condition sees every commit even within one PTY output frame', async () => { + await withTerminal(launch(), async (t) => { + await t.expect(field).toContainText('Ready'); + const recording = await t.capture( + { + until: sequence( + field.satisfies(textContains('Loading')), + field.satisfies(textContains('Saved')), + ), + }, + async (scope) => { + await scope.keyboard.type('m'); + }, + ); + expect(recording.observations.at(-1)?.kind).toBe('extension'); + }); +}); + +test('capture aborts cooperative work, closes escaped handles, and leaves parent usable', async () => { + await withTerminal(launch(), async (t) => { + await t.expect(field).toContainText('Ready'); + const controller = new AbortController(); + const reason = new Error('cancel recording'); + let escaped: typeof t | undefined; + let callbackSignal: AbortSignal | undefined; + await expect( + t.capture( + { until: field.satisfies(textContains('Never')), signal: controller.signal }, + async (scope) => { + escaped = scope; + callbackSignal = scope.signal; + controller.abort(reason); + await new Promise(() => {}); // deliberately uncooperative: must not block teardown + }, + ), + ).rejects.toBe(reason); + expect(callbackSignal?.aborted).toBe(true); + await expect(escaped!.keyboard.type('m')).rejects.toThrow(); + await t.expect(field).toContainText('Ready'); + }); +}); + +test('condition completion does not abort action; callback failure remains primary', async () => { + await withTerminal(launch(), async (t) => { + await t.expect(field).toContainText('Ready'); + const failure = new Error('action failed'); + await expect( + t.capture({ until: field.satisfies(textContains('Saved')) }, async (scope) => { + await scope.keyboard.type('m'); + await scope.expect(field).toContainText('Saved'); + expect(scope.signal.aborted).toBe(false); + throw failure; + }), + ).rejects.toBe(failure); + await t.expect(field).toContainText('Saved'); + }); +}); + +test('capture overflow, timeout, and process exit fail distinctly', async () => { + await withTerminal(launch(), async (t) => { + await t.expect(field).toContainText('Ready'); + await expect( + t.capture( + { maxObservations: 1, until: field.satisfies(textContains('Never')) }, + async (scope) => { + await scope.keyboard.type('m'); + }, + ), + ).rejects.toMatchObject({ code: 'GW_CAPTURE_LIMIT' }); + await expect( + t.capture({ timeoutMs: 10, until: field.satisfies(textContains('Never')) }, async () => {}), + ).rejects.toMatchObject({ code: 'GW_CAPTURE_TIMEOUT' }); + await expect( + t.capture({ until: field.satisfies(textContains('Never')) }, async (scope) => { + await scope.keyboard.type('x'); + }), + ).rejects.toMatchObject({ code: 'GW_PROCESS_EXITED' }); + }); +}); + +test('capture stops at the first endpoint while its callback finishes later work', async () => { + await withTerminal(launch(), async (ui) => { + await ui.expect(field).toContainText('Ready'); + let callbackFinished = false; + let child: typeof ui | undefined; + const recording = await ui.capture( + { until: field.satisfies(textContains('Loading')) }, + async (scope) => { + child = scope; + // One application write contains Loading followed by Saved. The + // recording stops at Loading even though the callback waits for Saved. + await scope.keyboard.type('m'); + await scope.expect(field).toContainText('Saved'); + expect(scope.signal.aborted).toBe(false); + callbackFinished = true; + }, + ); + expect(callbackFinished).toBe(true); + expect(child!.signal.aborted).toBe(true); + expect( + recording.observations + .filter((o) => o.kind === 'extension') + .map((o) => field.resolve(o)[0]!.text().trim()), + ).toEqual(['Loading']); + expect(field.resolve(recording.observations.at(-1)!)[0]!.text()).toContain('Loading'); + await expect(child!.keyboard.type('x')).rejects.toThrow(); + await ui.expect(field).toContainText('Saved'); + }); +}); + +test('byte overflow aborts capture work without closing the parent terminal', async () => { + await withTerminal(launch(), async (ui) => { + await ui.expect(field).toContainText('Ready'); + let child: typeof ui | undefined; + await expect( + ui.capture({ maxBytes: 1, until: field.satisfies(textContains('Saved')) }, async (scope) => { + child = scope; + await scope.keyboard.type('m'); + await new Promise(() => {}); // Cancellation must not await an uncooperative callback. + }), + ).rejects.toMatchObject({ code: 'GW_CAPTURE_LIMIT' }); + expect(child!.signal.aborted).toBe(true); + await expect(child!.keyboard.type('x')).rejects.toThrow(); + await ui.expect(field).toContainText('Saved'); + await ui.keyboard.type('x'); + expect((await ui.process.waitForExit()).exitCode).toBe(0); + }); +}); + +test('canceling a nested capture leaves its outer capture running', async () => { + await withTerminal(launch(), async (ui) => { + await ui.expect(field).toContainText('Ready'); + const controller = new AbortController(); + const reason = new Error('cancel only the inner recording'); + let inner: typeof ui | undefined; + const recording = await ui.capture( + { until: field.satisfies(textContains('Saved')) }, + async (outer) => { + await expect( + outer.capture( + { signal: controller.signal, until: field.satisfies(textContains('Never')) }, + async (scope) => { + inner = scope; + controller.abort(reason); + await new Promise(() => {}); + }, + ), + ).rejects.toBe(reason); + expect(inner!.signal.aborted).toBe(true); + expect(outer.signal.aborted).toBe(false); + await expect(inner!.keyboard.type('x')).rejects.toThrow(); + await outer.keyboard.type('m'); + }, + ); + expect(field.resolve(recording.observations.at(-1)!)[0]!.text()).toContain('Saved'); + await ui.expect(field).toContainText('Saved'); + }); +}); + +test('the capture deadline still owns callback work after the endpoint', async () => { + await withTerminal(launch(), async (ui) => { + await ui.expect(field).toContainText('Ready'); + let reachedEndpoint = false; + let child: typeof ui | undefined; + await expect( + ui.capture( + { timeoutMs: 250, until: field.satisfies(textContains('Loading')) }, + async (scope) => { + child = scope; + await scope.keyboard.type('m'); + await scope.expect(field).toContainText('Saved'); + reachedEndpoint = true; + await new Promise(() => {}); + }, + ), + ).rejects.toMatchObject({ code: 'GW_CAPTURE_TIMEOUT' }); + expect(reachedEndpoint).toBe(true); + expect(child!.signal.aborted).toBe(true); + await ui.expect(field).toContainText('Saved'); + }); +}); + +test('an already drawn region can settle without a new application commit', async () => { + await withTerminal(launch(), async (ui) => { + await ui.expect(field).toContainText('Ready'); + const capture = await ui.capture({ until: settled(field, 20) }, async () => {}); + expect(capture.observations).toHaveLength(0); + expect(field.resolve(capture.baseline)[0]!.text()).toContain('Ready'); + }); +}); + +test('a transition can compose with settlement without another commit', async () => { + await withTerminal(launch(), async (ui) => { + await ui.expect(field).toContainText('Ready'); + const capture = await ui.capture( + { until: sequence(field.satisfies(textContains('Saved')), settled(field, 20)) }, + async (child) => { + await child.keyboard.type('m'); + }, + ); + expect(field.resolve(capture.observations.at(-1)!)[0]!.text()).toContain('Saved'); + }); +}); + +test('runner rejection helpers can reenter a capture executor', async () => { + await withTerminal(launch(), async (ui) => { + await ui.expect(field).toContainText('Ready'); + await ui.capture({ until: field.satisfies(textContains('Saved')) }, async (child) => { + await expect( + child.revisions.collect({ + since: child.screen.current().sequence, + until: () => false, + timeoutMs: 10, + }), + ).rejects.toBeInstanceOf(Error); + await child.keyboard.type('m'); + }); + }); +}); + +test('native Effection capture uses the same matcher and recording core', async () => { + await run(function* () { + yield* withEffectionTerminal(launch(), function* (ui) { + yield* ui.expect(field).toContainText('Ready'); + const capture = yield* ui.capture( + { until: field.satisfies(textContains('Saved')) }, + function* (child) { + yield* child.keyboard.type('m'); + }, + ); + expect(field.resolve(capture.observations.at(-1)!)[0]!.text()).toContain('Saved'); + }); + }); +}); + +test('trace replay uses the live description pairing path', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ghostwright-paired-')); + try { + await withTerminal({ ...launch(), trace: { policy: 'on', directory } }, async (t) => { + await t.expect(field).toContainText('Ready'); + await t.keyboard.type('m'); + await t.expect(field).toContainText('Saved'); + }); + const path = join(directory, (await readdir(directory))[0]!); + await expect(replayTrace(path)).rejects.toThrow('requires extension decoder'); + const replay = await replayTrace(path, { extensions: [extension] }); + expect( + replay.observations + .filter((o) => o.kind === 'extension') + .map((o) => field.resolve(o)[0]!.text().trim()), + ).toEqual(['Ready', 'Loading', 'Saved']); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +test('settlement completes without requiring new output', async () => { + await withTerminal(launch(), async (t) => { + await t.expect(field).toContainText('Ready'); + const result = await t.capture({ until: settled(field, 10) }, async (scope) => { + await scope.keyboard.type('f'); + }); + expect(result.observations.length).toBeGreaterThan(0); + }); +}); diff --git a/experiments/ghostwright/test/screen-locator.test.ts b/experiments/ghostwright/test/screen-locator.test.ts new file mode 100644 index 0000000..a05c9fe --- /dev/null +++ b/experiments/ghostwright/test/screen-locator.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from 'bun:test'; +import { + defineScreenLocator, + type Observation, + type ScreenSnapshot, + type RegionInspection, + type Rect, +} from '../src/index.ts'; +import { GhosttyWasmTerminal } from '../src/terminal/wasm.ts'; + +function observation(screen: ScreenSnapshot): Observation { + return { kind: 'screen', screen, sequence: screen.sequence, timestamp: screen.timestamp }; +} + +test('a screen locator re-resolves geometry while historical matches keep their own cells', async () => { + const marks = defineScreenLocator('visible x cells', (screen) => + screen.lines.flatMap((line) => + line.cells + .filter((cell) => cell.text === 'x' && !cell.style.invisible) + .map((cell) => ({ column: cell.column, row: line.row, width: 1, height: 1 })), + ), + ); + const terminal = await GhosttyWasmTerminal.create({ + columns: 20, + rows: 4, + widthPixels: 200, + heightPixels: 80, + }); + try { + terminal.write(Buffer.from('xAxB')); + const before = observation(terminal.snapshot()); + terminal.write(Buffer.from('\x1b[2J\x1b[3;8HxC')); + const after = observation(terminal.snapshot()); + + expect(marks.resolve(before).map((region) => region.bounds.column)).toEqual([0, 2]); + expect(marks.nth(1).resolve(before)[0]!.text()).toBe('x'); + expect(marks.resolve(after)[0]!.bounds).toEqual({ column: 7, row: 2, width: 1, height: 1 }); + expect(marks.resolve(before)[0]!.bounds.column).toBe(0); + expect(marks.nth(1).resolve(after)).toEqual([]); + + // The resolver defines the relationship. Here the child is the next + // cell, not a cell geometrically contained by its parent. + const nextCell = (parent: RegionInspection): Rect[] => [ + { ...parent.bounds, column: parent.bounds.column + 1 }, + ]; + const letters = marks.derive('next cell', nextCell); + expect(letters.resolve(before).map((region) => region.text())).toEqual(['A', 'B']); + expect(letters.nth(1).resolve(before)[0]!.text()).toBe('B'); + expect(marks.nth(1).derive('next cell', nextCell).resolve(before)[0]!.text()).toBe('B'); + expect(letters.resolve(after).map((region) => region.text())).toEqual(['C']); + } finally { + terminal.free(); + } +}); + +test('screen-derived geometry crosses the same validation boundary as described geometry', async () => { + const invalid = defineScreenLocator('invalid region', () => [ + { column: 0, row: 0, width: -1, height: 1 }, + ]); + const terminal = await GhosttyWasmTerminal.create({ + columns: 20, + rows: 4, + widthPixels: 200, + heightPixels: 80, + }); + try { + expect(() => invalid.resolve(observation(terminal.snapshot()))).toThrow( + 'Region requires integer coordinates and nonnegative dimensions', + ); + } finally { + terminal.free(); + } +}); diff --git a/experiments/ghostwright/test/session.test.ts b/experiments/ghostwright/test/session.test.ts index 7792c03..eb94037 100644 --- a/experiments/ghostwright/test/session.test.ts +++ b/experiments/ghostwright/test/session.test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'bun:test'; -import { expectTerminal, withTerminalAsync } from '../src'; +import { expectTerminal, withTerminal } from '../src'; test('launches under a real PTY and snapshots output', async () => { - await withTerminalAsync( + await withTerminal( { command: '/bin/sh', args: ['-c', `test -t 0 && test -t 1 && test -t 2 && printf 'TTY READY'`], @@ -17,7 +17,7 @@ test('launches under a real PTY and snapshots output', async () => { ); }); test('resizes the kernel PTY without synthetic input', async () => { - await withTerminalAsync( + await withTerminal( { command: '/bin/sh', args: ['-c', `trap 'stty size; exit' WINCH; printf READY; while :; do sleep 1; done`], diff --git a/experiments/ghostwright/test/wait-for.test.ts b/experiments/ghostwright/test/wait-for.test.ts new file mode 100644 index 0000000..8a8d537 --- /dev/null +++ b/experiments/ghostwright/test/wait-for.test.ts @@ -0,0 +1,223 @@ +import { expect, test } from 'bun:test'; +import { launchTerminal } from '../src/index.ts'; + +const options = { + command: process.execPath, + args: [ + '-e', + String.raw` + process.stdin.setRawMode(true); + process.stdin.on('data', bytes => { + if (bytes.includes(120)) process.exit(0); + process.stdout.write('\r\nCHANGED'); + }); + process.stdout.write('READY'); + `, + ], + trace: 'off' as const, +}; + +function gate(): { promise: Promise; resolve(value: T): void; reject(error: unknown): void } { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +test('waitFor returns any normal result, including false, and awaits promises', async () => { + await using terminal = await launchTerminal(options); + const { waitFor } = terminal; + expect(await waitFor(() => false, { timeoutMs: 0 })).toBe(false); + expect(await waitFor(() => undefined)).toBeUndefined(); + expect(await waitFor(async () => 42)).toBe(42); +}); + +test('an observation retries a failed assertion without waiting for the interval', async () => { + await using terminal = await launchTerminal(options); + await terminal.screen.findByText('READY'); + const checked = gate(); + const finding = terminal.waitFor( + () => { + checked.resolve(); + return terminal.screen.getByText('CHANGED'); + }, + { intervalMs: 60_000, timeoutMs: 1000 }, + ); + await checked.promise; + await terminal.keyboard.type('c'); + expect((await finding).text()).toBe('CHANGED'); +}); + +test('interval retries notice nonterminal state even after the child exits', async () => { + await using terminal = await launchTerminal(options); + await terminal.screen.findByText('READY'); + await terminal.keyboard.type('x'); + await terminal.process.waitForExit(); + const checked = gate(); + let ready = false; + const waiting = terminal.waitFor( + () => { + checked.resolve(); + expect(ready).toBe(true); + return 'external state ready'; + }, + { intervalMs: 5 }, + ); + await checked.promise; + ready = true; + expect(await waiting).toBe('external state ready'); +}); + +test('pending async callbacks never overlap despite terminal observations', async () => { + await using terminal = await launchTerminal(options); + await terminal.screen.findByText('READY'); + const entered = gate(); + const release = gate(); + let active = false; + let overlapped = false; + let ready = false; + const waiting = terminal.waitFor( + async () => { + if (active) overlapped = true; + active = true; + entered.resolve(); + try { + if (!ready) await release.promise; + expect(ready).toBe(true); + return 'done'; + } finally { + active = false; + } + }, + { intervalMs: 0 }, + ); + await entered.promise; + await terminal.keyboard.type('c'); + await terminal.screen.findByText('CHANGED'); + ready = true; + release.resolve(); + expect(await waiting).toBe('done'); + expect(overlapped).toBe(false); +}); + +test('a rejected async callback can recover on a later attempt', async () => { + await using terminal = await launchTerminal(options); + await terminal.screen.findByText('READY'); + const entered = gate(); + const first = gate(); + let ready = false; + const waiting = terminal.waitFor( + () => { + entered.resolve(); + return ready ? Promise.resolve('recovered') : first.promise; + }, + { intervalMs: 5 }, + ); + await entered.promise; + ready = true; + first.reject(new Error('not ready yet')); + expect(await waiting).toBe('recovered'); +}); + +test('an already aborted signal prevents the first callback', async () => { + await using terminal = await launchTerminal(options); + const reason = new Error('already canceled'); + let called = false; + await expect( + terminal.waitFor( + () => { + called = true; + }, + { signal: AbortSignal.abort(reason) }, + ), + ).rejects.toBe(reason); + expect(called).toBe(false); +}); + +test('async rejection retries and timeout preserves the last assertion as its cause', async () => { + await using terminal = await launchTerminal(options); + await terminal.screen.findByText('READY'); + const failure = new Error('expected saved status'); + await expect( + terminal.waitFor( + async () => { + throw failure; + }, + { timeoutMs: 20, intervalMs: 5 }, + ), + ).rejects.toMatchObject({ + code: 'GW_WAIT_TIMEOUT', + cause: failure, + message: expect.stringContaining('READY'), + }); + await expect( + terminal.waitFor(() => new Promise(() => {}), { timeoutMs: 20 }), + ).rejects.toMatchObject({ code: 'GW_WAIT_TIMEOUT' }); +}); + +test('external cancellation stops attempts without closing the terminal', async () => { + await using terminal = await launchTerminal(options); + await terminal.screen.findByText('READY'); + const controller = new AbortController(); + const entered = gate(); + const reason = new Error('stop this wait'); + const waiting = terminal + .waitFor( + () => { + entered.resolve(); + return new Promise(() => {}); + }, + { signal: controller.signal }, + ) + .catch((error: unknown) => error); + await entered.promise; + controller.abort(reason); + expect(await waiting).toBe(reason); + expect(terminal.signal.aborted).toBe(false); + await terminal.keyboard.type('c'); + expect((await terminal.screen.findByText('CHANGED')).text()).toBe('CHANGED'); +}); + +test('scope disposal cancels pending waits and rejects escaped queries', async () => { + const terminal = await launchTerminal(options); + await terminal.screen.findByText('READY'); + const entered = gate(); + const waiting = terminal + .waitFor(() => { + entered.resolve(); + return new Promise(() => {}); + }) + .catch((error: unknown) => error); + await entered.promise; + await terminal[Symbol.asyncDispose](); + expect(await waiting).toBeInstanceOf(Error); + expect(() => terminal.screen.getByText('READY')).toThrow(); + await expect(terminal.waitFor(() => true)).rejects.toBeInstanceOf(Error); +}); + +for (const value of [-1, Infinity, NaN, 2_147_483_648]) { + test(`invalid wait durations fail before running the callback: ${value}`, async () => { + await using terminal = await launchTerminal(options); + let called = false; + await expect( + terminal.waitFor( + () => { + called = true; + }, + { timeoutMs: value }, + ), + ).rejects.toMatchObject({ code: 'GW_INVALID_OPTIONS' }); + await expect( + terminal.waitFor( + () => { + called = true; + }, + { intervalMs: value }, + ), + ).rejects.toMatchObject({ code: 'GW_INVALID_OPTIONS' }); + expect(called).toBe(false); + }); +} diff --git a/experiments/ghostwright/tsconfig.types.json b/experiments/ghostwright/tsconfig.types.json new file mode 100644 index 0000000..734f170 --- /dev/null +++ b/experiments/ghostwright/tsconfig.types.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "noEmit": true, + "emitDeclarationOnly": false, + "rootDir": ".", + "types": ["node", "bun"] + }, + "include": [ + "src/**/*.ts", + "type-tests/**/*.ts", + "test/**/*.ts", + "examples/**/*.ts", + "scripts/**/*.ts" + ] +} diff --git a/experiments/ghostwright/type-tests/api.ts b/experiments/ghostwright/type-tests/api.ts new file mode 100644 index 0000000..556008c --- /dev/null +++ b/experiments/ghostwright/type-tests/api.ts @@ -0,0 +1,53 @@ +import { + createExpect, + defineMatchers, + defineLocator, + defineScreenLocator, + textContains, + type AsyncExecution, + type RegionInspection, + type EffectionTerminal, +} from '../src/index.ts'; + +declare const ui: AsyncExecution; +declare const native: EffectionTerminal; +const cursor = defineScreenLocator('cursor cell', (screen) => [ + { ...screen.cursor, width: 1, height: 1 }, +]); +ui.expect(cursor).toContainCursor({ visible: true }); +// @ts-expect-error A screen resolver returns regions synchronously, not pending work. +defineScreenLocator('async resolver', async () => []); +const field = defineLocator<{ + bounds: { column: number; row: number; width: number; height: number }; +}>('test', 'field', (description) => [description.bounds]); +const expect = createExpect().extend( + defineMatchers({ + toShow(actual: RegionInspection, text: string) { + return textContains(text)(actual); + }, + }), +); +const firstCell = field.derive('first cell', (parent) => [{ ...parent.bounds, width: 1 }]); +expect(ui, firstCell).toShow('h'); +// @ts-expect-error Child resolution is synchronous, just like root resolution. +field.derive('async child', async () => []); +// @ts-expect-error A child resolver returns terminal rectangles, not snapshots. +field.derive('wrong result', (parent) => [parent.screen]); +expect(ui, field).toShow('hello'); +expect(ui, field).toContainText('hello'); +expect.operation(native, field).toShow('hello'); +// @ts-expect-error Operation facade keeps custom argument types too. +expect.operation(native, field).toShow(12); +// @ts-expect-error Custom matcher argument types survive extension. +expect(ui, field).toShow(12); +// @ts-expect-error Registration is local, not global declaration merging. +ui.expect(field).toShow('hello'); +// @ts-expect-error Query construction cannot execute an action. +field.click(); +// @ts-expect-error A matcher must provide diagnostics, not only a boolean. +defineMatchers({ toBeMagic: (_actual: RegionInspection) => true }); +ui.capture({ until: field.satisfies(textContains('done')) }, async (capture) => { + const signal: AbortSignal = capture.signal; + await capture.keyboard.type('hello'); + void signal; +}); diff --git a/experiments/ghostwright/type-tests/queries.ts b/experiments/ghostwright/type-tests/queries.ts new file mode 100644 index 0000000..b822abf --- /dev/null +++ b/experiments/ghostwright/type-tests/queries.ts @@ -0,0 +1,41 @@ +import { expect as jestExpect } from '@jest/globals'; +import { expect as vitestExpect } from 'vitest'; +// oxlint-disable-next-line import/no-unassigned-import -- Check the public matcher augmentation. +import '../src/jest.ts'; +// oxlint-disable-next-line import/no-unassigned-import -- Check the public matcher augmentation. +import '../src/vitest.ts'; +import { type launchTerminal, regionLocator, type RegionInspection } from '../src/index.ts'; +import { withTerminal } from '../src/effection/index.ts'; +import type { Operation } from 'effection'; + +declare const terminal: Awaited>; +const recipe = regionLocator({ column: 0, row: 0, width: 10, height: 1 }); +const region: RegionInspection = terminal.screen.getBy(recipe); +const optional: RegionInspection | null = terminal.screen.queryBy(recipe); +const regions: readonly RegionInspection[] = terminal.screen.queryAllBy(recipe); +const pending: Promise = terminal.screen.findBy(recipe); +const value: Promise = terminal.waitFor(async () => 42); +void [optional, regions, pending, value]; +jestExpect(region).toContainText('ready'); +vitestExpect(region).toContainText('ready'); +// @ts-expect-error Matcher arguments retain their types through runner integration. +jestExpect(region).toContainText(42); +// @ts-expect-error Matcher arguments retain their types through runner integration. +vitestExpect(region).toHaveEdgeStyle('center', {}); +// @ts-expect-error Query results are evidence, not live recipes. +terminal.screen.findBy(region); +// @ts-expect-error Query arrays cannot be mutated. +regions.push(region); +// @ts-expect-error Query results do not own a disposable lifetime. +region[Symbol.asyncDispose](); +// @ts-expect-error Standard mouse reports do not encode Super/Command. +terminal.mouse.hover(recipe, { super: true }); +void terminal.mouse.drag(recipe, { by: { columns: 8, rows: 0 } }); + +const native: Operation = withTerminal({ command: '/bin/sh' }, function* (ui) { + const found: RegionInspection = yield* ui.screen.findBy(recipe); + const count: number = yield* ui.waitFor(async () => 42); + void count; + return found.text(); +}); +void native; diff --git a/knip.jsonc b/knip.jsonc new file mode 100644 index 0000000..2608fc2 --- /dev/null +++ b/knip.jsonc @@ -0,0 +1,9 @@ +{ + "workspaces": { + "experiments/ghostwright": { + // src/jest.ts augments this package's Matchers interface. TypeScript + // needs the direct dependency; Knip does not count module augmentation. + "ignoreDependencies": ["expect"], + }, + }, +} diff --git a/package.json b/package.json index 6a7bd87..728d067 100644 --- a/package.json +++ b/package.json @@ -20,23 +20,21 @@ "publishConfig": { "access": "public" }, - "pnpm": { - "overrides": { - "@bomb.sh/tty": "https://pkg.pr.new/@bomb.sh/tty@103" - } - }, "scripts": { "playground": "NODE_NO_WARNINGS=1 node --experimental-transform-types ./scripts/playground.ts", "format": "bsh format", "format:check": "bsh format --check", "lint": "bsh lint .", - "test": "bsh test --exclude 'experiments/ghostwright/**' --exclude 'packages/**'" + "typecheck": "pnpm --filter ghostwright build && tsc --noEmit && pnpm -r --if-present run typecheck", + "test": "vitest run --exclude '**/node_modules/**' --exclude 'experiments/ghostwright/**' --exclude 'packages/**'" }, "devDependencies": { "@bomb.sh/args": "catalog:", "@bomb.sh/tools": "^0.6.1", "@clack/prompts": "catalog:", - "@types/node": "^22" + "@types/node": "^22", + "typescript": "^5.9.3", + "vitest": "^4.1.9" }, "devEngines": { "packageManager": { @@ -49,5 +47,10 @@ "version": "22.14.0", "onFail": "error" } + }, + "pnpm": { + "overrides": { + "@bomb.sh/tty": "https://pkg.pr.new/@bomb.sh/tty@103" + } } } diff --git a/packages/clack-tty/package.json b/packages/clack-tty/package.json index 6a864e5..5cf16b5 100644 --- a/packages/clack-tty/package.json +++ b/packages/clack-tty/package.json @@ -1,15 +1,16 @@ { "name": "@ghostwright/clack-tty", "version": "0.1.0", - "description": "Semantic tree locator for clack/ui applications, tested with ghostwright", "private": true, + "description": "Semantic tree locator for clack/ui applications, tested with ghostwright", "license": "MIT", "type": "module", "exports": { ".": "./src/index.ts", "./auto": "./src/auto.ts", "./protocol": "./src/protocol.ts", - "./producer": "./src/producer.ts" + "./producer": "./src/producer.ts", + "./vitest": "./src/vitest.ts" }, "scripts": { "test": "vitest run" @@ -26,6 +27,14 @@ "tsx": "^4.19.0", "vitest": "^4.1.9" }, + "peerDependencies": { + "vitest": ">=3" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + }, "devEngines": { "packageManager": { "name": "pnpm", diff --git a/packages/clack-tty/src/auto.ts b/packages/clack-tty/src/auto.ts index bb1e901..a4a17ff 100644 --- a/packages/clack-tty/src/auto.ts +++ b/packages/clack-tty/src/auto.ts @@ -29,4 +29,5 @@ const semanticAuto: UIExtension = (context) => { }); }; +// oxlint-disable-next-line import/no-default-export -- clack/ui package extension loader expects a default export export default semanticAuto; diff --git a/packages/clack-tty/src/expectations.ts b/packages/clack-tty/src/expectations.ts index befaa9c..aff05e5 100644 --- a/packages/clack-tty/src/expectations.ts +++ b/packages/clack-tty/src/expectations.ts @@ -1,42 +1,26 @@ -/** - * 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'; +import { + all, + createExpect, + cursorInside, + defineMatchers, + edgeHasStyle, + textHasStyle, + type RegionInspection, +} from 'ghostwright'; -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`, - ); -} +/** Clack's visual contracts. These consume terminal evidence, never node state. */ +export const clackMatchers = defineMatchers({ + toHaveInputFocus(actual: RegionInspection) { + return all( + edgeHasStyle('top', { foreground: '#ffffff' }), + edgeHasStyle('bottom', { foreground: '#ffffff' }), + edgeHasStyle('left', { foreground: '#ffffff' }), + edgeHasStyle('right', { foreground: '#ffffff' }), + cursorInside({ visible: true }), + )(actual); + }, + toHaveButtonFocus(actual: RegionInspection, label: string) { + return textHasStyle(label, { foreground: '#ffffff' })(actual); + }, +}); +export const expectUI = createExpect().extend(clackMatchers); diff --git a/packages/clack-tty/src/extension.ts b/packages/clack-tty/src/extension.ts index e51cd12..89493c6 100644 --- a/packages/clack-tty/src/extension.ts +++ b/packages/clack-tty/src/extension.ts @@ -1,121 +1,78 @@ -/** - * 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, +import { + defineLocator, + GhostwrightError, + InvalidOptionsError, + type RegionLocator, + type TerminalExtensionDefinition, } from 'ghostwright'; import { CLACK_TTY_NAMESPACE, CLACK_TTY_OSC, decodeFrame, - type ClackFrameV1, - type ClackNodeV1, - type Rect as ProtocolRect, + type ClackFrame, + type ClackNode, } 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 ID = 'ghostwright.clack-tty'; const fail = (code: string, message: string): never => { - throw new GhostwrightError({ code, message: message.slice(0, 1024) }); + throw new GhostwrightError({ code, message }); }; - -/** 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 { +interface Element extends ClackNode { 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[], - })); +function materialize(frame: ClackFrame): Element[] { + const nodes: Element[] = frame.nodes.map((node) => ({ ...node, parentNode: null, children: [] })); const byKey = new Map(nodes.map((node) => [node.key, node])); for (const node of nodes) { - const parent = node.parent ? byKey.get(node.parent) : undefined; + const parent = node.parent === null ? undefined : byKey.get(node.parent); 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; + const ordered: Element[] = []; + function visit(siblings: Element[]): void { + siblings.sort((a, b) => a.order - b.order); + for (const node of siblings) { + ordered.push(node); + visit(node.children); + } + } + visit(nodes.filter((node) => !node.parentNode)); + return ordered; } - 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; + if (name === 'input') return node.attrs.input ? 'true' : undefined; + const customKey = name === 'type' ? 'type' : name.startsWith('data-') ? name.slice(5) : undefined; + const custom = node.attrs.custom; 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; + : customKey !== undefined && custom && Object.hasOwn(custom, customKey) + ? custom[customKey] + : 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; + prevElementSibling(node) { + const siblings = node.parentNode?.children ?? [node]; + return siblings[siblings.indexOf(node) - 1] ?? 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(' '), + [node.attrs.label ?? '', ...node.children.map((child) => adapter.getText(child))].join(' '), removeSubsets: (nodes) => nodes.filter( (node) => @@ -127,18 +84,6 @@ const adapter: NonNullable['adapter']> = { ), 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', @@ -155,214 +100,127 @@ const allowedPseudos = new Set([ '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[][] = []; + if (new TextEncoder().encode(source).length > 4096) + fail('GW_CLACK_SELECTOR_LIMIT', 'Selector exceeds 4096 bytes'); + let ast: Selector[][]; try { ast = parse(source); } catch { - fail('GW_CLACK_SELECTOR_INVALID', 'Malformed semantic selector'); + return fail('GW_CLACK_SELECTOR_INVALID', 'Malformed 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'); + // oxlint-disable-next-line bombshell-dev/max-params -- traversal tracks independent selector depth limits + function visit(lists: Selector[][], depth: number, hasDepth: number): void { branches += lists.length; - if (branches > LIMITS.selectorBranches) - fail('GW_CLACK_SELECTOR_LIMIT', 'Selector list exceeds limit'); + if (depth > 8 || branches > 32) + fail('GW_CLACK_SELECTOR_LIMIT', 'Selector nesting/list limit exceeded'); 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) + if (++tokens > 256) fail('GW_CLACK_SELECTOR_LIMIT', 'Selector token limit exceeded'); + if ( + token.type === SelectorType.PseudoElement || + token.type === SelectorType.Parent || + token.type === SelectorType.ColumnCombinator + ) + fail('GW_CLACK_SELECTOR_INVALID', 'Unsupported selector traversal'); + if ( + token.type === SelectorType.Attribute && + (token.action === AttributeAction.Not || + ['focused', 'focusable', 'focus-root', 'visible'].includes(token.name)) + ) fail( 'GW_CLACK_SELECTOR_INVALID', - 'The nonstandard != attribute operator is not supported', + 'State selectors are not terminal evidence; use a matcher', ); if (token.type === SelectorType.Pseudo) { if (!allowedPseudos.has(token.name)) fail( 'GW_CLACK_SELECTOR_INVALID', - `Pseudo-class :${token.name} is not supported`, + `Unsupported pseudo-class :${token.name}; use terminal matchers for visual state`, ); - if (token.name === 'has' && hasDepth >= LIMITS.hasDepth) - fail('GW_CLACK_SELECTOR_LIMIT', `Nested :has() exceeds depth ${LIMITS.hasDepth}`); + if (token.name === 'has' && hasDepth >= 2) + fail('GW_CLACK_SELECTOR_LIMIT', 'Nested :has exceeds limit'); if (Array.isArray(token.data)) - visit(token.data, depth + 1, token.name === 'has' ? hasDepth + 1 : hasDepth); + visit(token.data, depth + 1, hasDepth + (token.name === 'has' ? 1 : 0)); } } - }; + } visit(ast, 0, 0); return ast; } -function bridgeRect(node: ClackNodeV1): ProtocolRect | undefined { - return node.geo?.visible ?? node.geo?.term; +/** DOM queries retain node identity until the final region is inspected. */ +export interface ClackLocator extends RegionLocator { + /** Search strict descendants of the current matches, not their cell bounds. */ + locator(source: string): ClackLocator; + nth(index: number): ClackLocator; } -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`, +const selectorOptions: Options = { adapter, xmlMode: true, cacheResults: false }; +type NodeQuery = (document: readonly Element[]) => readonly Element[]; + +function treeLocator(source: string, select: NodeQuery): ClackLocator { + const regions = defineLocator(ID, source, (frame) => + select(materialize(frame)).map((node) => { + if (!node.geo) + return fail('GW_CLACK_NO_GEOMETRY', `${source}: ${node.key}/${node.name} has no geometry`); + return node.geo.term; + }), + ); + return Object.freeze({ + ...regions, + nth(index: number): ClackLocator { + if (!Number.isSafeInteger(index) || index < 0) + throw new InvalidOptionsError('Locator index must be nonnegative'); + return treeLocator(`${source}.nth(${index})`, (document) => + select(document).slice(index, index + 1), ); - 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); - } + }, + locator(childSource: string): ClackLocator { + // Validate at construction, including selector expressions that fail compilation. + compile(selector(childSource), selectorOptions); + return treeLocator(`${source} >> ${childSource}`, (document) => { + const parents = select(document); + if (!parents.length) return []; + const roots = new Set(parents); + // css-select binds :scope/relative selectors to these nodes and mutates + // parsed tokens. Compile fresh tokens for this observation's context. + const matches = compile(childSource, selectorOptions, [...parents]); + return document.filter((node) => { + if (!matches(node)) return false; + for (let ancestor = node.parentNode; ancestor; ancestor = ancestor.parentNode) { + if (roots.has(ancestor)) return true; + } + return false; + }); + }); + }, + }); } -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); - } +/** Construct a reusable, session-free query. Resolution never reads a live UI. */ +export function locator(source: string): ClackLocator { + const predicate = compile(selector(source), selectorOptions); + return treeLocator(source, (document) => document.filter(predicate)); } -/** Ghostwright extension definition for the clack.ui semantic tree (REQ-015). */ -export function clackTtyExtension(): TerminalExtensionDefinition< - ClackTtySession, - ClackFrameV1 -> { +/** Pure decoder shared by live sessions and replay. */ +export function clackTtyExtension(): TerminalExtensionDefinition { return { - id: 'ghostwright.clack-tty', + id: ID, osc: { number: CLACK_TTY_OSC, namespace: CLACK_TTY_NAMESPACE, maxBufferedBytes: 1024 * 1024, - decode(message: RegisteredOscMessage) { + decode(message) { if (message.parameters.length !== 1 || message.parameters[0] !== 'v=1') - fail('GW_CLACK_VERSION', 'Unsupported semantic envelope version'); - return decodeFrame(message.payload); + fail('GW_CLACK_VERSION', 'Unsupported envelope version'); + const frame = decodeFrame(message.payload); + return { protocolFrame: frame.frame, value: frame }; }, }, - 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 index 52e37f9..edbfca5 100644 --- a/packages/clack-tty/src/index.ts +++ b/packages/clack-tty/src/index.ts @@ -1,3 +1,3 @@ -export { clackTtyExtension, ClackTtyLocator, ClackTtySession, type TreeMatch } from './extension.ts'; +export { clackTtyExtension, locator, type ClackLocator } from './extension.ts'; export { useSemantic, type SemanticOptions } from './producer.ts'; -export { expectFocused, expectTreeCondition } from './expectations.ts'; +export { clackMatchers, expectUI } from './expectations.ts'; diff --git a/packages/clack-tty/src/producer.ts b/packages/clack-tty/src/producer.ts index b9081e5..aa1d765 100644 --- a/packages/clack-tty/src/producer.ts +++ b/packages/clack-tty/src/producer.ts @@ -7,7 +7,7 @@ * 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. + * property bag at frame time. Focus, value, and cursor assertions use terminal evidence. * * Emission is opt-in and render-driven: `useSemantic` installs a render * observer via `RenderApi.around`. Each committed render emits exactly one @@ -16,7 +16,6 @@ */ 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'; @@ -24,8 +23,8 @@ import { encodeFrame, geometryFor, LIMITS, - type ClackFrameV1, - type ClackNodeV1, + type ClackFrame, + type ClackNode, type JsonScalar, } from './protocol.ts'; @@ -71,6 +70,8 @@ function siblingOrder(entry: Entry): number { return order; } +// oxlint-disable bombshell-dev/exported-function-async -- Render middleware must be installed synchronously. +/** Install semantic emission before the host's first synchronous render. */ export function useSemantic(host: Host, options: SemanticOptions): void { const entries = new Map(); @@ -96,7 +97,8 @@ export function useSemantic(host: Host, options: SemanticOptions): void { } function unregisterEntry(entry: Entry): void { - for (const child of entry.children) unregisterEntry(child); + // oxlint-disable-next-line unicorn/no-useless-spread -- unregister mutates this array + for (const child of [...entry.children]) unregisterEntry(child); entries.delete(entry.node); if (entry.parent) { const index = entry.parent.children.indexOf(entry); @@ -116,15 +118,6 @@ export function useSemantic(host: Host, options: SemanticOptions): void { 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. @@ -138,30 +131,22 @@ export function useSemantic(host: Host, options: SemanticOptions): void { 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[] = []; + ): ClackNode[] { + const nodes: ClackNode[] = []; + // oxlint-disable-next-line bombshell-dev/max-params -- traversal carries parent identity and sibling order 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; + const custom: [string, JsonScalar][] = []; + 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 === 'type' && typeof value === 'string') custom.push(['type', value]); else if (name.startsWith('data-') && value !== null && value !== undefined) - custom[name.slice(5)] = value as JsonScalar; + custom.push([name.slice(5), value as JsonScalar]); } const bounds = info.get(entry.key)?.bounds; const geo = bounds @@ -179,10 +164,8 @@ export function useSemantic(host: Host, options: SemanticOptions): void { ...(role !== undefined ? { role } : {}), ...(label !== undefined ? { label } : {}), ...(entry.name === 'input' ? { input: true } : {}), - focusable, - ...(Object.keys(custom).length > 0 ? { custom } : {}), + ...(custom.length > 0 ? { custom: Object.fromEntries(custom) } : {}), }, - states: { focused, focusRoot: focused }, ...(geo !== undefined ? { geo } : {}), }); entry.children.forEach((child, index) => visit(child, entry.key, index)); @@ -199,11 +182,10 @@ export function useSemantic(host: Host, options: SemanticOptions): void { function emit(info: RenderInfo, output: { write(chunk: Uint8Array): unknown }): void { try { const surface = deriveSurface(); - const frame: ClackFrameV1 = { + const frame: ClackFrame = { v: 1, - frame: ++frameCounter, + frame: frameCounter + 1, surface, - focusStack: focusStack(), nodes: buildNodes(info, surface), }; if (frame.nodes.length > LIMITS.nodes) { @@ -213,6 +195,7 @@ export function useSemantic(host: Host, options: SemanticOptions): void { return; } output.write(encodeFrame(frame)); + frameCounter++; } catch (error) { // A semantic failure is a diagnostic, never a broken paint. options.onDiagnostic?.(error as Error); @@ -230,3 +213,4 @@ export function useSemantic(host: Host, options: SemanticOptions): void { }, }); } +// oxlint-enable bombshell-dev/exported-function-async diff --git a/packages/clack-tty/src/protocol.ts b/packages/clack-tty/src/protocol.ts index 8fe19f8..96f6c96 100644 --- a/packages/clack-tty/src/protocol.ts +++ b/packages/clack-tty/src/protocol.ts @@ -1,12 +1,12 @@ /** * 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). + * One current schema, with bounded payloads, strict validation, and original + * geometry. The envelope marker identifies the wire format, not a type family. * * Schema reference: .pi/specs/ghostwright-clack-tty-spec.md (REQ-006..REQ-009). */ +// oxlint-disable bombshell-dev/exported-function-async -- OSC decoding and geometry calculations must remain synchronous. import { GhostwrightError } from 'ghostwright'; export const CLACK_TTY_OSC = 7777; @@ -43,43 +43,37 @@ 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 { +export interface ClackNode { 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 { +export interface ClackFrame { readonly v: 1; readonly frame: number; readonly surface: Readonly<{ columns: number; rows: number; row: number }>; - readonly focusStack: readonly string[]; - readonly nodes: readonly ClackNodeV1[]; + readonly nodes: readonly ClackNode[]; } const utf8 = new TextEncoder(); -const fail = (code: string, message: string): never => { +function fail(code: string, message: string): never { throw new GhostwrightError({ code, message: message.slice(0, 1024) }); -}; +} +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); const isScalar = (value: unknown): value is JsonScalar => value === null || typeof value === 'string' || @@ -87,7 +81,7 @@ const isScalar = (value: unknown): value is JsonScalar => (typeof value === 'number' && Number.isFinite(value)); /** Encode a semantic frame into its registered OSC byte sequence (REQ-005). */ -export function encodeFrame(frame: ClackFrameV1): Uint8Array { +export function encodeFrame(frame: ClackFrame): Uint8Array { const json = JSON.stringify(validateFrame(frame)); const bytes = utf8.encode(json); if (bytes.length > LIMITS.payloadBytes) @@ -100,19 +94,22 @@ export function encodeFrame(frame: ClackFrameV1): Uint8Array { } /** Decode a registered OSC payload into a validated frame (REQ-016). */ -export function decodeFrame(payload: Uint8Array): ClackFrameV1 { - const source = Buffer.from(payload).toString('ascii'); +export function decodeFrame(payload: Uint8Array): ClackFrame { + if (payload.length > Math.ceil((LIMITS.payloadBytes * 4) / 3)) + fail('GW_CLACK_LIMIT', 'Encoded payload exceeds limit'); + const source = new TextDecoder('utf-8', { fatal: true }).decode(payload); 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'); + if (decoded.toString('base64url') !== source) fail('GW_CLACK_BASE64', 'Noncanonical base64url'); } catch { fail('GW_CLACK_BASE64', 'Semantic payload cannot be decoded'); } let parsed: unknown; try { - parsed = JSON.parse(decoded.toString('utf8')); + parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(decoded)); } catch { fail('GW_CLACK_BASE64', 'Semantic payload is not valid UTF-8 JSON'); } @@ -120,165 +117,168 @@ export function decodeFrame(payload: Uint8Array): ClackFrameV1 { } /** 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; +export function validateFrame(input: unknown): ClackFrame { + if (!isRecord(input)) fail('GW_CLACK_SCHEMA', 'Semantic frame must be an object'); + const frame = input; 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) + if (typeof frame.frame !== 'number' || !Number.isSafeInteger(frame.frame) || frame.frame <= 0) fail('GW_CLACK_SCHEMA', 'Frame number must be a positive safe integer'); - const surface = frame.surface as Record | undefined; + const surface = frame.surface; if ( - !surface || + !isRecord(surface) || + typeof surface.columns !== 'number' || !Number.isInteger(surface.columns) || + typeof surface.rows !== 'number' || !Number.isInteger(surface.rows) || + typeof surface.row !== 'number' || !Number.isInteger(surface.row) || - (surface.columns as number) <= 0 || - (surface.rows as number) <= 0 || - (surface.row as number) <= 0 + surface.columns <= 0 || + surface.rows <= 0 || + surface.row <= 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[]; + const rawNodes: unknown[] = frame.nodes; 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, + frame: frame.frame, surface: { - columns: surface.columns as number, - rows: surface.rows as number, - row: surface.row as number, + columns: surface.columns, + rows: surface.rows, + row: surface.row, }, - 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; +function validateNode(raw: unknown, index: number): ClackNode { + if (!isRecord(raw)) fail('GW_CLACK_SCHEMA', `Node ${index} must be an object`); + const node = raw; 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) + if (typeof node.order !== 'number' || !Number.isInteger(node.order) || node.order < 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); + const attrs = node.attrs; + if (!isRecord(attrs)) fail('GW_CLACK_SCHEMA', `Node ${key} has invalid attributes`); + const role = + attrs.role === undefined + ? undefined + : stringField(attrs.role, `node ${key} role`, LIMITS.attribute); + const label = + attrs.label === undefined + ? 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)) + if (!isRecord(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; - } + custom = Object.fromEntries( + Object.entries(attrs.custom).map(([attribute, value]) => { + if (attribute.length === 0 || utf8.encode(attribute).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 ${attribute} is not a scalar`); + if (typeof value === 'string' && utf8.encode(value).length > LIMITS.attribute) + fail( + 'GW_CLACK_SCHEMA', + `Node ${key} custom attribute ${attribute} exceeds the value limit`, + ); + return [attribute, value]; + }), + ); } - 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, + parent: node.parent, + order: node.order, 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, + ...(role !== undefined ? { role } : {}), + ...(label !== undefined ? { label } : {}), + ...(attrs.input !== undefined ? { input: attrs.input } : {}), ...(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; + if (!isRecord(raw)) fail('GW_CLACK_SCHEMA', `Node ${key} has invalid geometry`); + const geo = raw; 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'); + const visible = geo.visible === undefined ? undefined : cellRect(geo.visible, key, 'visible'); return { layout, term, ...(visible !== undefined ? { visible } : {}) }; } +// oxlint-disable-next-line bombshell-dev/max-params -- value, diagnostic identity, and shared budget 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, + if (!isRecord(raw)) fail('GW_CLACK_SCHEMA', `Node ${key} has invalid ${field} geometry`); + const context = `Node ${key} has invalid ${field}`; + const rect = { + x: numberField(raw.x, `${context} x`), + y: numberField(raw.y, `${context} y`), + width: numberField(raw.width, `${context} width`), + height: numberField(raw.height, `${context} height`), }; + if (rect.width < 0 || rect.height < 0) + fail('GW_CLACK_SCHEMA', `Node ${key} has a negative ${field} size`); + return rect; } +// oxlint-disable-next-line bombshell-dev/max-params -- value, diagnostic identity, and shared budget 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, + if (!isRecord(raw)) fail('GW_CLACK_SCHEMA', `Node ${key} has invalid ${field} geometry`); + const context = `Node ${key} has invalid ${field}`; + const rect = { + column: integerField(raw.column, `${context} column`), + row: integerField(raw.row, `${context} row`), + width: integerField(raw.width, `${context} width`), + height: integerField(raw.height, `${context} height`), }; + if (rect.width < 0 || rect.height < 0) + fail('GW_CLACK_SCHEMA', `Node ${key} has a negative ${field} size`); + return rect; +} + +function numberField(value: unknown, message: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) fail('GW_CLACK_SCHEMA', message); + return value; +} + +function integerField(value: unknown, message: string): number { + const number = numberField(value, message); + if (!Number.isInteger(number)) fail('GW_CLACK_SCHEMA', message); + return number; } +// oxlint-disable-next-line bombshell-dev/max-params -- value, diagnostic identity, and shared budget 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`); + 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(); +function validateTree(nodes: readonly ClackNode[]): 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}`); + 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) { + if (node.parent !== null && !byKey.has(node.parent)) + fail('GW_CLACK_SCHEMA', `Missing parent ${node.parent}`); let current = node.parent ? byKey.get(node.parent) : undefined; const seen = new Set([node.key]); let depth = 0; @@ -294,15 +294,15 @@ function validateTree(nodes: readonly ClackNodeV1[]): void { } /** - * 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. + * Truncate both float edges to match the renderer's cell bounds. + * Truncating the origin and rounding the size can produce different bounds. + * `surface.row` is 1-based; the result is in zero-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 trunc = Math.trunc; const originRow = (surface.row ?? 1) - 1; const left = trunc(layoutBounds.x), right = trunc(layoutBounds.x + layoutBounds.width); @@ -323,6 +323,7 @@ export function geometryFor( }; } +/** Return the shared cell bounds, or undefined when rectangles do not overlap. */ export function intersect(a: Rect, b: Rect): Rect | undefined { const left = Math.max(a.column, b.column), top = Math.max(a.row, b.row); diff --git a/packages/clack-tty/src/vitest.ts b/packages/clack-tty/src/vitest.ts new file mode 100644 index 0000000..3f48f75 --- /dev/null +++ b/packages/clack-tty/src/vitest.ts @@ -0,0 +1,10 @@ +// oxlint-disable-next-line import/no-unassigned-import -- Register the core matchers before clack's matchers. +import 'ghostwright/vitest'; +import { expect } from 'vitest'; +import { createRunnerMatchers, type RunnerAssertions } from 'ghostwright/matchers'; +import { clackMatchers } from './expectations.ts'; + +expect.extend(createRunnerMatchers(clackMatchers)); +declare module 'vitest' { + interface Assertion extends RunnerAssertions {} +} diff --git a/packages/clack-tty/test/e2e.test.ts b/packages/clack-tty/test/e2e.test.ts index 5011e80..38854f9 100644 --- a/packages/clack-tty/test/e2e.test.ts +++ b/packages/clack-tty/test/e2e.test.ts @@ -1,155 +1,67 @@ -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'; +import { withTerminal, regionLocator, type TerminalLaunchOptions } from 'ghostwright'; +import { clackTtyExtension, expectUI, locator } 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[]) => ({ +const entry = (): TerminalLaunchOptions => ({ command: process.execPath, - args: ['--import', 'tsx', 'src/hello-world.ts', ...extra], - cwd: demoRoot, - viewport: { columns: 80, rows: 24 }, + args: ['--import', import.meta.resolve('tsx'), 'src/hello-world.ts'], + cwd: new URL('../../hello-world', import.meta.url).pathname, 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); - }); + extensions: [clackTtyExtension()], }); -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('producer and CSS adapter compose with core assertions over real terminal output', async () => { + await withTerminal(entry(), async (ui) => { + const say = locator('input[label="say"]'); + await expectUI(ui, say).toHaveInputFocus(); + await ui.keyboard.type('Hi'); + await ui.expect(say).toContainText('Hi'); + await ui.expect(locator('box[label="hello"]')).toContainText('Hi, World!'); + const to = locator('input[label="to"]'); + await ui.keyboard.press('Tab'); + await expectUI(ui, to).toHaveInputFocus(); + await ui.expect(say).toHaveEdgeStyle('top', { foreground: '#646464' }); }); }); -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('custom attribute names survive the producer, wire decoder, and CSS query', async () => { + await withTerminal( + { + command: process.execPath, + args: [ + '--import', + import.meta.resolve('tsx'), + new URL('fixtures/custom-attributes.ts', import.meta.url).pathname, + ], + extensions: [clackTtyExtension()], + }, + async (ui) => { + const contact = locator( + 'box[data-__proto__="contact"][data-constructor="field"][data-toString="label"]', + ); + await ui.expect(contact).toContainText('Contact details'); + }, + ); }); -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('ambiguous location fails immediately, not as an assertion timeout', async () => { + await withTerminal(entry(), async (ui) => { + await expectUI(ui, locator('input[label="say"]')).toHaveInputFocus(); + await expect(ui.expect(locator('input')).toContainCursor()).rejects.toMatchObject({ + code: 'GW_LOCATOR_STRICT', + }); }); }); -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'); +test('semantic emission is opt-in; a missing description cannot prove visibility', async () => { + await withTerminal({ ...entry(), env: {}, assertionTimeoutMs: 1000 }, async (ui) => { + await ui + .expect(regionLocator({ column: 0, row: 0, width: 80, height: 24 })) + .toContainText('Hello, World!'); + await expect(ui.expect(locator('input')).toContainCursor()).rejects.toMatchObject({ + code: 'GW_ASSERTION', + }); + expect(Buffer.from(ui.screen.rawOutput()).toString()).not.toContain('7777;clack.ui'); }); - 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/action-target.ts b/packages/clack-tty/test/fixtures/action-target.ts new file mode 100644 index 0000000..fd3f696 --- /dev/null +++ b/packages/clack-tty/test/fixtures/action-target.ts @@ -0,0 +1,119 @@ +// oxlint-disable eslint/no-control-regex -- Parse terminal mouse reports, including ESC. +import { encodeFrame, type ClackFrame, type ClackNode } from '../../src/protocol.ts'; + +// A small interactive protocol fixture: Enter reveals Submit, clicking it +// updates the UI, and ! finishes with an audit of all preceding PTY input. +process.stdin.setRawMode(true); +const mode = process.env.ACTION_TARGET; +let revealed = mode === 'ambiguous'; +let frame = 0; +let pressedTarget = -1; +let submitted = 0; +let received = ''; +let pending = ''; +let finishing = false; + +function render(status: string): void { + const panel = revealed || mode !== 'missing-parent'; + const count = revealed ? (mode === 'ambiguous' ? 2 : 1) : 0; + const nodes: ClackNode[] = [ + { + key: 'status', + name: 'text', + parent: null, + order: 0, + attrs: { label: 'status' }, + geo: geometry({ column: 0, row: 0, width: 40, height: 1 }), + }, + ]; + let paint = '\x1b[2J\x1b[H' + status; + if (panel) { + paint += '\x1b[2;1HPanel'; + nodes.push({ + key: 'panel', + name: 'form', + parent: null, + order: 1, + attrs: { label: 'delivery' }, + geo: geometry({ column: 0, row: 1, width: 40, height: 5 }), + }); + } + for (let index = 0; index < count; index++) { + paint += `\x1b[${index + 3};5H[Submit]`; + nodes.push({ + key: `submit-${index}`, + name: 'button', + parent: 'panel', + order: index, + attrs: { label: 'submit' }, + geo: geometry({ column: 4, row: index + 2, width: 8, height: 1 }), + }); + } + if (process.env.DESCRIBE === '1') { + const description: ClackFrame = { + v: 1, + frame: ++frame, + nodes, + surface: { columns: 80, rows: 24, row: 1 }, + }; + paint += Buffer.from(encodeFrame(description)).toString(); + } + process.stdout.write(paint); +} +function geometry(term: { + column: number; + row: number; + width: number; + height: number; +}): NonNullable { + return { term, layout: { x: term.column, y: term.row, width: term.width, height: term.height } }; +} +function mouse(event: RegExpMatchArray): void { + const button = Number(event[1]), + column = Number(event[2]) - 1, + row = Number(event[3]) - 1; + const count = revealed ? (mode === 'ambiguous' ? 2 : 1) : 0; + const target = column >= 4 && column < 12 && row >= 2 && row < 2 + count ? row - 2 : -1; + if (button === 51 && target !== -1) { + render('Hover: control'); // SGR motion without a button, with Control held. + return; + } + if (button !== 0) return; + if (event[4] === 'M') pressedTarget = target; + else { + if (target !== -1 && pressedTarget === target) render(`Submitted: ${++submitted}`); + pressedTarget = -1; + } +} +process.stdin.on('data', (bytes) => { + if (finishing) return; + const text = bytes.toString('latin1'); + received += text; + pending += text; + while (pending.length) { + if (pending[0] === '!') { + finishing = true; + const prefix = received.slice(0, received.indexOf('!')); + const audit = Buffer.from(prefix, 'latin1').toString('hex') || '(none)'; + process.stdout.write(`\x1b[7;1HINPUT:${audit}`, () => process.exit(0)); + return; + } + if (pending[0] === '\r') { + pending = pending.slice(1); + revealed = true; + render('Ready'); + continue; + } + const event = pending.match(/^\x1b\[<(\d+);(\d+);(\d+)([Mm])/); + if (event) { + pending = pending.slice(event[0].length); + mouse(event); + } else if (/^\x1b(?:\[(?:<[\d;]*)?)?$/.test(pending)) { + return; // A mouse report can span several PTY reads. + } else { + pending = pending.slice(1); // Still retained in the input audit. + } + } +}); +process.stdout.write('\x1b[?1003h\x1b[?1006h'); +render(revealed ? 'Ready' : 'Loading'); diff --git a/packages/clack-tty/test/fixtures/custom-attributes.ts b/packages/clack-tty/test/fixtures/custom-attributes.ts new file mode 100644 index 0000000..3e38529 --- /dev/null +++ b/packages/clack-tty/test/fixtures/custom-attributes.ts @@ -0,0 +1,16 @@ +import { stdin, stdout } from 'node:process'; +import { createUI } from '@clack/ui'; +import { useSemantic } from '../../src/producer.ts'; + +await using ui = await createUI({ input: stdin, output: stdout }); +const { host } = ui; +useSemantic(host, { surface: () => ({ columns: stdout.columns, rows: stdout.rows }) }); + +const contact = host.createElement('box'); +host.setProperty(contact, 'data-__proto__', 'contact'); +host.setProperty(contact, 'data-constructor', 'field'); +host.setProperty(contact, 'data-toString', 'label'); +host.insertBefore(contact, host.createLiteral('Contact details')); +host.insertBefore(host.element, contact); + +await ui.main(); diff --git a/packages/clack-tty/test/locator.test.ts b/packages/clack-tty/test/locator.test.ts index 9efa71f..8a6a3f9 100644 --- a/packages/clack-tty/test/locator.test.ts +++ b/packages/clack-tty/test/locator.test.ts @@ -1,217 +1,84 @@ -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'; +import { expect, test } from 'vitest'; +import { locator } from '../src/extension.ts'; +import { withTerminal, type Observation } from 'ghostwright'; +import type { ClackFrame } 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 = { +const description: ClackFrame = { 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', + { key: 'form', name: 'form', parent: null, order: 0, attrs: { label: 'delivery' } }, + { + key: 'name', 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', + parent: 'form', + order: 0, + attrs: { label: 'name', role: 'textbox', custom: { ['__proto__']: 'contact' } }, + geo: { + layout: { x: 0, y: 0, width: 10, height: 1 }, + term: { column: 0, row: 0, width: 10, height: 1 }, + }, + }, + { + key: 'address', 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' } } }), + parent: 'form', + order: 1, + attrs: { label: 'address', role: 'textbox' }, + geo: { + layout: { x: 10, y: 0, width: 10, height: 1 }, + term: { column: 10, row: 0, width: 10, height: 1 }, + }, + }, ], }; -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, +test('queries are immutable, pure, ordered, and work against historical descriptions', async () => { + const name = locator('form[label="delivery"] > input[label="name"]'); + expect(Object.isFrozen(name)).toBe(true); + await withTerminal( + { + command: process.execPath, + args: ['-e', 'process.stdout.write("Ryan Main St")'], + trace: 'off', + }, + async (t) => { + await t.process.waitForExit(); + const observation: Observation = { + kind: 'extension', + sequence: 1, timestamp: 0, - extensionId: 'test', - protocolFrame: commit.protocolFrame, - screenSequence: 0, - value: commit.value, + extensionId: 'ghostwright.clack-tty', + protocolFrame: 1, + description, + screen: t.screen.current(), }; - }, - 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)$/), - }), + expect(name.resolve(observation)[0]?.text().trim()).toBe('Ryan'); + expect(locator('input + input').resolve(observation)[0]?.text().trim()).toBe('Main St'); + expect(locator('input').nth(1).resolve(observation)[0]?.bounds.column).toBe(10); + expect(locator('input[label="absent"]').resolve(observation)).toEqual([]); + expect(locator('[data-__proto__="contact"]').resolve(observation)[0]?.text().trim()).toBe( + 'Ryan', ); - }); - } -}); - -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']); - }); + expect(locator('[data-constructor], [data-toString]').resolve(observation)).toEqual([]); + expect(() => locator('form').resolve(observation)).toThrow(/no geometry/); + expect( + name.resolve({ kind: 'screen', sequence: 2, timestamp: 1, screen: t.screen.current() }), + ).toEqual([]); + }, + ); }); +for (const source of [ + 'input:focus', + '[focused]', + '[focusable]', + 'input:visible', + 'input::before', + 'box:contains(x)', + 'box:', + 'input' + ':has(box)'.repeat(600), +]) { + test(`rejects unsupported or over-limit selector ${source.slice(0, 40)}`, () => + expect(() => locator(source)).toThrow()); +} diff --git a/packages/clack-tty/test/protocol.test.ts b/packages/clack-tty/test/protocol.test.ts index d55898c..1a6f1cd 100644 --- a/packages/clack-tty/test/protocol.test.ts +++ b/packages/clack-tty/test/protocol.test.ts @@ -1,245 +1,118 @@ -import { describe, expect, test } from 'vitest'; +import { expect, test } from 'vitest'; import { decodeFrame, encodeFrame, geometryFor, - LIMITS, - type ClackFrameV1, - type ClackNodeV1, + validateFrame, + type ClackFrame, + type ClackNode, } 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, - }; -} +const input = { + key: 'name', + name: 'input', + parent: null, + order: 0, + attrs: { role: 'textbox', label: 'name' }, + geo: { + layout: { x: 2, y: 5, width: 10, height: 3 }, + term: { column: 2, row: 5, width: 10, height: 3 }, + }, +} satisfies ClackNode; -/** 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'); -} +const frame = (): ClackFrame => ({ + v: 1, + frame: 1, + surface: { columns: 80, rows: 24, row: 1 }, + nodes: [structuredClone(input)], +}); -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('identity and geometry round-trip', () => { + const encoded = Buffer.from(encodeFrame(frame())).toString(); + expect(encoded.startsWith('\x1b]7777;clack.ui;v=1;')).toBe(true); + expect(decodeFrame(Buffer.from(encoded.slice('\x1b]7777;clack.ui;v=1;'.length, -2)))).toEqual( + frame(), + ); +}); - 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\\'); - }); +test('custom attributes retain names shared with Object.prototype', () => { + const custom = { ['__proto__']: 'plain', constructor: 'field', toString: null }; + const encoded = Buffer.from( + encodeFrame({ ...frame(), nodes: [{ ...input, attrs: { custom } }] }), + ).toString(); + const decoded = decodeFrame(Buffer.from(encoded.slice('\x1b]7777;clack.ui;v=1;'.length, -2))); + expect(decoded.nodes[0]?.attrs.custom).toEqual(custom); }); -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}` }))), - }, +// Malformed wire data is intentionally not a ClackFrame. Construct it as +// input to the validator rather than using `any` to mutate a valid typed frame. +for (const [name, value] of [ + ['version', { ...frame(), v: 9 }], + ['frame number', { ...frame(), frame: 0 }], + ['non-object surface', { ...frame(), surface: '80x24' }], + ['string surface dimension', { ...frame(), surface: { ...frame().surface, columns: '80' } }], + ['non-string label', { ...frame(), nodes: [{ ...input, attrs: { label: 42 } }] }], + ['non-boolean input flag', { ...frame(), nodes: [{ ...input, attrs: { input: 1 } }] }], + [ + 'non-numeric layout', { - name: 'non-scalar custom attribute', - code: 'GW_CLACK_SCHEMA', - frame: () => frame({}, [node({ attrs: { focusable: false, custom: { x: { deep: true } } } })]), + ...frame(), + nodes: [{ ...input, geo: { ...input.geo, layout: { ...input.geo.layout, x: '2' } } }], }, + ], + [ + 'non-finite layout', { - 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'] })]), + ...frame(), + nodes: [ + { ...input, geo: { ...input.geo, layout: { ...input.geo.layout, width: Infinity } } }, + ], }, + ], + ['missing parent', { ...frame(), nodes: [{ ...input, parent: 'absent' }] }], + ['parent cycle', { ...frame(), nodes: [{ ...input, parent: 'name' }] }], + ['duplicate key', { ...frame(), nodes: [input, input] }], + [ + 'negative size', { - 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 } }, - })]), + ...frame(), + nodes: [{ ...input, geo: { ...input.geo, term: { ...input.geo.term, width: -1 } } }], }, + ], + [ + 'fractional cell', { - 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 } }, - })]), + ...frame(), + nodes: [{ ...input, geo: { ...input.geo, term: { ...input.geo.term, column: 1.5 } } }], }, - ]; - 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 }); + ], + [ + 'oversized label', + { ...frame(), nodes: [{ ...input, attrs: { ...input.attrs, label: 'x'.repeat(1025) } }] }, + ], +] as const) { + test(`rejects ${name}`, () => { + expect(() => validateFrame(value)).toThrow(); }); +} - 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 }); +for (const payload of [ + '=', + '***', + 'a', + Buffer.from('{').toString('base64url'), + Buffer.from([0xff]).toString('base64url'), +]) { + test(`rejects malformed payload ${payload}`, () => { + expect(() => decodeFrame(Buffer.from(payload))).toThrow(); }); +} - 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(); - }); +test('geometry keeps original bounds separate from viewport clipping', () => { + const geometry = geometryFor( + { x: -2, y: 5, width: 10, height: 3 }, + { columns: 80, rows: 24, row: 1 }, + ); + expect(geometry.term).toEqual({ column: -2, row: 5, width: 10, height: 3 }); + expect(geometry.visible).toEqual({ column: 0, row: 5, width: 8, height: 3 }); }); diff --git a/packages/clack-tty/test/scoped-actions.test.ts b/packages/clack-tty/test/scoped-actions.test.ts new file mode 100644 index 0000000..dd6b4ff --- /dev/null +++ b/packages/clack-tty/test/scoped-actions.test.ts @@ -0,0 +1,152 @@ +import { expect, test } from 'vitest'; +import { + defineScreenLocator, + regionLocator, + withTerminal, + type AsyncExecution, + type TerminalLaunchOptions, +} from 'ghostwright'; +import { clackTtyExtension, locator } from '../src/index.ts'; + +const panel = defineScreenLocator('panel', (screen) => + screen.lines.flatMap((line) => + line.text.trim() === 'Panel' ? [{ column: 0, row: line.row, width: 40, height: 5 }] : [], + ), +); +const spatialSubmit = panel.derive('submit', (region) => + region + .text() + .split('\n') + .flatMap((line, row) => { + const column = line.indexOf('[Submit]'); + return column === -1 + ? [] + : [ + { + column: region.bounds.column + column, + row: region.bounds.row + row, + width: 8, + height: 1, + }, + ]; + }), +); + +const cases = [ + { + name: 'spatial', + described: false, + submit: spatialSubmit, + status: regionLocator({ column: 0, row: 0, width: 40, height: 1 }), + path: 'panel >> submit', + }, + { + name: 'DOM', + described: true, + submit: locator('form[label="delivery"]').locator('button[label="submit"]'), + status: locator('text[label="status"]'), + path: 'form[label="delivery"] >> button[label="submit"]', + }, +]; + +for (const query of cases) { + const launch = ( + mode: 'missing-parent' | 'missing-child' | 'ambiguous', + ): TerminalLaunchOptions => ({ + command: process.execPath, + args: [ + '--import', + import.meta.resolve('tsx'), + new URL('fixtures/action-target.ts', import.meta.url).pathname, + ], + cwd: new URL('..', import.meta.url).pathname, + env: { ACTION_TARGET: mode, DESCRIBE: query.described ? '1' : '0' }, + extensions: query.described ? [clackTtyExtension()] : [], + assertionTimeoutMs: 1000, + trace: 'off' as const, + }); + + for (const missing of ['missing-parent', 'missing-child'] as const) { + test(`${query.name} click waits for ${missing} before sending mouse input`, async () => { + await withTerminal(launch(missing), async (ui) => { + await ui.expect(query.status).toContainText('Loading'); + // Start the click while its target is absent. Enter is the fixture's + // real interaction for revealing the form; it releases the pending click. + await Promise.all([ui.mouse.click(query.submit), ui.keyboard.press('Enter')]); + await ui.expect(query.status).toContainText('Submitted: 1'); + const input = await finishInputAudit(ui); + expect(input.startsWith('\r')).toBe(true); // No input preceded the reveal key. + expect(ui.screen.getText()).toContain('Submitted: 1'); // No second click after the first assertion. + }); + }); + } + + test(`${query.name} hover resolves a target and transmits its modifier`, async () => { + await withTerminal(launch('ambiguous'), async (ui) => { + await ui.expect(query.status).toContainText('Ready'); + await ui.mouse.hover(query.submit.nth(0), { control: true }); + await ui.expect(query.status).toContainText('Hover: control'); + expect(await finishInputAudit(ui)).toBe('\x1b[<51;8;3M'); + }); + }); + + test(`${query.name} invalid drag destination sends no button-down`, async () => { + await withTerminal(launch('ambiguous'), async (ui) => { + await ui.expect(query.status).toContainText('Ready'); + await expect( + ui.mouse.drag(query.submit.nth(0), { by: { columns: Infinity, rows: 0 } }), + ).rejects.toMatchObject({ code: 'GW_COORDINATE_RANGE' }); + expect(await finishInputAudit(ui)).toBe(''); + }); + }); + + test(`${query.name} ambiguity includes the whole path and sends no input`, async () => { + await withTerminal(launch('ambiguous'), async (ui) => { + await ui.expect(query.status).toContainText('Ready'); + await expect(ui.mouse.click(query.submit)).rejects.toMatchObject({ + code: 'GW_LOCATOR_STRICT', + message: expect.stringContaining(query.path), + }); + expect(await finishInputAudit(ui)).toBe(''); + }); + }); + + test(`${query.name} timeout includes the whole path and sends no input`, async () => { + await withTerminal(launch('missing-child'), async (ui) => { + await ui.expect(query.status).toContainText('Loading'); + await expect(ui.mouse.click(query.submit)).rejects.toMatchObject({ + code: 'GW_ASSERTION', + message: expect.stringContaining(query.path), + }); + expect(await finishInputAudit(ui)).toBe(''); + }); + }); + + test(`${query.name} exit while waiting identifies the full query path`, async () => { + await withTerminal(launch('missing-child'), async (ui) => { + await ui.expect(query.status).toContainText('Loading'); + await Promise.all([ + expect(ui.mouse.click(query.submit)).rejects.toMatchObject({ + code: 'GW_PROCESS_EXITED', + message: expect.stringContaining(query.path), + }), + finishInputAudit(ui).then((input) => expect(input).toBe('')), + ]); + }); + }); +} + +/** The finish key is a PTY stream barrier, after all input preceding it. + * The fixture audits that prefix, flushes its report, and exits. No timing guess. */ +async function finishInputAudit(ui: AsyncExecution): Promise { + await ui.keyboard.type('!'); + await ui.process.waitForExit(); + const audit = ui.screen + .getText() + .split('\n') + .find((line) => line.startsWith('INPUT:')) + ?.slice(6) + .trim(); + expect(audit).toBeDefined(); + return audit === '(none)' ? '' : Buffer.from(audit!, 'hex').toString('latin1'); +} diff --git a/packages/clack-tty/test/scoped-locator.test.ts b/packages/clack-tty/test/scoped-locator.test.ts new file mode 100644 index 0000000..6242e7a --- /dev/null +++ b/packages/clack-tty/test/scoped-locator.test.ts @@ -0,0 +1,231 @@ +import { expect, expectTypeOf, test } from 'vitest'; +import { + textContains, + withTerminal, + type Observation, + type RegionLocator, + type TerminalLaunchOptions, +} from 'ghostwright'; +import { clackTtyExtension, locator, type ClackLocator } from '../src/index.ts'; +import { + encodeFrame, + type ClackFrame, + type ClackNode, + type ClackNodeGeometry, +} from '../src/protocol.ts'; + +const geo = ({ + column, + row, + width, +}: { + column: number; + row: number; + width: number; +}): ClackNodeGeometry => ({ + layout: { x: column, y: row, width, height: 1 }, + term: { column, row, width, height: 1 }, +}); + +function scene({ + frame, + text, + column, + parentKey, +}: { + frame: number; + text: string; + column: number; + parentKey: string | null; +}): string { + const nodes: ClackNode[] = []; + if (parentKey) + nodes.push( + { + key: parentKey, + name: 'form', + parent: null, + order: 0, + attrs: { label: 'delivery' }, + geo: geo({ column: 0, row: 0, width: 6 }), + }, + // This structural container deliberately has no painted geometry. + { key: 'fields', name: 'box', parent: parentKey, order: 0, attrs: { label: 'fields' } }, + { + key: 'name', + name: 'input', + parent: 'fields', + order: 0, + attrs: { label: 'name' }, + geo: geo({ column, row: 1, width: 8 }), + }, + { + key: 'address', + name: 'input', + parent: 'fields', + order: 1, + attrs: { label: 'address' }, + geo: geo({ column: 40, row: 1, width: 8 }), + }, + { + key: 'send', + name: 'button', + parent: parentKey, + order: 1, + attrs: { label: 'send' }, + geo: geo({ column: 50, row: 1, width: 5 }), + }, + ); + nodes.push( + { key: 'billing', name: 'form', parent: null, order: 1, attrs: { label: 'billing' } }, + // This unrelated input is physically INSIDE the delivery form's bounds. + { + key: 'decoy', + name: 'input', + parent: 'billing', + order: 0, + attrs: { label: 'name' }, + geo: geo({ column: 1, row: 0, width: 5 }), + }, + { + key: 'status', + name: 'text', + parent: null, + order: 2, + attrs: { label: 'status' }, + geo: geo({ column: 0, row: 3, width: 20 }), + }, + ); + const description: ClackFrame = { + v: 1, + frame, + nodes, + surface: { columns: 80, rows: 24, row: 1 }, + }; + const paint = + `\x1b[2J\x1b[1;2HDecoy\x1b[4;1H${text}` + + (parentKey ? `\x1b[2;${column + 1}H${text}\x1b[2;41HMain St\x1b[2;51HSend` : ''); + return paint + Buffer.from(encodeFrame(description)).toString(); +} + +const launch = (): TerminalLaunchOptions => ({ + command: process.execPath, + args: [ + '-e', + ` + process.stdin.setRawMode(true); + const output = ${JSON.stringify([ + scene({ frame: 1, text: 'Ryan', column: 10, parentKey: 'delivery' }), + // One write contains two complete render/description pairs. + scene({ frame: 2, text: 'Loading', column: 20, parentKey: 'delivery' }) + + scene({ frame: 3, text: 'Saved', column: 30, parentKey: 'replacement' }), + scene({ frame: 4, text: 'No form', column: 0, parentKey: null }), + scene({ frame: 5, text: 'Back', column: 15, parentKey: 'returned' }), + ])}; + let index = 0; + process.stdin.on('data', bytes => { + for (const key of bytes.toString()) if (key === '\\r' && index < output.length - 1) process.stdout.write(output[++index]); + }); + process.stdout.write(output[0]); + `, + ], + trace: 'off' as const, + extensions: [clackTtyExtension()], +}); +const delivery = locator('form[label="delivery"]'); +const status = locator('text[label="status"]'); + +test('DOM-scoped children follow parent replacement and retain coherent history', async () => { + const name = delivery.locator('box[label="fields"]').locator('input[label="name"]'); + await withTerminal(launch(), async (ui) => { + await ui.expect(name).toContainText('Ryan'); + const movement = await ui.capture( + { until: name.satisfies(textContains('Saved')) }, + async (capture) => { + await capture.keyboard.press('Enter'); + }, + ); + const descriptions = movement.observations.filter( + (observation) => observation.kind === 'extension', + ); + expect(descriptions.map((observation) => name.resolve(observation)[0]!.text().trim())).toEqual([ + 'Loading', + 'Saved', + ]); + expect(descriptions.map((observation) => name.resolve(observation)[0]!.bounds.column)).toEqual([ + 20, 30, + ]); + expect(name.resolve(movement.baseline)[0]!.text().trim()).toBe('Ryan'); + expect(name.resolve(movement.baseline)[0]!.bounds.column).toBe(10); + + const removal = await ui.capture( + { until: status.satisfies(textContains('No form')) }, + async (capture) => { + await capture.keyboard.press('Enter'); + }, + ); + expect(name.resolve(removal.observations.at(-1)!)).toEqual([]); + await ui.keyboard.press('Enter'); + expect((await ui.expect(name).toContainText('Back')).bounds.column).toBe(15); + expect(name.resolve(movement.baseline)[0]!.text().trim()).toBe('Ryan'); + }); +}); + +test('scope uses ancestry, preserves node-level nth, and never clips to parent geometry', async () => { + await withTerminal(launch(), async (ui) => { + await ui.expect(status).toContainText('Ryan'); + await expect(ui.expect(delivery.locator('input')).toContainText('Ryan')).rejects.toMatchObject({ + code: 'GW_LOCATOR_STRICT', + }); + const movement = await ui.capture( + { until: status.satisfies(textContains('Saved')) }, + async (capture) => { + await capture.keyboard.press('Enter'); + }, + ); + const sample = movement.baseline; + const texts = ( + query: ReturnType, + observation: Observation = sample, + ): string[] => query.resolve(observation).map((region) => region.text().trim()); + expect(texts(delivery.locator('input, button'))).toEqual(['Ryan', 'Main St', 'Send']); + expect(texts(locator('form').nth(1).locator('input'))).toEqual(['Decoy']); + expect(texts(delivery.locator('input').nth(1))).toEqual(['Main St']); + expect(texts(delivery.locator('> box').locator('> input').nth(0))).toEqual(['Ryan']); + expect(texts(delivery.locator('box, input').locator('input'))).toEqual(['Ryan', 'Main St']); + expect(texts(delivery.locator('form'))).toEqual([]); + expect(texts(locator('form').nth(2).locator('input'))).toEqual([]); + // A geometry-free parent is usable for addressing, but cannot itself be inspected. + expect(() => delivery.locator('box').resolve(sample)).toThrow(/no geometry/); + expect(delivery.locator('input').nth(0).resolve(sample)[0]!.bounds).toEqual({ + column: 10, + row: 1, + width: 8, + height: 1, + }); + }); +}); + +test('invalid child selectors and indices fail at construction', () => { + for (const source of ['input:focus', 'input::before', 'input:']) { + expect(() => delivery.locator(source)).toThrowError( + expect.objectContaining({ code: 'GW_CLACK_SELECTOR_INVALID' }), + ); + } + expect(() => delivery.locator('x'.repeat(4097))).toThrowError( + expect.objectContaining({ code: 'GW_CLACK_SELECTOR_LIMIT' }), + ); + expect(() => delivery.nth(-1)).toThrowError( + expect.objectContaining({ code: 'GW_INVALID_OPTIONS' }), + ); + expect(() => delivery.locator('input').nth(0.5)).toThrowError( + expect.objectContaining({ code: 'GW_INVALID_OPTIONS' }), + ); +}); + +test('scoping and nth keep DOM query types; spatial derivation returns a region query', () => { + const name = delivery.nth(0).locator('input').nth(0); + expectTypeOf(name).toEqualTypeOf(); + const cell = name.derive('first cell', (region) => [{ ...region.bounds, width: 1, height: 1 }]); + expectTypeOf(cell).toEqualTypeOf(); +}); diff --git a/packages/clack-tty/test/structural.test.ts b/packages/clack-tty/test/structural.test.ts deleted file mode 100644 index d74df2c..0000000 --- a/packages/clack-tty/test/structural.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -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 index f08f29c..0c8ae7a 100644 --- a/packages/clack-tty/tsconfig.json +++ b/packages/clack-tty/tsconfig.json @@ -1,5 +1,7 @@ { + "extends": "../../tsconfig.json", "compilerOptions": { + "composite": false, "types": ["node"], "paths": { "@clack/ui": ["../../vendor/clack-ui/src/index.ts"], @@ -8,5 +10,6 @@ "@clack/ui/focus": ["../../vendor/clack-ui/src/focus.ts"], "@clack/ui/core": ["../../vendor/clack-ui/src/core.ts"] } - } + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] } diff --git a/packages/clack-tty/vitest.config.ts b/packages/clack-tty/vitest.config.ts index 23938da..72b5b1e 100644 --- a/packages/clack-tty/vitest.config.ts +++ b/packages/clack-tty/vitest.config.ts @@ -8,6 +8,6 @@ export default defineConfig({ // 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 } }, + maxWorkers: 1, }, }); diff --git a/packages/hello-world/package.json b/packages/hello-world/package.json index 07a0e61..2c15784 100644 --- a/packages/hello-world/package.json +++ b/packages/hello-world/package.json @@ -1,40 +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" - } - } + "name": "@ghostwright/hello-world", + "version": "0.0.0", + "private": true, + "description": "A plain clack/ui hello-world application, validated end to end with ghostwright tree locators", + "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", + "ghostwright": "workspace:*", + "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" + } + }, + "@clack/ui": { + "extensions": [ + "@ghostwright/clack-tty/auto" + ] + } } diff --git a/packages/hello-world/src/hello-world.ts b/packages/hello-world/src/hello-world.ts index d4b7b31..96499e4 100644 --- a/packages/hello-world/src/hello-world.ts +++ b/packages/hello-world/src/hello-world.ts @@ -4,10 +4,7 @@ * 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'; @@ -16,17 +13,6 @@ 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; @@ -81,11 +67,7 @@ const app = box( 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() } }, label('say:'), label('to:')), box({ layout: { direction: 'ltr', gap: 1, width: grow() } }, sayInput, toInput), ), ); @@ -102,10 +84,7 @@ function box(properties: Record, ...children: HostElement[]): H } function label(content: string): HostElement { - return box( - { layout: { width: percent(0.3) } }, - text({ color: gray }, content), - ); + return box({ layout: { width: percent(0.3) } }, text({ color: gray }, content)); } function text(properties: TextProps, content: string): HostElement { diff --git a/packages/hello-world/test/hello-world.test.ts b/packages/hello-world/test/hello-world.test.ts index 89e951c..20905dc 100644 --- a/packages/hello-world/test/hello-world.test.ts +++ b/packages/hello-world/test/hello-world.test.ts @@ -1,104 +1,27 @@ -import { expect, test } from 'vitest'; -import { cellsMatchStyle, expectTerminal, withTerminalAsync } from 'ghostwright'; -import { - clackTtyExtension, - expectFocused, - expectTreeCondition, - type ClackTtySession, -} from '@ghostwright/clack-tty'; +import { test } from 'vitest'; +import { withTerminal, type TerminalLaunchOptions } from 'ghostwright'; +import { clackTtyExtension, expectUI, locator } 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 = () => ({ +const entry = (): TerminalLaunchOptions => ({ 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 }); - }); + extensions: [clackTtyExtension()], }); -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/); +test('greeting reacts to typing through the real terminal', async () => { + const say = locator('input[label="say"]'); + const to = locator('input[label="to"]'); + const group = locator('box[label="hello"]'); + await withTerminal(entry(), async (ui) => { + await ui.expect(group).toContainText('Hello, World!'); + await expectUI(ui, say).toHaveInputFocus(); + await ui.keyboard.type('Hi'); + await ui.expect(group).toContainText('Hi, World!'); + await ui.expect(say).toContainText('Hi'); + await ui.keyboard.press('Tab'); + await expectUI(ui, to).toHaveInputFocus(); + await ui.expect(say).toHaveEdgeStyle('top', { foreground: '#646464' }); }); }); diff --git a/packages/hello-world/vitest.config.ts b/packages/hello-world/vitest.config.ts index 48cdfd7..866bafa 100644 --- a/packages/hello-world/vitest.config.ts +++ b/packages/hello-world/vitest.config.ts @@ -6,6 +6,6 @@ export default defineConfig({ hookTimeout: 30_000, teardownTimeout: 30_000, pool: 'forks', - poolOptions: { forks: { singleFork: true } }, + maxWorkers: 1, }, }); diff --git a/packages/pizza-preact/package.json b/packages/pizza-preact/package.json new file mode 100644 index 0000000..60266f5 --- /dev/null +++ b/packages/pizza-preact/package.json @@ -0,0 +1,32 @@ +{ + "name": "@ghostwright/pizza-preact", + "version": "0.0.0", + "private": true, + "description": "A Preact clack/ui pizza delivery example tested outside-in with Ghostwright", + "license": "MIT", + "type": "module", + "scripts": { + "start": "tsx src/index.tsx", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@bomb.sh/tty": "^0.8.0", + "@clack/ui": "workspace:*", + "@clack/ui-preact": "workspace:*", + "@ghostwright/clack-tty": "workspace:*", + "preact": "11.0.0-beta.2" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "ghostwright": "workspace:*", + "tsx": "^4.19.0", + "typescript": "^5.7.2", + "vitest": "^4.1.9" + }, + "@clack/ui": { + "extensions": [ + "@ghostwright/clack-tty/auto" + ] + } +} diff --git a/packages/pizza-preact/src/app.tsx b/packages/pizza-preact/src/app.tsx new file mode 100644 index 0000000..7d2c579 --- /dev/null +++ b/packages/pizza-preact/src/app.tsx @@ -0,0 +1,112 @@ +import { fixed, grow, rgba } from '@bomb.sh/tty'; +import type { ComponentChildren, VNode } from 'preact'; +import { useState } from 'preact/hooks'; + +const black = rgba(0, 0, 0); +const blue = rgba(0, 0, 238); +const cyan = rgba(0, 205, 205); +const gray = rgba(127, 127, 127); + +interface SubmitButtonProps { + children: ComponentChildren; + label: string; +} + +function SubmitButton({ children, label }: SubmitButtonProps): VNode { + return ( + + ); +} + +interface FieldRowProps { + label: string; + labelWidth: number; +} + +function FieldRow({ label, labelWidth }: FieldRowProps): VNode { + return ( + + + {label}: + + + + ); +} + +/** Pizza delivery expressed as a Preact tree over the clack/ui Host. */ +export function PizzaDelivery(): VNode { + const [cardOpen, setCardOpen] = useState(false); + + return ( + +
setCardOpen(true)} + layout={{ + direction: 'ttb', + gap: 1, + padding: { top: 1, right: 2, bottom: 1, left: 2 }, + width: grow(32, 44), + }} + border={{ color: blue, top: 1, right: 1, bottom: 1, left: 1 }} + > + Pizza Delivery + + + + Add card + + + + {cardOpen ? ( + +
setCardOpen(false)} + layout={{ + direction: 'ttb', + gap: 1, + padding: { top: 1, right: 2, bottom: 1, left: 2 }, + width: grow(), + }} + > + Card Details + + + + + Submit card + + +
+ ) : null} +
+ ); +} diff --git a/packages/pizza-preact/src/index.tsx b/packages/pizza-preact/src/index.tsx new file mode 100644 index 0000000..1edce02 --- /dev/null +++ b/packages/pizza-preact/src/index.tsx @@ -0,0 +1,9 @@ +import { stdin, stdout } from 'node:process'; +import { createUI } from '@clack/ui'; +import { createRoot } from '@clack/ui-preact'; +import { PizzaDelivery } from './app.tsx'; + +await using ui = await createUI({ input: stdin, output: stdout }); +const root = createRoot(ui.host.element); +root.render(); +await ui.main(); diff --git a/packages/pizza-preact/test/pizza-preact.test.ts b/packages/pizza-preact/test/pizza-preact.test.ts new file mode 100644 index 0000000..7a66f36 --- /dev/null +++ b/packages/pizza-preact/test/pizza-preact.test.ts @@ -0,0 +1,99 @@ +import { expect } from 'vitest'; +import { test } from 'ghostwright/vitest'; +// oxlint-disable-next-line import/no-unassigned-import -- Install typed terminal and clack matchers in Vitest. +import '@ghostwright/clack-tty/vitest'; +import { type TerminalLaunchOptions } from 'ghostwright'; +import { clackTtyExtension, locator } from '@ghostwright/clack-tty'; + +// The runner fixture owns each terminal and retains artifacts on test failure. +// The app is a real child process; assertions read terminal cells, not Preact state. +const pizza = (): TerminalLaunchOptions => ({ + command: process.execPath, + args: ['--import', 'tsx', 'src/index.tsx'], + cwd: new URL('..', import.meta.url).pathname, + viewport: { columns: 80, rows: 24 }, + env: { CLACK_UI_SEMANTIC: '1' }, + extensions: [clackTtyExtension()], +}); + +const delivery = locator('form[label="delivery"]'); +const name = delivery.locator('input[label="name"]'); +const address = delivery.locator('input[label="address"]'); +const addCard = delivery.locator('button[label="add-card"]'); +const cardDetails = locator('dialog[label="card"]'); +const cardNumber = cardDetails.locator('input[label="card-number"]'); +const expiry = cardDetails.locator('input[label="expiry"]'); +const cvc = cardDetails.locator('input[label="cvc"]'); +const submitCard = cardDetails.locator('button[label="submit-card"]'); + +test('return from card details without losing the delivery address', async ({ launchTerminal }) => { + const { screen, keyboard, waitFor } = await launchTerminal(pizza()); + + // Tell the shop who we are and where to deliver. + expect(await screen.findBy(delivery)).toContainText('Pizza Delivery'); + await waitFor(() => expect(screen.getBy(name)).toHaveInputFocus()); + await keyboard.type('Ryan'); + await waitFor(() => expect(screen.getBy(name)).toContainText('Ryan')); + + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(address)).toHaveInputFocus()); + await keyboard.type('1 Main St'); + await waitFor(() => expect(screen.getBy(address)).toContainText('1 Main St')); + + // Open the card form with the keyboard. Focus moves into the dialog. + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(addCard)).toHaveButtonFocus('Add card')); + await keyboard.press('Enter'); + expect(await screen.findBy(cardDetails)).toContainText('Card Details'); + + await waitFor(() => expect(screen.getBy(cardNumber)).toHaveInputFocus()); + await keyboard.type('4242'); + await waitFor(() => expect(screen.getBy(cardNumber)).toContainText('4242')); + + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(expiry)).toHaveInputFocus()); + await keyboard.type('12/30'); + await waitFor(() => expect(screen.getBy(expiry)).toContainText('12/30')); + + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(cvc)).toHaveInputFocus()); + await keyboard.type('123'); + await waitFor(() => expect(screen.getBy(cvc)).toContainText('123')); + + // Return to the opener, with our delivery details intact. + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(submitCard)).toHaveButtonFocus('Submit card')); + await keyboard.press('Enter'); + await waitFor(() => { + expect(screen.getBy(addCard)).toHaveButtonFocus('Add card'); + expect(screen.queryBy(cardDetails)).toBeNull(); + expect(screen.getBy(name)).toContainText('Ryan'); + expect(screen.getBy(address)).toContainText('1 Main St'); + }); + + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(name)).toHaveInputFocus()); +}); + +test('correct a typo before moving to the address', async ({ launchTerminal }) => { + const { screen, keyboard, waitFor } = await launchTerminal(pizza()); + await waitFor(() => expect(screen.getBy(name)).toHaveInputFocus()); + await keyboard.type('Ryn'); + await waitFor(() => expect(screen.getBy(name)).toContainText('Ryn')); + + // Move before the final letter and insert the missing "a". + await keyboard.press('ArrowLeft'); + await keyboard.type('a'); + await waitFor(() => { + const corrected = screen.getBy(name); + expect(corrected).toContainText('Ryan'); + expect(corrected).toContainCursor({ visible: true }); + }); + + // Tab changes focus, not the name we just corrected. + await keyboard.press('Tab'); + await waitFor(() => { + expect(screen.getBy(address)).toHaveInputFocus(); + expect(screen.getBy(name)).toContainText('Ryan'); + }); +}); diff --git a/packages/pizza-preact/tsconfig.json b/packages/pizza-preact/tsconfig.json new file mode 100644 index 0000000..9106f5c --- /dev/null +++ b/packages/pizza-preact/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "@bomb.sh/tools/tsconfig.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "declarationMap": false, + "jsx": "react-jsx", + "jsxImportSource": "@clack/ui-preact", + "noEmit": true, + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "test/**/*.ts", + "vitest.config.ts", + "../../vendor/clack-ui-preact/src/**/*.ts" + ] +} diff --git a/packages/pizza-preact/vitest.config.ts b/packages/pizza-preact/vitest.config.ts new file mode 100644 index 0000000..da41b43 --- /dev/null +++ b/packages/pizza-preact/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + testTimeout: 60_000, + hookTimeout: 30_000, + teardownTimeout: 30_000, + pool: 'forks', + }, +}); diff --git a/packages/pizza/package.json b/packages/pizza/package.json new file mode 100644 index 0000000..290b2f2 --- /dev/null +++ b/packages/pizza/package.json @@ -0,0 +1,40 @@ +{ + "name": "@ghostwright/pizza", + "version": "0.0.0", + "private": true, + "description": "A clack/ui pizza delivery form with a card dialog, validated with ghostwright tree locators", + "license": "MIT", + "type": "module", + "scripts": { + "start": "tsx src/pizza.ts", + "test": "vitest run" + }, + "dependencies": { + "@bomb.sh/tty": "^0.8.0", + "@clack/ui": "workspace:*", + "@ghostwright/clack-tty": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.20.0", + "ghostwright": "workspace:*", + "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" + } + }, + "@clack/ui": { + "extensions": [ + "@ghostwright/clack-tty/auto" + ] + } +} diff --git a/packages/pizza/src/pizza.ts b/packages/pizza/src/pizza.ts new file mode 100644 index 0000000..c083efc --- /dev/null +++ b/packages/pizza/src/pizza.ts @@ -0,0 +1,185 @@ +/** + * Pizza delivery: a clack/ui form application. A delivery form submits on + * Enter, which opens the card dialog; the card form submits on Enter, which + * closes it. No keyboard policy lives in this file — forms own implicit + * submission, the way they do in the DOM. + * + * Run: `tsx src/pizza.ts` + */ +import { stdin, stdout } from 'node:process'; +import { fixed, grow, rgba } from '@bomb.sh/tty'; +import { createUI, type HostElement } from '@clack/ui'; + +const black = rgba(0, 0, 0); +const blue = rgba(0, 0, 238); +const cyan = rgba(0, 205, 205); +const gray = rgba(127, 127, 127); + +const ui = await createUI({ input: stdin, output: stdout }); +const { host } = ui; + +// Give the application one explicit, full-screen layout parent. Floating +// children can then attach to this stable surface as the terminal resizes. +const screen = host.createElement('box'); +host.setProperty(screen, 'layout', { + direction: 'ttb', + width: grow(), + height: grow(), +}); + +function button(labelText: string, name: string): HostElement { + const element = host.createElement('button'); + host.setProperty(element, 'role', 'button'); + host.setProperty(element, 'label', name); + host.setProperty(element, 'type', 'submit'); + host.setProperty(element, 'layout', { + width: fixed(16), + height: fixed(3), + padding: { top: 1, right: 1, bottom: 1, left: 1 }, + }); + host.setProperty(element, 'border', { + color: gray, + top: 1, + right: 1, + bottom: 1, + left: 1, + }); + host.insertBefore(element, host.createLiteral(labelText)); + return element; +} + +function field(name: string): HostElement { + const element = host.createElement('input'); + host.setProperty(element, 'role', 'textbox'); + host.setProperty(element, 'label', name); + return element; +} + +// --- delivery form --------------------------------------------------------- + +const nameInput = field('name'); +const addressInput = field('address'); + +const delivery = host.createElement('form'); +host.setProperty(delivery, 'role', 'form'); +host.setProperty(delivery, 'label', 'delivery'); +host.setProperty(delivery, 'layout', { + direction: 'ttb', + gap: 1, + padding: { top: 1, bottom: 1, left: 2, right: 2 }, + width: grow(32, 44), +}); +host.setProperty(delivery, 'border', { color: blue, top: 1, right: 1, bottom: 1, left: 1 }); + +const header = host.createElement('text'); +host.setProperty(header, 'color', cyan); +host.insertBefore(header, host.createLiteral('Pizza Delivery')); +host.insertBefore(delivery, header); + +for (const [labelText, element] of [ + ['name:', nameInput], + ['address:', addressInput], +] as const) { + const row = host.createElement('box'); + host.setProperty(row, 'layout', { direction: 'ltr', gap: 1, width: grow() }); + const label = host.createElement('box'); + host.setProperty(label, 'layout', { width: fixed(9) }); + const label2 = host.createElement('text'); + host.setProperty(label2, 'color', gray); + host.insertBefore(label2, host.createLiteral(labelText)); + host.insertBefore(label, label2); + host.insertBefore(row, label); + host.insertBefore(row, element); + host.insertBefore(delivery, row); +} +const actions = host.createElement('box'); +host.setProperty(actions, 'layout', { direction: 'ltr', gap: 1, width: grow() }); +host.insertBefore(actions, button('Add card', 'add-card')); +host.insertBefore(delivery, actions); + +// --- card dialog ----------------------------------------------------------- + +const cardNumberInput = field('card-number'); +const expiryInput = field('expiry'); +const cvcInput = field('cvc'); + +const cardDialog = host.createElement('dialog'); +host.setProperty(cardDialog, 'role', 'dialog'); +host.setProperty(cardDialog, 'label', 'card'); +host.setProperty(cardDialog, 'modal', true); +host.setProperty(cardDialog, 'layout', { + direction: 'ttb', + width: grow(32, 44), +}); +host.setProperty(cardDialog, 'bg', black); +host.setProperty(cardDialog, 'border', { + color: blue, + top: 1, + right: 1, + bottom: 1, + left: 1, +}); +host.setProperty(cardDialog, 'floating', { + attachTo: 'parent', + attachPoints: { element: 'center-center', parent: 'center-center' }, + zIndex: 1, +}); + +const card = host.createElement('form'); +host.setProperty(card, 'role', 'form'); +host.setProperty(card, 'label', 'card-payment'); +host.setProperty(card, 'layout', { + direction: 'ttb', + gap: 1, + padding: { top: 1, bottom: 1, left: 2, right: 2 }, + width: grow(), +}); +host.insertBefore(cardDialog, card); + +const cardHeader = host.createElement('text'); +host.setProperty(cardHeader, 'color', cyan); +host.insertBefore(cardHeader, host.createLiteral('Card Details')); +host.insertBefore(card, cardHeader); + +for (const [labelText, element] of [ + ['card-number:', cardNumberInput], + ['expiry:', expiryInput], + ['cvc:', cvcInput], +] as const) { + const row = host.createElement('box'); + host.setProperty(row, 'layout', { direction: 'ltr', gap: 1, width: grow() }); + const label = host.createElement('box'); + host.setProperty(label, 'layout', { width: fixed(13) }); + const label2 = host.createElement('text'); + host.setProperty(label2, 'color', gray); + host.insertBefore(label2, host.createLiteral(labelText)); + host.insertBefore(label, label2); + host.insertBefore(row, label); + host.insertBefore(row, element); + host.insertBefore(card, row); +} +const cardActions = host.createElement('box'); +host.setProperty(cardActions, 'layout', { direction: 'ltr', gap: 1, width: grow() }); +host.insertBefore(cardActions, button('Submit card', 'submit-card')); +host.insertBefore(card, cardActions); + +// --- behavior: forms submit, the app decides what that means --------------- + +let cardOpen = false; + +host.addEventListener(delivery, 'submit', () => { + if (cardOpen) return; + cardOpen = true; + host.insertBefore(screen, cardDialog); +}); + +host.addEventListener(card, 'submit', () => { + if (!cardOpen) return; + cardOpen = false; + host.removeChild(screen, cardDialog); +}); + +host.insertBefore(screen, delivery); +host.insertBefore(host.element, screen); + +await ui.main(); diff --git a/packages/pizza/test/pizza.test.ts b/packages/pizza/test/pizza.test.ts new file mode 100644 index 0000000..3b6b232 --- /dev/null +++ b/packages/pizza/test/pizza.test.ts @@ -0,0 +1,134 @@ +import { expect, test } from 'vitest'; +// oxlint-disable-next-line import/no-unassigned-import -- Install typed terminal and clack matchers in Vitest. +import '@ghostwright/clack-tty/vitest'; +import { withTerminal, settled, type TerminalLaunchOptions } from 'ghostwright'; +import { clackTtyExtension, locator } from '@ghostwright/clack-tty'; + +// Launch the real CLI. Recipes address controls; runner assertions inspect +// frozen terminal evidence. Only waitFor retries an assertion. +const pizza = (): TerminalLaunchOptions => ({ + command: process.execPath, + args: ['--import', 'tsx', 'src/pizza.ts'], + cwd: new URL('..', import.meta.url).pathname, + viewport: { columns: 80, rows: 24 }, + env: { CLACK_UI_SEMANTIC: '1' }, + extensions: [clackTtyExtension()], + selector: locator, +}); + +const delivery = locator('form[label="delivery"]'); +const name = delivery.locator('input[label="name"]'); +const address = delivery.locator('input[label="address"]'); +const addCard = delivery.locator('button[label="add-card"]'); +const cardDetails = locator('dialog[label="card"]'); +const cardNumber = cardDetails.locator('input[label="card-number"]'); +const expiry = cardDetails.locator('input[label="expiry"]'); +const cvc = cardDetails.locator('input[label="cvc"]'); +const submitCard = cardDetails.locator('button[label="submit-card"]'); + +test('tell the pizza shop where to deliver', async () => { + await withTerminal(pizza(), async ({ screen, keyboard, waitFor }) => { + expect(await screen.findBySelector('form[label="delivery"]')).toContainText('Pizza Delivery'); + + // The name field is ready to type into as soon as the form opens. + await waitFor(() => expect(screen.getBy(name)).toHaveInputFocus()); + await keyboard.type('Ryan'); + await waitFor(() => expect(screen.getBy(name)).toContainText('Ryan')); + + // Continue with the keyboard. Input is outside the retried assertions. + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(address)).toHaveInputFocus()); + await keyboard.type('1 Main St'); + await waitFor(() => { + expect(screen.getBy(address)).toContainText('1 Main St'); + expect(screen.getBy(name)).toContainText('Ryan'); + }); + }); +}); + +test('keep keyboard navigation inside card details until the form is submitted', async () => { + await withTerminal(pizza(), async ({ screen, keyboard, waitFor }) => { + // Reach "Add card" from the delivery form. + await waitFor(() => expect(screen.getBy(name)).toHaveInputFocus()); + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(address)).toHaveInputFocus()); + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(addCard)).toHaveButtonFocus('Add card')); + await keyboard.press('Enter'); + expect(await screen.findBy(cardDetails)).toContainText('Card Details'); + + // Tab visits each card field in order. + await waitFor(() => expect(screen.getBy(cardNumber)).toHaveInputFocus()); + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(expiry)).toHaveInputFocus()); + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(cvc)).toHaveInputFocus()); + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(submitCard)).toHaveButtonFocus('Submit card')); + + // Neither direction lets focus escape into the form behind the dialog. + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(cardNumber)).toHaveInputFocus()); + await keyboard.press('Shift+Tab'); + await waitFor(() => expect(screen.getBy(submitCard)).toHaveButtonFocus('Submit card')); + + // Closing the dialog returns us to the button that opened it. + await keyboard.press('Enter'); + await waitFor(() => { + expect(screen.getBy(addCard)).toHaveButtonFocus('Add card'); + expect(screen.queryBy(cardDetails)).toBeNull(); + }); + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(name)).toHaveInputFocus()); + }); +}); + +test('keep the delivery form usable in a narrow terminal', async () => { + await withTerminal(pizza(), async ({ screen, keyboard, waitFor, capture }) => { + await waitFor(() => expect(screen.getBy(name)).toHaveInputFocus()); + + // Record the resize until this form settles, rather than sleeping and hoping. + const recording = await capture({ until: settled(delivery, 50) }, async ({ resize }) => { + await resize({ columns: 36, rows: 20 }); + }); + + // These assertions inspect historical evidence, not the live screen. + const resizedForm = recording.observations + .flatMap((observation) => delivery.resolve(observation)) + .at(-1)!; + expect(resizedForm.screen.viewport.columns).toBe(36); + expect(resizedForm.visibleBounds).toEqual(resizedForm.bounds); + expect(resizedForm).toContainText('Pizza Delivery'); + + await keyboard.press('Enter'); + expect(await screen.findBy(cardDetails)).toContainText('Card Details'); + await waitFor(() => expect(screen.getBy(cardNumber)).toHaveInputFocus()); + }); +}); + +test('edit the name with the cursor, then continue to the next control', async () => { + await withTerminal(pizza(), async ({ screen, keyboard, waitFor }) => { + await waitFor(() => expect(screen.getBy(name)).toHaveInputFocus()); + await keyboard.type('Ryn'); + await waitFor(() => expect(screen.getBy(name)).toContainText('Ryn')); + + // Correct the typo in place, just as a person would. + await keyboard.press('ArrowLeft'); + await keyboard.type('a'); + await waitFor(() => { + const corrected = screen.getBy(name); + expect(corrected).toContainText('Ryan'); + expect(corrected).toContainCursor({ visible: true }); + }); + + await keyboard.press('Tab'); + await waitFor(() => expect(screen.getBy(address)).toHaveInputFocus()); + await keyboard.press('Tab'); + await waitFor(() => { + const button = screen.getBy(addCard); + expect(button).toHaveButtonFocus('Add card'); + // Buttons show focus, but not a text-entry cursor. + expect(button.screen.cursor.visible).toBe(false); + }); + }); +}); diff --git a/packages/pizza/vitest.config.ts b/packages/pizza/vitest.config.ts new file mode 100644 index 0000000..866bafa --- /dev/null +++ b/packages/pizza/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', + maxWorkers: 1, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 685c731..ca53408 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,13 +31,19 @@ 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)(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)) + version: 0.6.1(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.13))(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 '@types/node': specifier: ^22 version: 22.20.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + 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)) examples/demo: dependencies: @@ -65,6 +71,25 @@ importers: effection: specifier: ^4.0.2 version: 4.0.3 + devDependencies: + '@jest/globals': + specifier: ^30.2.0 + version: 30.5.1 + '@types/bun': + specifier: ^1.3.9 + version: 1.4.1 + expect: + specifier: ^30.2.0 + version: 30.5.1 + jest: + specifier: ^30.2.0 + version: 30.5.1(@types/node@22.20.1) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + 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/clack-tty: dependencies: @@ -119,6 +144,65 @@ importers: 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/pizza: + 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)) + + packages/pizza-preact: + 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 + '@clack/ui-preact': + specifier: workspace:* + version: link:../../vendor/clack-ui-preact + '@ghostwright/clack-tty': + specifier: workspace:* + version: link:../clack-tty + preact: + specifier: 11.0.0-beta.2 + version: 11.0.0-beta.2 + 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 + typescript: + specifier: ^5.7.2 + version: 5.9.3 + 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': @@ -127,6 +211,10 @@ importers: '@types/node': specifier: ^22.20.0 version: 22.20.1 + devDependencies: + 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-preact: dependencies: @@ -149,13 +237,178 @@ importers: devDependencies: '@bomb.sh/tools': specifier: ^0.5.4 - 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)) + version: 0.5.6(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.13))(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)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages: + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-async-generators@7.8.4': + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-bigint@7.8.3': + resolution: {integrity: sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-properties@7.12.13': + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-class-static-block@7.14.5': + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-attributes@7.29.7': + resolution: {integrity: sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-import-meta@7.10.4': + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-json-strings@7.8.3': + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4': + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3': + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-numeric-separator@7.10.4': + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-object-rest-spread@7.8.3': + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-catch-binding@7.8.3': + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-optional-chaining@7.8.3': + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-private-property-in-object@7.14.5': + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-top-level-await@7.14.5': + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@0.2.3': + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@bomb.sh/args@0.3.1': resolution: {integrity: sha512-CwxKrfgcorUPP6KfYD59aRdBYWBTsfsxT+GmoLVnKo5Tmyoqbpo0UNcjngRMyU+6tiPbd18RuIYxhgAn44wU/Q==} @@ -380,9 +633,116 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@istanbuljs/load-nyc-config@1.1.0': + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + + '@jest/console@30.5.1': + resolution: {integrity: sha512-u5Ncuc+gXVUwjNMFQOnphHo2Qx2DxyC8Dvpmf4HCx4y0kvSkRRNiQYRixrvOP4V7y52JcSuNxqochCt0PpdHVg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/core@30.5.1': + resolution: {integrity: sha512-BL9g6CJUUhIbdoAflz/Va658erSSXUIvU8XUYiWNTbZljJjZ5yaC9EJ9/dQ19rpbYV3ZOK4sBACqhPaTyhoUIA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/diff-sequences@30.5.0': + resolution: {integrity: sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/environment@30.5.1': + resolution: {integrity: sha512-eYJAkOsrpwDXPcoLNEG6lN9Zo3Cy35pxnVQD74vyIfsi/Q8wB/lZFsEjU4wrecOgT4ZviQDZaX/cFIJyMdMxTw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.5.1': + resolution: {integrity: sha512-WcRWhHQdTMRDpyWKZ/6MINBmovI7zeD+bL8wFjCncRV3NQOwKy1X45IfyblfHR4k/XciIlNEdFL9QjFO+HNKOg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect@30.5.1': + resolution: {integrity: sha512-uOGd40P/COyUp9xHf5jeGiGJC2/ANg+2+Tk9/xN5/LxmlY/r/gxsPrx3DTEtaRZsLAX4wgNSLEDELZ5bmVs2bA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/fake-timers@30.5.1': + resolution: {integrity: sha512-rEkV6YzpBXo/L9cnj2ibyuYZuyGiNWaPUsyCu3HYJbqCV4jnEiKmf7hId20z8eKjZ0JQ9jtIYKxRSmYB/OYSsA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.5.0': + resolution: {integrity: sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/globals@30.5.1': + resolution: {integrity: sha512-VhqvQ251XIC7pk46YymI3HCeBLOdvriDw9zibekodAHEwoVvbVVgpU5H13GvmyOAzxtZCfsm1uvzqkaqJf2I+g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.5.0': + resolution: {integrity: sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/reporters@30.5.1': + resolution: {integrity: sha512-RbUXIfv85KxitJn4l3MpAoMilkvXe2QCO5lXxHWftywo/VdZ5vkEoHRDgphcxP6qmoIkUaN8/UZj6NhdkIFdJg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + '@jest/schemas@30.5.0': + resolution: {integrity: sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/snapshot-utils@30.5.1': + resolution: {integrity: sha512-V3wnxNtiVmw5PPVg433Cn3VdXnsOeu/ofLw3KC04Bn2y1wIlU5kozXQn48rhrgj/02LrCVtntTEF1yBha0XSUw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/source-map@30.5.0': + resolution: {integrity: sha512-xWpTJP9D0bDFGbPGT8XuWSwwha/iHADyyKzUnMx4UbdgnHugxrDaQFO4RZ8x4ZsFzRP6pNii8uvlgKCDxCIuDg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-result@30.5.1': + resolution: {integrity: sha512-A/1S6ZBdpic50E0pxLgvaB9XNPL4k7AksmG69OO2oiotxciWZwHkbOec+qbIi+uUtIIByOVlXJUl97oZUsz+Jw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/test-sequencer@30.5.1': + resolution: {integrity: sha512-SHcPnrjdVRYJv6y6l4JUTy4Jccu7zmO6BmiOfOA34UiVBto22s19WiDC0A/2qfM7MWB/qdcoqfSIRMitpjTT4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/transform@30.5.1': + resolution: {integrity: sha512-EDnDhn0jleU9ZhpVoA4gvqw+Ev0iw/r5upNT2b79RiwiaTiYAMMhvNJP3lmocjIe5J2ZkoNr0B3K/J6O5GIm4Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@30.5.1': + resolution: {integrity: sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@napi-rs/wasm-runtime@1.1.6': resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} peerDependencies: @@ -840,6 +1200,90 @@ packages: cpu: [x64] os: [win32] + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@pkgr/core@0.3.6': + resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} + engines: {node: ^14.18.0 || >=16.0.0} + '@publint/pack@0.1.5': resolution: {integrity: sha512-edgyN2pP07uXiP4tJs0s8KVmU8M8i60YPbbI0/WDeok1mIJHRXz+CgD8I0nelwDkoCh3EWL/G5kGfbuHjsdbvw==} engines: {node: '>=18'} @@ -1031,12 +1475,36 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sinclair/typebox@0.34.52': + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} + + '@sinonjs/commons@3.0.1': + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + + '@sinonjs/fake-timers@15.4.0': + resolution: {integrity: sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/bun@1.4.1': + resolution: {integrity: sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1046,9 +1514,27 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260623.1': resolution: {integrity: sha512-8AX9NwC+G6Sbh5hNLnx8YgxoRV/8BH8FQRtZ86OTtUQfESMRvwszOGTtfcC32g86O5jsEQPDHXKVSnsIzWh6lg==} engines: {node: '>=16.20.0'} @@ -1096,73 +1582,186 @@ packages: engines: {node: '>=16.20.0'} hasBin: true - '@vitest/expect@4.1.9': - resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} - - '@vitest/mocker@4.1.9': - resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} - peerDependencies: - msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0 || ^8.0.0 - peerDependenciesMeta: - msw: - optional: true - vite: - optional: true - - '@vitest/pretty-format@4.1.9': - resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} - - '@vitest/runner@4.1.9': - resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} - - '@vitest/snapshot@4.1.9': - resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} - '@vitest/spy@4.1.9': - resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + resolution: {integrity: sha512-g5T90pqg1bo/7mytQx6F4iBNC0Wsh9cu+z9veDbFjc7HjpesJFWD7QMS0NGStXM075+7dJPPVvBbpZlnrdpi/w==} + cpu: [arm] + os: [android] - '@vitest/utils@4.1.9': - resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + '@unrs/resolver-binding-android-arm64@1.12.2': + resolution: {integrity: sha512-YGCRZv/9GLhwmz6mYDeTsm/92BAyR28l6c2ReweVW5pWgfsitWLY8upvfRlGdoyD8HjeTHSYJWyZGD4KJA/nFQ==} + cpu: [arm64] + os: [android] - '@yuku-codegen/binding-darwin-arm64@0.6.3': - resolution: {integrity: sha512-pbDcFygFmbvo0jGFq5U0m5Sa9U8aVttVJWbBHZDZ68w/X48HdDWS1V4XvBacs8XkmWbTr/ef5fMGG7HsngqTmg==} + '@unrs/resolver-binding-darwin-arm64@1.12.2': + resolution: {integrity: sha512-u9DiNT1auQMO20A9SyTuG3wUgQWB9Z7KjAg0uFuCDR1FsAY8A0CG2S6JpHS1xwm/w1G08bjXZDcyOCjv1WAm2w==} cpu: [arm64] os: [darwin] - '@yuku-codegen/binding-darwin-x64@0.6.3': - resolution: {integrity: sha512-qZMrnA4i8OfqL3NMGoOvLdh1vby8cCGsmNo+tJQIIezXO557m3fdQLenz/57GqtBnnGWzWqd/aDp+faNxWlLwQ==} + '@unrs/resolver-binding-darwin-x64@1.12.2': + resolution: {integrity: sha512-f7rPLi/T1HVKZu/u6t87lroib16n8vrSzcyxI7lg4BGO9UF26KhQL44sd9eOUgrTYhvRXtWOIZT5PejdPyJfUA==} cpu: [x64] os: [darwin] - '@yuku-codegen/binding-freebsd-x64@0.6.3': - resolution: {integrity: sha512-2+SFpfem2GBH6BlCTAq6R44bZwuieduwRWHkCQnSgbK8tdEjMwB0Ix0IchryVBn6hdiWCZSTkSE3UILliRXsRQ==} + '@unrs/resolver-binding-freebsd-x64@1.12.2': + resolution: {integrity: sha512-BpcOjWCJub6nRZUS2zA20pmLvjtqAtGejETaIyRLiZiQf++cbrjltLA5NN/xaXfqeOBOSlMFbemIl5/S5tljmg==} cpu: [x64] os: [freebsd] - '@yuku-codegen/binding-linux-arm-gnu@0.6.3': - resolution: {integrity: sha512-fbxg3cBPdJ++36DXtdzcoKw2xzFov91Wxvmn1khX9MXQbDqJQLJmITZhtokcZsj4uGJe32sUmxAZnKbUtZLjmA==} + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + resolution: {integrity: sha512-vZTDvdSISZjJx66OzJqtsOhzifbqRjbmI1Mnu49fQDwog5GtDI4QidRiEAYbZCRj9C8YZEW+3ZjqsyS9GR4k2A==} cpu: [arm] os: [linux] - '@yuku-codegen/binding-linux-arm-musl@0.6.3': - resolution: {integrity: sha512-Jk4P7kocGEisSvUFIm1VuHO3hC01LvS3sYAAmVVu1/ve5TuZ0iXyl9kIGtd1ZrgUvchgvZWNOaB+/Kq/RO63FA==} + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + resolution: {integrity: sha512-BiPI+IrIlwcW4nLLMM21+B1dFPzd55yAVgVGrdgDjNef+ch03GdxrcyaIz8X9SsQirh/kCQ7mviyWlMxdh2D7g==} cpu: [arm] os: [linux] - '@yuku-codegen/binding-linux-arm64-gnu@0.6.3': - resolution: {integrity: sha512-i1xE8Bx1YZLheWtBZHD0Mq3nAIDrhgiH7o8VB4GiCbHufKb4XKj4CqSDMWSC0RYPmn//E+UEd1NsE2NbOux1tQ==} + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + resolution: {integrity: sha512-zJc0H99FEPoFfSrNpa91HYfxzfAJCr502oxNK1cfdC9hlaFI43RT+JFCann9JUgZmLzzntChHyn13Sgn9ljHNg==} cpu: [arm64] os: [linux] - '@yuku-codegen/binding-linux-arm64-musl@0.6.3': - resolution: {integrity: sha512-ZeLkC6xZrlDoIJTadHfqTABTmsj2f6wCCtYBYx/RPGgdmQcGLA0NALRl7m0tnK2CT45eNRuOYzsfEZyF0XWM/A==} + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + resolution: {integrity: sha512-KQ3Lki6l+Pz1k/eBipN41ES+YUK30beLGb9YqcB1O542cyLCNE6GaxrfcY3T6EezmGGk84wb5XyO9loTM9tkcA==} cpu: [arm64] os: [linux] - '@yuku-codegen/binding-linux-x64-gnu@0.6.3': - resolution: {integrity: sha512-HNYt7zjIChPcnjZRG42CZq3Zn5mqaRo4UcFLr4mIbmGqdhX5hDDE8O8US8YgYyOUosfbBTBFdsbvFwVAx1TOkQ==} - cpu: [x64] + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + resolution: {integrity: sha512-3SJGEh1DborhG6pyxvhPzCT4bbSIVihsvgJc13P1bHG7KLdNDaF9T3gsTwFc7Jw/5Y5/iWOjkEx7Zy0NvCGX3Q==} + cpu: [loong64] + os: [linux] + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + resolution: {integrity: sha512-jiuG/Obbel7uw1PwHNFfrkiKhLAF6mnyZ6aWlOAVN9WqKm8v0OFGnciJIHu8+CMvXLQ8AD51LPzAoUfT21D5Ew==} + cpu: [loong64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + resolution: {integrity: sha512-q7xRvVpmcfeL+LlZg8Pbbo6QaTZwDU5BaGZbwfhkEsXJn3Was8xYfE0RBH266xZt0rM6B7i8xAYIvjthuUIWHg==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + resolution: {integrity: sha512-0CVdx6lcnT3Q9inOH8tsMIOJ6ImndllMjqJHg8RLVdB7Vq4SfkEXl9mCSsVNuNA4MCYycRicCUxPCabVHJRr6A==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + resolution: {integrity: sha512-iOwlRo9vnp6R6ohHQS11n0NnfdXx/omhkocmIfaPRpQhKZ+3BDMkkdRVh53qjkFkpPddf+FETA28NwGN7l5l+w==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + resolution: {integrity: sha512-HYJtLfXq94q8iZNFT1lknx258wlkkWhZeUXJRqzKBBUJ00CvZ+N33zgbCqimLjsyw5Va6uUxhVa12mI+kaveEw==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + resolution: {integrity: sha512-mPsUhunKKDih5O96Y6enDQyHc1SqBPlY1E/SfMWDM3EdJ95Z9CArPeCVwCCqbP45ljvivdEk8Fxn+SIb1rDAJQ==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + resolution: {integrity: sha512-azrt6+5ydLd8Vt210AAFis/lZevSfPw93EJRIJG+xPu4WCJ8K0kppCTpMyLPcKT7H15M4Jnt2tMp5bOvCkRC6A==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + resolution: {integrity: sha512-YZ9hP4O0X9PQb8eO980qmLNGH4zT3I9+SZTdt0Pr0YyuGQhYKoOZkV02VzrzyOZJ5xIJ3UFIenKkUkGg8GjgWQ==} + cpu: [arm64] + os: [openharmony] + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + resolution: {integrity: sha512-tYFDIkMxSflfEc/h92ZWNsZlHSwgimbNHSO3PL2JWQHfCuC2q316jMyYU9TIWZsFK2bQwyK5VAdYgn8ygPj69A==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + resolution: {integrity: sha512-qzNyg3xL0VPQmCaUh+N5jSitce6k+uCBfMDesWRnlULOZaqUkaJ0ybdT+UqlAWJoQjuqfIU/0Ptx9bteN4D82g==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + resolution: {integrity: sha512-WD9sY00OfpHVGfsnHZoA8jVT+esS/Bg8z8jzxp5BnDCjjwsuKsPQrzswwpFy4J1AUJbXPRfkpcX0mXrzeXW79g==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + resolution: {integrity: sha512-nAB74NfSNKknqQ1RrYj6uz8FcXEomu/MATJZxh/x+BArzN2U3JbOYC0APYzUIGhVY3m5hRxA8VPNdPBoG8txlA==} + cpu: [x64] + os: [win32] + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + '@yuku-codegen/binding-darwin-arm64@0.6.3': + resolution: {integrity: sha512-pbDcFygFmbvo0jGFq5U0m5Sa9U8aVttVJWbBHZDZ68w/X48HdDWS1V4XvBacs8XkmWbTr/ef5fMGG7HsngqTmg==} + cpu: [arm64] + os: [darwin] + + '@yuku-codegen/binding-darwin-x64@0.6.3': + resolution: {integrity: sha512-qZMrnA4i8OfqL3NMGoOvLdh1vby8cCGsmNo+tJQIIezXO557m3fdQLenz/57GqtBnnGWzWqd/aDp+faNxWlLwQ==} + cpu: [x64] + os: [darwin] + + '@yuku-codegen/binding-freebsd-x64@0.6.3': + resolution: {integrity: sha512-2+SFpfem2GBH6BlCTAq6R44bZwuieduwRWHkCQnSgbK8tdEjMwB0Ix0IchryVBn6hdiWCZSTkSE3UILliRXsRQ==} + cpu: [x64] + os: [freebsd] + + '@yuku-codegen/binding-linux-arm-gnu@0.6.3': + resolution: {integrity: sha512-fbxg3cBPdJ++36DXtdzcoKw2xzFov91Wxvmn1khX9MXQbDqJQLJmITZhtokcZsj4uGJe32sUmxAZnKbUtZLjmA==} + cpu: [arm] + os: [linux] + + '@yuku-codegen/binding-linux-arm-musl@0.6.3': + resolution: {integrity: sha512-Jk4P7kocGEisSvUFIm1VuHO3hC01LvS3sYAAmVVu1/ve5TuZ0iXyl9kIGtd1ZrgUvchgvZWNOaB+/Kq/RO63FA==} + cpu: [arm] + os: [linux] + + '@yuku-codegen/binding-linux-arm64-gnu@0.6.3': + resolution: {integrity: sha512-i1xE8Bx1YZLheWtBZHD0Mq3nAIDrhgiH7o8VB4GiCbHufKb4XKj4CqSDMWSC0RYPmn//E+UEd1NsE2NbOux1tQ==} + cpu: [arm64] + os: [linux] + + '@yuku-codegen/binding-linux-arm64-musl@0.6.3': + resolution: {integrity: sha512-ZeLkC6xZrlDoIJTadHfqTABTmsj2f6wCCtYBYx/RPGgdmQcGLA0NALRl7m0tnK2CT45eNRuOYzsfEZyF0XWM/A==} + cpu: [arm64] + os: [linux] + + '@yuku-codegen/binding-linux-x64-gnu@0.6.3': + resolution: {integrity: sha512-HNYt7zjIChPcnjZRG42CZq3Zn5mqaRo4UcFLr4mIbmGqdhX5hDDE8O8US8YgYyOUosfbBTBFdsbvFwVAx1TOkQ==} + cpu: [x64] os: [linux] '@yuku-codegen/binding-linux-x64-musl@0.6.3': @@ -1241,29 +1840,167 @@ packages: alien-signals@2.0.8: resolution: {integrity: sha512-844G1VLkk0Pe2SJjY0J8vp8ADI73IM4KliNu2OGlYzWpO28NexEUvjHTcFjFX3VXoiUtwTbHxLNI9ImkcoBqzA==} + ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.3.0: + resolution: {integrity: sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + babel-jest@30.5.1: + resolution: {integrity: sha512-ge1xUVZS91ml09YRMgRGgeKJ4YJpcOiuwteAxFBYLugQyp7cRw+hHej6Ho0vPjvLrjq60bb7JPHH9LAMA1/krA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-0 + + babel-plugin-istanbul@8.0.0: + resolution: {integrity: sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==} + engines: {node: '>=18'} + + babel-plugin-jest-hoist@30.5.0: + resolution: {integrity: sha512-gtGo1B+u14jrZQv6TdSWIWkTqclboo7Qn+dFAGUOIuXLFuJKAdg3U5MIWzZnW4vfUb4dX9skmAMiby86e/SF4A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + babel-preset-current-node-syntax@1.2.0: + resolution: {integrity: sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==} + peerDependencies: + '@babel/core': ^7.0.0 || ^8.0.0-0 + + babel-preset-jest@30.5.0: + resolution: {integrity: sha512-ZGPn5ClP4lBDpuOK8W1yQIOy359HmbnZv3suucAlIe+SEE9yDsxV4S2PjSbH2Vc97U+WmzYD7vw3kr8NsQ/i6w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@babel/core': ^7.11.0 || ^8.0.0-beta.1 || ^8.0.0 + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.21: + resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} + engines: {node: '>=6.0.0'} + hasBin: true + boolbase@2.0.0: resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} engines: {node: '>=20.19.0'} + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + + browserslist@4.28.9: + resolution: {integrity: sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + + bun-types@1.4.1: + resolution: {integrity: sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA==} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + caniuse-lite@1.0.30001810: + resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + char-regex@1.0.2: + resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} + engines: {node: '>=10'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cjs-module-lexer@2.2.1: + resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + co@4.6.0: + resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + + collect-v8-coverage@1.0.3: + resolution: {integrity: sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + css-select@7.0.0: resolution: {integrity: sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==} engines: {node: '>=20.19.0'} @@ -1272,6 +2009,27 @@ packages: resolution: {integrity: sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==} engines: {node: '>=20.19.0'} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -1279,6 +2037,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-newline@3.1.0: + resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} + engines: {node: '>=8'} + dom-serializer@3.1.1: resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} engines: {node: '>=20.19.0'} @@ -1304,10 +2066,26 @@ packages: oxc-resolver: optional: true + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + effection@4.0.3: resolution: {integrity: sha512-bmzMdw1+q6d+O6J2iU314cXofWsZEDmeF2nqOjgOJe7R964RDJKamXdoO3iBvmMa7CJhgNcjZHow1ILzUP8Bjg==} engines: {node: '>= 16'} + electron-to-chromium@1.5.422: + resolution: {integrity: sha512-UvA/32XqrLDdZSn7Jllo1AYNcWji/G0d5M0GTViE7KoGBiMunw3a34Sb2KO4ZZyrSEhqsxFoVhWWJshdyfKqJA==} + + emittery@0.13.1: + resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} + engines: {node: '>=12'} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} @@ -1316,6 +2094,9 @@ packages: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} @@ -1324,13 +2105,41 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + exit-x@0.2.2: + resolution: {integrity: sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==} + engines: {node: '>= 0.8.0'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + expect@30.5.1: + resolution: {integrity: sha512-m8YrYgvKe9+9gEnWEuKz+qCGfHqkrff7PPfyDnOFkjsfYRKqiYyOxDFMFieCGAuhtk/VNm63tvNgKZVzVy+Hvg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + fast-string-truncated-width@3.0.3: resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} @@ -1340,6 +2149,9 @@ packages: fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fd-package-json@2.0.0: resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} @@ -1352,6 +2164,14 @@ packages: picomatch: optional: true + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + formatly@0.3.0: resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==} engines: {node: '>=18.3.0'} @@ -1362,6 +2182,22 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -1369,22 +2205,246 @@ packages: resolution: {integrity: sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ==} engines: {node: '>=20.20.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + hookable@6.1.1: resolution: {integrity: sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + import-local@3.2.0: + resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} + engines: {node: '>=8'} + hasBin: true + import-without-cache@0.4.0: resolution: {integrity: sha512-NkJQA7oZ4YHQhd2+H3BoRFKF3d/XNsiKpHZCQEMH9pDX27hQQLsTyOocyRgaIVtf8gHX3Nt3LPkR4e5EdtPAGQ==} engines: {node: ^22.18.0 || >=24.0.0} + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-generator-fn@2.1.0: + resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} + engines: {node: '>=6'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-instrument@6.0.3: + resolution: {integrity: sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==} + engines: {node: '>=10'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + jest-changed-files@30.5.1: + resolution: {integrity: sha512-0+bvMM/ENhDI29Z8q1r4HxiDIi4G5tnBmSw4esfPQoj9q8Nik7KIyuxHkTVnnniJQf05SxpGaKDQ+4h28ynGPg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-circus@30.5.1: + resolution: {integrity: sha512-NgliezXQ6yznqR4W5Gqw++0cZSbBZlF0NknNIAE5VmSJKWOtmWJmWnBq3TSkUOVBTPrT7FGGhVdA8DLWnxo2Sw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-cli@30.5.1: + resolution: {integrity: sha512-uwNYepWgaNBCplm42fCIUZTf/tIEHIy5AYvx7eL1BrwIgyjPt+2poouR23UO2yopIP+kV8oZFHfv+l4pg/8prQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + + jest-config@30.5.1: + resolution: {integrity: sha512-L8PKM2X/ngG8PxfLMqglKGZjylPgw84bVPUlMY9W/o76TnLqPWrmQgsaT0HrheGdRtpGE5FbmREA0Kvb+Qx5gQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + peerDependencies: + '@types/node': '*' + esbuild-register: '>=3.4.0' + ts-node: '>=9.0.0' + peerDependenciesMeta: + '@types/node': + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + + jest-diff@30.5.1: + resolution: {integrity: sha512-e3cNNMpv8Kh20MjjphTXs+3Vz7DQyLM1nft7KJhnh46atFhjVJRa+0Hq0beywuwsACtMQUBihQkFl8zxb7gt1Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-docblock@30.5.0: + resolution: {integrity: sha512-NwDqcxtoZi33RhuW+zJS/RVA3rmheQ8BnwpYZuc/Eruaz6seQb7+aoCeDZu/3X7W2XmD8DbSo9Pn72DbKsBFYw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-each@30.5.1: + resolution: {integrity: sha512-S1af0TU4v1EZ/AUlkFs/sxf/5KGsbAT9kRgdyoMX/x72y7C8ZEgETE5o1TPnbKRnrbprc5FR/moYLfdRmqEjsQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-environment-node@30.5.1: + resolution: {integrity: sha512-LrPj3sPMjsQoOB3jrb8p/sa+XkSFNKo60TTsyc+EB2kxQJHgbwElpXnx1yX25fdFn5958FIObRtcLSHyV8VIAw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-haste-map@30.5.1: + resolution: {integrity: sha512-VIFgt67jW480YDxKfEv9IYQKrFpYt7bOCMn3VsnjK7AK9qo7p6acpnA1DHwsVOULE3dTaYew/6JaTD/d0VDHoQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-leak-detector@30.5.1: + resolution: {integrity: sha512-gt4GT2aWgEoCNTcBe4rqS74xTIUJxi+UD9SNSu9aOk5LmTEd6fS5KSTmAKAfitCCktQHNN+upUuL6EkBfFbMDQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.5.1: + resolution: {integrity: sha512-aroZVqwOz/wC2y6pC+obgFWKV9viaQWQTTSB6W5H55+egtUczIfXR/rxExTv92xD/ADYwpqaYfVWx1aqwKg7FA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.5.1: + resolution: {integrity: sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.5.1: + resolution: {integrity: sha512-9fVjc3leUpGID2/by/LU4Dvdcp7PFh9LlxS3QRWK3ABm+KtvEVsG/AEGeLY3gKOZsjkBxyfwGltoAVlW7dygHg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-regex-util@30.5.0: + resolution: {integrity: sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve-dependencies@30.5.1: + resolution: {integrity: sha512-JKpXGONDcTaunrNVn8KCl6qAnwl06jIkvv70lhq0Ze47lsPx9sH5HoeEUiXX6iFGnliHN/qpIbQ0l38wrVmXaw==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-resolve@30.5.1: + resolution: {integrity: sha512-wprhLejRtwN6h8ZgaqC0eYjGJ1uMdGPT/+b3eFncbG4NWuuP9QL+vWwRinu9waw7hkOLcpMJGVJAMgBwsPssMQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runner@30.5.1: + resolution: {integrity: sha512-FPlQE4+mwnFxXmpPrSi836KV2ZzvK1g6/nPCT8o5BcoDUUNJQeHo1/Qdkoe/QeoL4M7OeJpbnGUhYKhC1VMdaQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-runtime@30.5.1: + resolution: {integrity: sha512-UB88+NRkK2Tw/OqV7dofYcyiUGrVZtD41k/N0xQOs9fG//57XHK7JLWG52HD1UYpUAhjK4xm6DBvFX3yWudsMA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-snapshot@30.5.1: + resolution: {integrity: sha512-cNWFdSb5xuDGl8hKkAZJ3YtI/PzHpAPFV+HUXWIOG8rMhpDTLVbvAc2d2wRicoYw2wGsJGLaurJT6BLC97bLXQ==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@30.5.1: + resolution: {integrity: sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-validate@30.5.1: + resolution: {integrity: sha512-i/buJ56wTpxihE93hQYNMfdOThS87+HGvLZzZJmU4xggPcdTYQq051iwALLCHp3q+SkCGH+EFejQZz5EbBSkkg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-watcher@30.5.1: + resolution: {integrity: sha512-+FHJ7C+S7b3ySfhA1aFmoa6TztsnwZVv84ycPRn0tVN93np2EU4b8C/KadqA3l6igdjgLBTnUotab9ER0HLG8A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-worker@30.5.1: + resolution: {integrity: sha512-Cbxh5v7AoLuFRmFJSM4/aHdQ68rjXvUWr716EE0Dh3I7T+T/3FgFKhOERGXHcU2Meftq9+9zxPM3TSNyI9D+HA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest@30.5.1: + resolution: {integrity: sha512-3qrR8+ZXFnn7y0H2yjWQNkGGBLBY4zTRoTMAq9zJcgwLLtlyonfsCLviIXK9xuE2KgIyM+M36AFcoK2DgFR36w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.15.2: + resolution: {integrity: sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + knip@6.18.0: resolution: {integrity: sha512-RlT6fK3epETsEUCVdlz96CiJN3DQJwuxOuQhEeAVWjNRuAUJHWwe+XolTL8vIiwLPZ38tTdPWJUgZyTdskOEog==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -1455,18 +2515,87 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.15: resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + node-releases@2.0.54: + resolution: {integrity: sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==} + engines: {node: '>=18'} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + nth-check@3.0.1: resolution: {integrity: sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==} engines: {node: '>=20.19.0'} @@ -1475,6 +2604,10 @@ packages: resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} engines: {node: '>=12.20.0'} + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + oxc-parser@0.137.0: resolution: {integrity: sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1500,19 +2633,70 @@ packages: vite-plus: optional: true + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.5: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -1525,14 +2709,39 @@ packages: preact-render-to-string: optional: true + pretty-format@30.5.1: + resolution: {integrity: sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + publint@0.3.21: resolution: {integrity: sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==} engines: {node: '>=18'} hasBin: true + pure-rand@7.0.1: + resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + resolve-cwd@3.0.0: + resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} + engines: {node: '>=8'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1569,17 +2778,40 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + smol-toml@1.7.0: resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} engines: {node: '>= 18'} @@ -1588,16 +2820,71 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-length@4.0.2: + resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} + engines: {node: '>=10'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@4.0.0: + resolution: {integrity: sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==} + engines: {node: '>=8'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + synckit@0.11.13: + resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} + engines: {node: ^14.18.0 || >=16.0.0} + + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -1663,6 +2950,19 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + ultramatter@0.0.4: resolution: {integrity: sha512-1f/hO3mR+/Hgue4eInOF/Qm/wzDqwhYha4DxM0hre9YIUyso3fE2XtrAU6B4njLqTC8CM49EZaYgsVSa+dXHGw==} @@ -1676,6 +2976,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unrs-resolver@1.12.2: + resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + unrun@0.2.39: resolution: {integrity: sha512-h9FxYVpztY/wwq+bauLOh6Y3CWu2IVeRLq5lxzneBiIU9Tn86OGp9xiQrGhnYspAmg5dzdY0Cc8+Y70kuTARCg==} engines: {node: '>=20.19.0'} @@ -1686,6 +2989,16 @@ packages: synckit: optional: true + update-browserslist-db@1.3.2: + resolution: {integrity: sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + v8-to-istanbul@9.3.0: + resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} + engines: {node: '>=10.12.0'} + vite@8.1.0: resolution: {integrity: sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1775,37 +3088,262 @@ packages: jsdom: optional: true - walk-up-path@4.0.0: - resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} - engines: {node: 20 || >=22} + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + yuku-ast@0.1.7: + resolution: {integrity: sha512-2RiMEWv500TixY5rJy6OZd4fSy9WYZKWh6gGbIJ7y7vAGcuCugWOWwOLGaQcRZrXcPUfqtLtvpaJ3SdXtWlhKA==} + + yuku-codegen@0.6.3: + resolution: {integrity: sha512-3c9H521tf1RRDu4cNUySfH01sKlALve4HKu2sITk33gLl5HhsvI6ngSuarpWxMPAiJEgqJc/HTvojWQRnYm9/g==} + + yuku-parser@0.6.3: + resolution: {integrity: sha512-iI6uABvvup9mvv8Mcpz7Tp//gehQlvcSnX4A4/0bf9i6X3RVQDuVUZel8jdpljwlF7WrbKsvD19y55Mc6+sKZw==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.8': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.9 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.8 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - why-is-node-running@2.3.0: - resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} - engines: {node: '>=8'} - hasBin: true + '@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - yaml@2.9.0: - resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} - engines: {node: '>= 14.6'} - hasBin: true + '@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - yuku-ast@0.1.7: - resolution: {integrity: sha512-2RiMEWv500TixY5rJy6OZd4fSy9WYZKWh6gGbIJ7y7vAGcuCugWOWwOLGaQcRZrXcPUfqtLtvpaJ3SdXtWlhKA==} + '@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - yuku-codegen@0.6.3: - resolution: {integrity: sha512-3c9H521tf1RRDu4cNUySfH01sKlALve4HKu2sITk33gLl5HhsvI6ngSuarpWxMPAiJEgqJc/HTvojWQRnYm9/g==} + '@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - yuku-parser@0.6.3: - resolution: {integrity: sha512-iI6uABvvup9mvv8Mcpz7Tp//gehQlvcSnX4A4/0bf9i6X3RVQDuVUZel8jdpljwlF7WrbKsvD19y55Mc6+sKZw==} + '@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 -snapshots: + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@babel/traverse@7.29.8': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/template': 7.29.7 + '@babel/types': 7.29.8 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@0.2.3': {} '@bomb.sh/args@0.3.1': {} - '@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))': + '@bomb.sh/tools@0.5.6(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.13))(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 @@ -1816,7 +3354,7 @@ 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)(tsx@4.23.13)(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)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.13)) ultramatter: 0.0.4 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))) @@ -1848,7 +3386,7 @@ snapshots: - vite-plus - vue-tsc - '@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))': + '@bomb.sh/tools@0.6.1(@types/node@22.20.1)(oxc-resolver@11.21.3)(tsx@4.23.13)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.13))(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 @@ -1859,7 +3397,7 @@ 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)(tsx@4.23.13)(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)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.13)) ultramatter: 0.0.4 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))) @@ -2045,8 +3583,223 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@istanbuljs/load-nyc-config@1.1.0': + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.15.2 + resolve-from: 5.0.0 + + '@istanbuljs/schema@0.1.6': {} + + '@jest/console@30.5.1': + dependencies: + '@jest/types': 30.5.1 + '@types/node': 22.20.1 + chalk: 4.1.2 + jest-message-util: 30.5.1 + jest-util: 30.5.1 + slash: 3.0.0 + + '@jest/core@30.5.1': + dependencies: + '@jest/console': 30.5.1 + '@jest/pattern': 30.5.0 + '@jest/reporters': 30.5.1 + '@jest/test-result': 30.5.1 + '@jest/transform': 30.5.1 + '@jest/types': 30.5.1 + '@types/node': 22.20.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.4.0 + exit-x: 0.2.2 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-changed-files: 30.5.1 + jest-config: 30.5.1(@types/node@22.20.1) + jest-haste-map: 30.5.1 + jest-message-util: 30.5.1 + jest-regex-util: 30.5.0 + jest-resolve: 30.5.1 + jest-resolve-dependencies: 30.5.1 + jest-runner: 30.5.1 + jest-runtime: 30.5.1 + jest-snapshot: 30.5.1 + jest-util: 30.5.1 + jest-validate: 30.5.1 + jest-watcher: 30.5.1 + pretty-format: 30.5.1 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + '@jest/diff-sequences@30.5.0': {} + + '@jest/environment@30.5.1': + dependencies: + '@jest/fake-timers': 30.5.1 + '@jest/types': 30.5.1 + '@types/node': 22.20.1 + jest-mock: 30.5.1 + + '@jest/expect-utils@30.5.1': + dependencies: + '@jest/get-type': 30.5.0 + + '@jest/expect@30.5.1': + dependencies: + expect: 30.5.1 + jest-snapshot: 30.5.1 + transitivePeerDependencies: + - supports-color + + '@jest/fake-timers@30.5.1': + dependencies: + '@jest/types': 30.5.1 + '@sinonjs/fake-timers': 15.4.0 + '@types/node': 22.20.1 + jest-message-util: 30.5.1 + jest-mock: 30.5.1 + jest-util: 30.5.1 + + '@jest/get-type@30.5.0': {} + + '@jest/globals@30.5.1': + dependencies: + '@jest/environment': 30.5.1 + '@jest/expect': 30.5.1 + '@jest/types': 30.5.1 + jest-mock: 30.5.1 + transitivePeerDependencies: + - supports-color + + '@jest/pattern@30.5.0': + dependencies: + '@types/node': 22.20.1 + jest-regex-util: 30.5.0 + + '@jest/reporters@30.5.1': + dependencies: + '@bcoe/v8-coverage': 0.2.3 + '@jest/console': 30.5.1 + '@jest/test-result': 30.5.1 + '@jest/transform': 30.5.1 + '@jest/types': 30.5.1 + '@jridgewell/trace-mapping': 0.3.31 + '@types/node': 22.20.1 + chalk: 4.1.2 + collect-v8-coverage: 1.0.3 + exit-x: 0.2.2 + glob: 13.0.6 + graceful-fs: 4.2.11 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-instrument: 6.0.3 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + jest-message-util: 30.5.1 + jest-util: 30.5.1 + jest-worker: 30.5.1 + slash: 3.0.0 + string-length: 4.0.2 + v8-to-istanbul: 9.3.0 + transitivePeerDependencies: + - supports-color + + '@jest/schemas@30.5.0': + dependencies: + '@sinclair/typebox': 0.34.52 + + '@jest/snapshot-utils@30.5.1': + dependencies: + '@jest/types': 30.5.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + natural-compare: 1.4.0 + + '@jest/source-map@30.5.0': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + callsites: 3.1.0 + convert-source-map: 2.0.0 + graceful-fs: 4.2.11 + + '@jest/test-result@30.5.1': + dependencies: + '@jest/console': 30.5.1 + '@jest/types': 30.5.1 + '@types/istanbul-lib-coverage': 2.0.6 + collect-v8-coverage: 1.0.3 + + '@jest/test-sequencer@30.5.1': + dependencies: + '@jest/test-result': 30.5.1 + graceful-fs: 4.2.11 + jest-haste-map: 30.5.1 + slash: 3.0.0 + + '@jest/transform@30.5.1': + dependencies: + '@babel/core': 7.29.7 + '@jest/types': 30.5.1 + '@jridgewell/trace-mapping': 0.3.31 + babel-plugin-istanbul: 8.0.0 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 30.5.1 + jest-regex-util: 30.5.0 + jest-util: 30.5.1 + pirates: 4.0.7 + slash: 3.0.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + + '@jest/types@30.5.1': + dependencies: + '@jest/pattern': 30.5.0 + '@jest/schemas': 30.5.0 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 22.20.1 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.5.5': {} + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -2314,6 +4067,67 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.74.0': optional: true + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.5 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@pkgr/core@0.3.6': {} + '@publint/pack@0.1.5': dependencies: tinyexec: 1.2.4 @@ -2425,6 +4239,16 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sinclair/typebox@0.34.52': {} + + '@sinonjs/commons@3.0.1': + dependencies: + type-detect: 4.0.8 + + '@sinonjs/fake-timers@15.4.0': + dependencies: + '@sinonjs/commons': 3.0.1 + '@standard-schema/spec@1.1.0': {} '@tybys/wasm-util@0.10.3': @@ -2432,6 +4256,31 @@ snapshots: tslib: 2.8.1 optional: true + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/bun@1.4.1': + dependencies: + bun-types: 1.4.1 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2441,10 +4290,28 @@ snapshots: '@types/estree@1.0.9': {} + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 + '@types/stack-utils@2.0.3': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260623.1': optional: true @@ -2476,6 +4343,78 @@ snapshots: '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260623.1 '@typescript/native-preview-win32-x64': 7.0.0-dev.20260623.1 + '@ungap/structured-clone@1.4.0': {} + + '@unrs/resolver-binding-android-arm-eabi@1.12.2': + optional: true + + '@unrs/resolver-binding-android-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-loong64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.12.2': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.12.2': + optional: true + + '@unrs/resolver-binding-openharmony-arm64@1.12.2': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.12.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.12.2': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.12.2': + optional: true + '@vitest/expect@4.1.9': dependencies: '@standard-schema/spec': 1.1.0 @@ -2587,18 +4526,166 @@ snapshots: alien-signals@2.0.8: {} + ansi-escapes@4.3.2: + dependencies: + type-fest: 0.21.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.3.0: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@5.2.0: {} + + ansi-styles@6.2.3: {} + ansis@4.3.1: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + assertion-error@2.0.1: {} + babel-jest@30.5.1(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@jest/transform': 30.5.1 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 8.0.0 + babel-preset-jest: 30.5.0(@babel/core@7.29.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + + babel-plugin-istanbul@8.0.0: + dependencies: + '@babel/helper-plugin-utils': 7.29.7 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-instrument: 6.0.3 + test-exclude: 7.0.2 + transitivePeerDependencies: + - supports-color + + babel-plugin-jest-hoist@30.5.0: + dependencies: + '@types/babel__core': 7.20.5 + + babel-preset-current-node-syntax@1.2.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.29.7) + '@babel/plugin-syntax-bigint': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.29.7) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.29.7) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.29.7) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) + + babel-preset-jest@30.5.0(@babel/core@7.29.7): + dependencies: + '@babel/core': 7.29.7 + babel-plugin-jest-hoist: 30.5.0 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.21: {} + boolbase@2.0.0: {} + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.2 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.9: + dependencies: + baseline-browser-mapping: 2.11.21 + caniuse-lite: 1.0.30001810 + electron-to-chromium: 1.5.422 + node-releases: 2.0.54 + update-browserslist-db: 1.3.2(browserslist@4.28.9) + + bser@2.1.1: + dependencies: + node-int64: 0.4.0 + + bun-types@1.4.1: + dependencies: + '@types/node': 22.20.1 + cac@7.0.0: {} + callsites@3.1.0: {} + + camelcase@5.3.1: {} + + camelcase@6.3.0: {} + + caniuse-lite@1.0.30001810: {} + chai@6.2.2: {} + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + char-regex@1.0.2: {} + + ci-info@4.4.0: {} + + cjs-module-lexer@2.2.1: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + co@4.6.0: {} + + collect-v8-coverage@1.0.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + convert-source-map@2.0.0: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + css-select@7.0.0: dependencies: boolbase: 2.0.0 @@ -2609,10 +4696,20 @@ snapshots: css-what@8.0.0: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.2: {} + + deepmerge@4.3.1: {} + defu@6.1.7: {} detect-libc@2.1.2: {} + detect-newline@3.1.0: {} + dom-serializer@3.1.1: dependencies: domelementtype: 3.0.0 @@ -2635,12 +4732,26 @@ snapshots: optionalDependencies: oxc-resolver: 11.21.3 + eastasianwidth@0.2.0: {} + effection@4.0.3: {} + electron-to-chromium@1.5.422: {} + + emittery@0.13.1: {} + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + empathic@2.0.1: {} entities@8.0.0: {} + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + es-module-lexer@2.1.0: {} esbuild@0.28.2: @@ -2672,12 +4783,43 @@ snapshots: '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} + + escape-string-regexp@2.0.0: {} + + esprima@4.0.1: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + exit-x@0.2.2: {} + expect-type@1.3.0: {} + expect@30.5.1: + dependencies: + '@jest/expect-utils': 30.5.1 + '@jest/get-type': 30.5.0 + jest-matcher-utils: 30.5.1 + jest-message-util: 30.5.1 + jest-mock: 30.5.1 + jest-util: 30.5.1 + + fast-json-stable-stringify@2.1.0: {} + fast-string-truncated-width@3.0.3: {} fast-string-width@3.0.2: @@ -2688,6 +4830,10 @@ snapshots: dependencies: fast-string-width: 3.0.2 + fb-watchman@2.0.2: + dependencies: + bser: 2.1.1 + fd-package-json@2.0.0: dependencies: walk-up-path: 4.0.0 @@ -2696,6 +4842,16 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + formatly@0.3.0: dependencies: fd-package-json: 2.0.0 @@ -2703,6 +4859,14 @@ snapshots: fsevents@2.3.3: optional: true + gensync@1.0.0-beta.2: {} + + get-caller-file@2.0.5: {} + + get-package-type@0.1.0: {} + + get-stream@6.0.1: {} + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -2711,12 +4875,415 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + hookable@6.1.1: {} + html-escaper@2.0.2: {} + + human-signals@2.1.0: {} + + import-local@3.2.0: + dependencies: + pkg-dir: 4.2.0 + resolve-cwd: 3.0.0 + import-without-cache@0.4.0: {} + imurmurhash@0.1.4: {} + + is-arrayish@0.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-generator-fn@2.1.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-stream@2.0.1: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-instrument@6.0.3: + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.8 + '@istanbuljs/schema': 0.1.6 + istanbul-lib-coverage: 3.2.2 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3 + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + jest-changed-files@30.5.1: + dependencies: + execa: 5.1.1 + jest-util: 30.5.1 + p-limit: 3.1.0 + + jest-circus@30.5.1: + dependencies: + '@jest/environment': 30.5.1 + '@jest/expect': 30.5.1 + '@jest/test-result': 30.5.1 + '@jest/types': 30.5.1 + '@types/node': 22.20.1 + chalk: 4.1.2 + co: 4.6.0 + dedent: 1.7.2 + is-generator-fn: 2.1.0 + jest-each: 30.5.1 + jest-matcher-utils: 30.5.1 + jest-message-util: 30.5.1 + jest-runtime: 30.5.1 + jest-snapshot: 30.5.1 + jest-util: 30.5.1 + p-limit: 3.1.0 + pretty-format: 30.5.1 + pure-rand: 7.0.1 + slash: 3.0.0 + stack-utils: 2.0.6 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-cli@30.5.1(@types/node@22.20.1): + dependencies: + '@jest/core': 30.5.1 + '@jest/test-result': 30.5.1 + '@jest/types': 30.5.1 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.5.1(@types/node@22.20.1) + jest-util: 30.5.1 + jest-validate: 30.5.1 + yargs: 17.7.3 + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + jest-config@30.5.1(@types/node@22.20.1): + dependencies: + '@babel/core': 7.29.7 + '@jest/get-type': 30.5.0 + '@jest/pattern': 30.5.0 + '@jest/test-sequencer': 30.5.1 + '@jest/types': 30.5.1 + babel-jest: 30.5.1(@babel/core@7.29.7) + chalk: 4.1.2 + ci-info: 4.4.0 + deepmerge: 4.3.1 + glob: 13.0.6 + graceful-fs: 4.2.11 + jest-circus: 30.5.1 + jest-docblock: 30.5.0 + jest-environment-node: 30.5.1 + jest-regex-util: 30.5.0 + jest-resolve: 30.5.1 + jest-runner: 30.5.1 + jest-util: 30.5.1 + jest-validate: 30.5.1 + parse-json: 5.2.0 + pretty-format: 30.5.1 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + '@types/node': 22.20.1 + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-diff@30.5.1: + dependencies: + '@jest/diff-sequences': 30.5.0 + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + pretty-format: 30.5.1 + + jest-docblock@30.5.0: + dependencies: + detect-newline: 3.1.0 + + jest-each@30.5.1: + dependencies: + '@jest/get-type': 30.5.0 + '@jest/types': 30.5.1 + chalk: 4.1.2 + jest-util: 30.5.1 + pretty-format: 30.5.1 + + jest-environment-node@30.5.1: + dependencies: + '@jest/environment': 30.5.1 + '@jest/fake-timers': 30.5.1 + '@jest/types': 30.5.1 + '@types/node': 22.20.1 + jest-mock: 30.5.1 + jest-util: 30.5.1 + jest-validate: 30.5.1 + + jest-haste-map@30.5.1: + dependencies: + '@jest/types': 30.5.1 + '@parcel/watcher': 2.6.0 + '@types/node': 22.20.1 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + fdir: 6.5.0(picomatch@4.0.5) + graceful-fs: 4.2.11 + jest-regex-util: 30.5.0 + jest-util: 30.5.1 + jest-worker: 30.5.1 + picomatch: 4.0.5 + + jest-leak-detector@30.5.1: + dependencies: + '@jest/get-type': 30.5.0 + pretty-format: 30.5.1 + + jest-matcher-utils@30.5.1: + dependencies: + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + jest-diff: 30.5.1 + pretty-format: 30.5.1 + + jest-message-util@30.5.1: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 30.5.1 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.5.1 + picomatch: 4.0.5 + pretty-format: 30.5.1 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.5.1: + dependencies: + '@jest/expect-utils': 30.5.1 + '@jest/types': 30.5.1 + '@types/node': 22.20.1 + jest-util: 30.5.1 + + jest-regex-util@30.5.0: {} + + jest-resolve-dependencies@30.5.1: + dependencies: + jest-regex-util: 30.5.0 + jest-snapshot: 30.5.1 + transitivePeerDependencies: + - supports-color + + jest-resolve@30.5.1: + dependencies: + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-haste-map: 30.5.1 + jest-util: 30.5.1 + jest-validate: 30.5.1 + slash: 3.0.0 + unrs-resolver: 1.12.2 + + jest-runner@30.5.1: + dependencies: + '@jest/console': 30.5.1 + '@jest/environment': 30.5.1 + '@jest/source-map': 30.5.0 + '@jest/test-result': 30.5.1 + '@jest/transform': 30.5.1 + '@jest/types': 30.5.1 + '@types/node': 22.20.1 + chalk: 4.1.2 + emittery: 0.13.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-docblock: 30.5.0 + jest-environment-node: 30.5.1 + jest-haste-map: 30.5.1 + jest-leak-detector: 30.5.1 + jest-message-util: 30.5.1 + jest-resolve: 30.5.1 + jest-runtime: 30.5.1 + jest-util: 30.5.1 + jest-watcher: 30.5.1 + jest-worker: 30.5.1 + p-limit: 3.1.0 + transitivePeerDependencies: + - supports-color + + jest-runtime@30.5.1: + dependencies: + '@jest/environment': 30.5.1 + '@jest/fake-timers': 30.5.1 + '@jest/globals': 30.5.1 + '@jest/source-map': 30.5.0 + '@jest/test-result': 30.5.1 + '@jest/transform': 30.5.1 + '@jest/types': 30.5.1 + '@types/node': 22.20.1 + chalk: 4.1.2 + cjs-module-lexer: 2.2.1 + collect-v8-coverage: 1.0.3 + es-module-lexer: 2.1.0 + glob: 13.0.6 + graceful-fs: 4.2.11 + jest-haste-map: 30.5.1 + jest-message-util: 30.5.1 + jest-mock: 30.5.1 + jest-regex-util: 30.5.0 + jest-resolve: 30.5.1 + jest-snapshot: 30.5.1 + jest-util: 30.5.1 + slash: 3.0.0 + strip-bom: 4.0.0 + transitivePeerDependencies: + - supports-color + + jest-snapshot@30.5.1: + dependencies: + '@babel/core': 7.29.7 + '@babel/generator': 7.29.8 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/types': 7.29.8 + '@jest/expect-utils': 30.5.1 + '@jest/get-type': 30.5.0 + '@jest/snapshot-utils': 30.5.1 + '@jest/transform': 30.5.1 + '@jest/types': 30.5.1 + babel-preset-current-node-syntax: 1.2.0(@babel/core@7.29.7) + chalk: 4.1.2 + expect: 30.5.1 + graceful-fs: 4.2.11 + jest-diff: 30.5.1 + jest-matcher-utils: 30.5.1 + jest-message-util: 30.5.1 + jest-util: 30.5.1 + pretty-format: 30.5.1 + semver: 7.8.5 + synckit: 0.11.13 + transitivePeerDependencies: + - supports-color + + jest-util@30.5.1: + dependencies: + '@jest/types': 30.5.1 + '@types/node': 22.20.1 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.5 + + jest-validate@30.5.1: + dependencies: + '@jest/get-type': 30.5.0 + '@jest/types': 30.5.1 + camelcase: 6.3.0 + chalk: 4.1.2 + leven: 3.1.0 + pretty-format: 30.5.1 + + jest-watcher@30.5.1: + dependencies: + '@jest/test-result': 30.5.1 + '@jest/types': 30.5.1 + '@types/node': 22.20.1 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + emittery: 0.13.1 + jest-util: 30.5.1 + string-length: 4.0.2 + + jest-worker@30.5.1: + dependencies: + '@types/node': 22.20.1 + '@ungap/structured-clone': 1.4.0 + jest-util: 30.5.1 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + jest@30.5.1(@types/node@22.20.1): + dependencies: + '@jest/core': 30.5.1 + '@jest/types': 30.5.1 + import-local: 3.2.0 + jest-cli: 30.5.1(@types/node@22.20.1) + transitivePeerDependencies: + - '@types/node' + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jiti@2.7.0: {} + js-tokens@4.0.0: {} + + js-yaml@3.15.2: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json5@2.2.3: {} + knip@6.18.0: dependencies: fdir: 6.5.0(picomatch@4.0.5) @@ -2733,6 +5300,8 @@ snapshots: yaml: 2.9.0 zod: 4.4.3 + leven@3.1.0: {} + lightningcss-android-arm64@1.32.0: optional: true @@ -2782,20 +5351,74 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lines-and-columns@1.2.4: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + make-dir@4.0.0: + dependencies: + semver: 7.8.5 + + merge-stream@2.0.0: {} + + mimic-fn@2.1.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + minipass@7.1.3: {} + mri@1.2.0: {} + ms@2.1.3: {} + nanoid@3.3.15: {} + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + node-addon-api@7.1.1: {} + + node-int64@0.4.0: {} + + node-releases@2.0.54: {} + + normalize-path@3.0.0: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + nth-check@3.0.1: dependencies: boolbase: 2.0.0 obug@2.1.3: {} + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + oxc-parser@0.137.0: dependencies: '@oxc-project/types': 0.137.0 @@ -2889,14 +5512,59 @@ snapshots: '@oxlint/binding-win32-ia32-msvc': 1.74.0 '@oxlint/binding-win32-x64-msvc': 1.74.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + package-json-from-dist@1.0.1: {} + package-manager-detector@1.6.0: {} + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + pathe@2.0.3: {} picocolors@1.1.1: {} + picomatch@2.3.2: {} + picomatch@4.0.5: {} + pirates@4.0.7: {} + + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + postcss@8.5.15: dependencies: nanoid: 3.3.15 @@ -2905,6 +5573,13 @@ snapshots: preact@11.0.0-beta.2: {} + pretty-format@30.5.1: + dependencies: + '@jest/react-is-18': react-is@18.3.1 + '@jest/react-is-19': react-is@19.2.8 + '@jest/schemas': 30.5.0 + ansi-styles: 5.2.0 + publint@0.3.21: dependencies: '@publint/pack': 0.1.5 @@ -2912,11 +5587,25 @@ snapshots: picocolors: 1.1.1 sade: 1.8.1 + pure-rand@7.0.1: {} + quansync@1.0.0: {} + react-is@18.3.1: {} + + react-is@19.2.8: {} + + require-directory@2.1.1: {} + + resolve-cwd@3.0.0: + dependencies: + resolve-from: 5.0.0 + + resolve-from@5.0.0: {} + resolve-pkg-maps@1.0.0: {} - rolldown-plugin-dts@0.27.9(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(rolldown@1.1.5): + rolldown-plugin-dts@0.27.9(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(rolldown@1.1.5)(typescript@5.9.3): dependencies: dts-resolver: 3.0.0(oxc-resolver@11.21.3) get-tsconfig: 5.0.0-beta.5 @@ -2927,6 +5616,7 @@ snapshots: yuku-parser: 0.6.3 optionalDependencies: '@typescript/native-preview': 7.0.0-dev.20260623.1 + typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -2977,22 +5667,91 @@ snapshots: dependencies: mri: 1.2.0 + semver@6.3.1: {} + semver@7.8.5: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + sisteransi@1.0.5: {} + slash@3.0.0: {} + smol-toml@1.7.0: {} source-map-js@1.2.1: {} + sprintf-js@1.0.3: {} + + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} std-env@4.1.0: {} + string-length@4.0.2: + dependencies: + char-regex: 1.0.2 + strip-ansi: 6.0.1 + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.3.0 + + strip-bom@4.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-json-comments@3.1.1: {} + strip-json-comments@5.0.3: {} + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + synckit@0.11.13: + dependencies: + '@pkgr/core': 0.3.6 + + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.6 + tinybench@2.9.0: {} tinyexec@1.2.4: {} @@ -3008,7 +5767,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)(tsx@4.23.13)(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)(typescript@5.9.3)(unrun@0.2.39(synckit@0.11.13)): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -3019,7 +5778,7 @@ snapshots: obug: 2.1.3 picomatch: 4.0.5 rolldown: 1.1.5 - rolldown-plugin-dts: 0.27.9(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(rolldown@1.1.5) + rolldown-plugin-dts: 0.27.9(@typescript/native-preview@7.0.0-dev.20260623.1)(oxc-resolver@11.21.3)(rolldown@1.1.5)(typescript@5.9.3) semver: 7.8.5 tinyexec: 1.2.4 tinyglobby: 0.2.17 @@ -3028,7 +5787,8 @@ snapshots: optionalDependencies: publint: 0.3.21 tsx: 4.23.13 - unrun: 0.2.39 + typescript: 5.9.3 + unrun: 0.2.39(synckit@0.11.13) transitivePeerDependencies: - '@ts-macro/tsc' - '@typescript/native-preview' @@ -3044,6 +5804,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-detect@4.0.8: {} + + type-fest@0.21.3: {} + + typescript@5.9.3: {} + ultramatter@0.0.4: {} unbash@4.0.1: {} @@ -3055,11 +5821,52 @@ snapshots: undici-types@6.21.0: {} - unrun@0.2.39: + unrs-resolver@1.12.2: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.12.2 + '@unrs/resolver-binding-android-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-arm64': 1.12.2 + '@unrs/resolver-binding-darwin-x64': 1.12.2 + '@unrs/resolver-binding-freebsd-x64': 1.12.2 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.12.2 + '@unrs/resolver-binding-linux-arm64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-arm64-musl': 1.12.2 + '@unrs/resolver-binding-linux-loong64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-loong64-musl': 1.12.2 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-riscv64-musl': 1.12.2 + '@unrs/resolver-binding-linux-s390x-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-gnu': 1.12.2 + '@unrs/resolver-binding-linux-x64-musl': 1.12.2 + '@unrs/resolver-binding-openharmony-arm64': 1.12.2 + '@unrs/resolver-binding-wasm32-wasi': 1.12.2 + '@unrs/resolver-binding-win32-arm64-msvc': 1.12.2 + '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 + '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + + unrun@0.2.39(synckit@0.11.13): dependencies: rolldown: 1.0.0-rc.17 + optionalDependencies: + synckit: 0.11.13 optional: true + update-browserslist-db@1.3.2(browserslist@4.28.9): + dependencies: + browserslist: 4.28.9 + escalade: 3.2.0 + picocolors: 1.1.1 + + v8-to-istanbul@9.3.0: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@types/istanbul-lib-coverage': 2.0.6 + convert-source-map: 2.0.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 @@ -3108,13 +5915,52 @@ snapshots: walk-up-path@4.0.0: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + y18n@5.0.8: {} + + yallist@3.1.1: {} + yaml@2.9.0: {} + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@0.1.0: {} + yuku-ast@0.1.7: dependencies: '@yuku-toolchain/types': 0.5.43 diff --git a/tsconfig.json b/tsconfig.json index 910c1d6..7ae72cb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,5 +3,15 @@ "compilerOptions": { "types": ["node"], "lib": ["ESNext"] - } + }, + "include": [ + "*.ts", + "scripts/**/*.ts", + "examples/**/*.ts", + "vendor/ui/**/*.ts", + "vendor/clack-ui/**/*.ts", + "packages/clack-tty/**/*.ts", + "packages/hello-world/**/*.ts", + "packages/pizza/**/*.ts" + ] } diff --git a/vendor/clack-ui-preact/package.json b/vendor/clack-ui-preact/package.json index cc94f8f..ec6b9cc 100644 --- a/vendor/clack-ui-preact/package.json +++ b/vendor/clack-ui-preact/package.json @@ -1,8 +1,8 @@ { "name": "@clack/ui-preact", "version": "0.0.0", - "description": "Vendored Preact adapter for @clack/ui", "private": true, + "description": "Vendored Preact adapter for @clack/ui", "license": "MIT", "type": "module", "exports": { diff --git a/vendor/clack-ui-preact/src/facade.ts b/vendor/clack-ui-preact/src/facade.ts index a6da7a7..5261212 100644 --- a/vendor/clack-ui-preact/src/facade.ts +++ b/vendor/clack-ui-preact/src/facade.ts @@ -1,5 +1,6 @@ // oxlint-disable bombshell-dev/exported-function-async -- This is an internal synchronous adapter factory. import type { Host } from '@clack/ui'; +import type { ContainerNode } from 'preact'; import type { HostElement, HostElementChild, HostLiteral } from '@clack/ui/elements'; import type { HostEventListener, HostEventType } from '@clack/ui/events'; @@ -10,7 +11,7 @@ interface HostAttribute { readonly value: unknown; } -export interface ElementHandle { +export interface ElementHandle extends ContainerNode { /** The framework-neutral element represented by this Preact host instance. */ readonly element: HostElement; } @@ -18,7 +19,7 @@ export interface ElementHandle { /** Create the DOM-shaped container Preact uses to mutate a Host. */ export function createContainer(host: Host, element: HostElement): ElementHandle { const document = new HostDocument(host); - return document.wrap(element) as PreactElementNode; + return document.wrap(element); } const XHTML_NAMESPACE = 'http://www.w3.org/1999/xhtml'; @@ -33,13 +34,16 @@ class HostDocument { } createElementNS(_namespace: string | null, name: string): PreactElementNode { - return this.wrap(this.host.createElement(name)) as PreactElementNode; + return this.wrap(this.host.createElement(name)); } createTextNode(content: string): PreactTextNode { - return this.wrap(this.host.createLiteral(String(content))) as PreactTextNode; + return this.wrap(this.host.createLiteral(String(content))); } + wrap(child: HostElement): PreactElementNode; + wrap(child: HostLiteral): PreactTextNode; + wrap(child: HostElementChild): PreactNode; wrap(child: HostElementChild): PreactNode { const existing = this.#instances.get(child); if (existing) return existing; @@ -53,7 +57,14 @@ class HostDocument { } } -abstract class PreactNodeBase { +class InvalidTextMutationError extends TypeError { + constructor() { + super('Text nodes cannot contain children'); + this.name = 'InvalidTextMutationError'; + } +} + +abstract class PreactNodeBase implements ContainerNode { abstract readonly nodeType: number; readonly ownerDocument: HostDocument; readonly [hostChild]: Child; @@ -63,9 +74,25 @@ abstract class PreactNodeBase { this[hostChild] = child; } + get childNodes(): PreactNode[] { + return []; + } + get firstChild(): PreactNode | null { + return null; + } + insertBefore(_child: PreactNode, _anchor: PreactNode | null): PreactNode { + throw new InvalidTextMutationError(); + } + appendChild(child: PreactNode): PreactNode { + return this.insertBefore(child, null); + } + removeChild(_child: PreactNode): PreactNode { + throw new InvalidTextMutationError(); + } + get parentNode(): PreactElementNode | null { const parent = this[hostChild].parent; - return parent ? (this.ownerDocument.wrap(parent) as PreactElementNode) : null; + return parent ? this.ownerDocument.wrap(parent) : null; } get nextSibling(): PreactNode | null { @@ -101,11 +128,11 @@ class PreactElementNode extends PreactNodeBase implements ElementHa return Object.entries(this.element.properties).map(([name, value]) => ({ name, value })); } - get childNodes(): PreactNode[] { + override get childNodes(): PreactNode[] { return this.element.children.map((child) => this.ownerDocument.wrap(child)); } - get firstChild(): PreactNode | null { + override get firstChild(): PreactNode | null { const child = this.element.children[0]; return child ? this.ownerDocument.wrap(child) : null; } @@ -118,16 +145,16 @@ class PreactElementNode extends PreactNodeBase implements ElementHa this.ownerDocument.host.setProperty(this.element, name, undefined); } - insertBefore(child: PreactNode, anchor: PreactNode | null): PreactNode { + override insertBefore(child: PreactNode, anchor: PreactNode | null): PreactNode { this.ownerDocument.host.insertBefore(this.element, child[hostChild], anchor?.[hostChild]); return child; } - appendChild(child: PreactNode): PreactNode { + override appendChild(child: PreactNode): PreactNode { return this.insertBefore(child, null); } - removeChild(child: PreactNode): PreactNode { + override removeChild(child: PreactNode): PreactNode { this.ownerDocument.host.removeChild(this.element, child[hostChild]); return child; } diff --git a/vendor/clack-ui-preact/src/root.ts b/vendor/clack-ui-preact/src/root.ts index 36006ca..782ad8d 100644 --- a/vendor/clack-ui-preact/src/root.ts +++ b/vendor/clack-ui-preact/src/root.ts @@ -16,7 +16,7 @@ export interface Root { /** Create a Preact root which reconciles into an attached Host element. */ export function createRoot(element: HostElement): Root { const host = HostApi.methods.getHost(element.node!); - const container = createContainer(host, element) as unknown as Element; + const container = createContainer(host, element); return { element, diff --git a/vendor/clack-ui/package.json b/vendor/clack-ui/package.json index 540ead7..03bbdb3 100644 --- a/vendor/clack-ui/package.json +++ b/vendor/clack-ui/package.json @@ -67,6 +67,9 @@ "@bomb.sh/tty": "^0.8.0", "@types/node": "^22.20.0" }, + "devDependencies": { + "vitest": "^4.1.9" + }, "devEngines": { "packageManager": { "name": "pnpm", diff --git a/vendor/clack-ui/src/core/api.test.ts b/vendor/clack-ui/src/core/api.test.ts new file mode 100644 index 0000000..6d73124 --- /dev/null +++ b/vendor/clack-ui/src/core/api.test.ts @@ -0,0 +1,42 @@ +import { expect, expectTypeOf, test } from 'vitest'; +import { createApi } from './api.ts'; +import { create, destroy } from './lifecycle.ts'; + +const api = createApi('typed-middleware-test', { + increment(_node, value: number): number { + return value + 1; + }, + label(_node, value: string): string { + return `Label: ${value}`; + }, +}); + +test('middleware preserves member signatures and updates inherited handles', () => { + const parent = create(); + const child = create(parent); + try { + api.around(parent, { increment: ([node, value], next) => next(node, value * 2) }); + api.around(child, { increment: ([node, value], next) => next(node, value + 3) }); + expect(api.methods.increment(child, 1)).toBe(6); + expect(api.invoke('label', [child, 'hello'])).toBe('Label: hello'); + + api.around(parent, { increment: ([node, value], next) => next(node, value) + 10 }); + expect(api.methods.increment(child, 1)).toBe(16); + expect(api.methods.increment(parent, 1)).toBe(13); + expectTypeOf(api.methods.increment).parameter(1).toEqualTypeOf(); + expectTypeOf(api.methods.label).returns.toEqualTypeOf(); + } finally { + destroy(parent); + } +}); + +test('an omitted middleware does not replace an installed member', () => { + const node = create(); + try { + api.around(node, { increment: ([target, value], next) => next(target, value * 2) }); + api.around(node, { increment: undefined }); + expect(api.methods.increment(node, 4)).toBe(9); + } finally { + destroy(node); + } +}); diff --git a/vendor/clack-ui/src/core/api.ts b/vendor/clack-ui/src/core/api.ts index 85edd5e..20d0d3d 100644 --- a/vendor/clack-ui/src/core/api.ts +++ b/vendor/clack-ui/src/core/api.ts @@ -1,4 +1,3 @@ -// oxlint-disable no-explicit-any import { createContext } from './context.ts'; import type { Node } from './node.ts'; @@ -6,7 +5,8 @@ import type { Node } from './node.ts'; * The shape every api core must satisfy: each member is a function whose first * parameter is the {@link Node} it operates on. */ -type Core = Record any>; +type Core = Record unknown>; +type Signature = (...args: Parameters) => ReturnType; /** * A function that surrounds a core member, optionally delegating to the next @@ -24,10 +24,8 @@ export interface Middleware { * The set of middlewares that can surround a core `A`. Each member is wrapped * by a {@link Middleware} over that member's own signature — node included. */ -export type Around = { - [K in keyof A]: A[K] extends (...args: infer TArgs) => infer TReturn - ? Middleware - : never; +export type Around = { + [K in keyof A]: Middleware, ReturnType>; }; export interface Api { @@ -63,11 +61,8 @@ export function createApi(name: string, core: A): Api { for (const key of Object.keys(inner) as (keyof A)[]) { const current = outer[key]; const decoration = inner[key]; - if (!current) { - result[key] = decoration; - } else { - result[key] = combine([current as any, decoration as any]) as Around[keyof A]; - } + if (!decoration) continue; + result[key] = current ? combine([current, decoration]) : decoration; } return result; } @@ -79,13 +74,14 @@ export function createApi(name: string, core: A): Api { if (Object.keys(around).length === 0) { return core; } else { - const handle = {} as A; + const handle = { ...core }; for (const key of fields) { - const middleware = around[key] as Middleware | undefined; - if (!middleware) { - handle[key] = core[key]; - } else { - handle[key] = ((...args: any[]) => middleware(args, core[key] as any)) as A[keyof A]; + const middleware = around[key]; + if (middleware) { + const member = core[key] as Signature; + // Preserve the key/signature association erased by the dynamic traversal. + handle[key] = ((...args: Parameters) => + middleware(args, member)) as A[typeof key]; } } return handle; @@ -107,16 +103,19 @@ export function createApi(name: string, core: A): Api { } const api: Api = { - methods: fields.reduce((methods, key) => { - return Object.assign(methods, { - [key]: (node: Node, ...args: any[]) => api.invoke(key, [node, ...args] as any), - }); - }, {} as A), + methods: fields.reduce( + (methods, key) => { + return Object.assign(methods, { + [key]: (...args: Parameters) => api.invoke(key, args), + }); + }, + { ...core }, + ), invoke(key, args) { - const node = args[0] as Node; + const node = args[0]; const handle = context.get(node)?.handle ?? core; - const member = handle[key] as (...args: any[]) => any; + const member = handle[key] as Signature; return member(...args); }, @@ -149,19 +148,22 @@ export function createApi(name: string, core: A): Api { * - `handle`: the core methods with `total` + `local` already wrapped around * them, so calling a method does no extra work. */ -interface Installed { +interface Installed { local: Partial>; total: Partial>; handle: A; } /** Fold a stack of middlewares into one; the first is outermost. */ -function combine(middlewares: Middleware[]): Middleware { +function combine( + middlewares: Middleware[], +): Middleware { if (middlewares.length === 0) { return (args, next) => next(...args); } else { return middlewares.reduceRight( - (next, middleware) => (args, base) => middleware(args, (...args) => next(args, base)), + (next, middleware) => (args, base) => + middleware(args, (...innerArgs) => next(innerArgs, base)), ); } } diff --git a/vendor/clack-ui/src/core/lifecycle.ts b/vendor/clack-ui/src/core/lifecycle.ts index 83dc3cc..d445839 100644 --- a/vendor/clack-ui/src/core/lifecycle.ts +++ b/vendor/clack-ui/src/core/lifecycle.ts @@ -23,7 +23,7 @@ export const LifecycleApi = createApi('lifecycle', { export const { destroy, id } = LifecycleApi.methods; -export function create(parent: Node = global) { +export function create(parent: Node = global): Node { return LifecycleApi.methods.create(parent); } diff --git a/vendor/clack-ui/src/elements/box.ts b/vendor/clack-ui/src/elements/box.ts index 9e9fa28..5fdab40 100644 --- a/vendor/clack-ui/src/elements/box.ts +++ b/vendor/clack-ui/src/elements/box.ts @@ -12,7 +12,7 @@ declare module '@clack/ui/elements' { } } -export function useBoxElement(host: Host) { +export function useBoxElement(host: Host): void { LayoutApi.around(host.root, { *layout([node], next) { const element = getElement(node); @@ -26,10 +26,7 @@ export function useBoxElement(host: Host) { } /** Container layout: box framing with literal children folded into text runs. */ -export function* containerLayout( - node: Node, - element: HostElement, -): Generator { +export function* containerLayout(node: Node, element: HostElement): Generator { let content = ''; yield open(id(node), element.properties); for (const child of element.children) { diff --git a/vendor/clack-ui/src/elements/form.ts b/vendor/clack-ui/src/elements/form.ts index 0a9cea0..51d3892 100644 --- a/vendor/clack-ui/src/elements/form.ts +++ b/vendor/clack-ui/src/elements/form.ts @@ -45,9 +45,10 @@ export function collectValues(form: HostElement): Record { for (const child of element.children) { if (child.type !== 'element') continue; if (child.name === 'input') { - const key = typeof child.properties.label === 'string' - ? child.properties.label - : String(child.properties.key ?? id(child.node!)); + const key = + typeof child.properties.label === 'string' + ? child.properties.label + : String(child.properties.key ?? id(child.node!)); values[key] = String(child.properties.value ?? ''); } visit(child); diff --git a/vendor/clack-ui/src/elements/input.ts b/vendor/clack-ui/src/elements/input.ts index fe2958b..dd6682c 100644 --- a/vendor/clack-ui/src/elements/input.ts +++ b/vendor/clack-ui/src/elements/input.ts @@ -1,5 +1,14 @@ import { createApi, createContext, id, type Node } from '../core.ts'; -import { open, close, text, fit, percent, rgba, type KeyDown, type KeyRepeat } from '@bomb.sh/tty'; +import { + open, + close, + text as textOperation, + fit, + percent, + rgba, + type KeyDown, + type KeyRepeat, +} from '@bomb.sh/tty'; import type { HostEvent } from '@clack/ui/events'; import { emit } from '../emit.ts'; import { getElement } from '../elements.ts'; @@ -35,8 +44,8 @@ export function useInputElement(host: Host): void { const { root } = host; HostApi.around(root, { - createElement([root, name], next) { - const element = next(root, name); + createElement([parent, name], next) { + const element = next(parent, name); if (element.name === 'input') { const model = { content: '', caret: 0 }; element.attach = (node) => { @@ -83,7 +92,7 @@ export function useInputElement(host: Host): void { padding: { top: 1, right: 1, bottom: 1, left: 1 }, }, }); - yield text(value, { color, ...(focused ? { caret } : {}) }); + yield textOperation(value, { color, ...(focused ? { caret } : {}) }); yield close(); }, }); diff --git a/vendor/clack-ui/src/elements/text.ts b/vendor/clack-ui/src/elements/text.ts index 0aef3e5..8695b9f 100644 --- a/vendor/clack-ui/src/elements/text.ts +++ b/vendor/clack-ui/src/elements/text.ts @@ -17,7 +17,7 @@ declare module '@clack/ui/elements' { * Text elements ignore non-textual children like "box" or "input" and will * always return an iteration of tty `text()` directives */ -export function useTextElement(host: Host) { +export function useTextElement(host: Host): void { LayoutApi.around(host.root, { *layout([node], next) { const element = getElement(node); diff --git a/vendor/clack-ui/src/emit.ts b/vendor/clack-ui/src/emit.ts index ea051cd..f05c97a 100644 --- a/vendor/clack-ui/src/emit.ts +++ b/vendor/clack-ui/src/emit.ts @@ -1,7 +1,6 @@ -// oxlint-disable no-unused-vars import { createApi, type Node } from './core.ts'; import { getElement, type HostElement } from './elements.ts'; -import type { AnyHostEvent, HostEvent, HostEvents } from './events.ts'; +import type { AnyHostEvent, HostEventType, HostEvents } from './events.ts'; export const EmitApi = createApi('emit', { emit(node, event: AnyHostEvent): void { @@ -11,11 +10,13 @@ export const EmitApi = createApi('emit', { }, }); -export function emit>(node: Node, data: E): void { +type EventData = { [T in HostEventType]: Omit }[HostEventType]; + +export function emit(node: Node, data: EventData): void { return EmitApi.methods.emit(node, { ...data, target: getElement(node), - } as AnyHostEvent); + }); } class InvalidEventTargetError extends TypeError { diff --git a/vendor/clack-ui/src/extensions.ts b/vendor/clack-ui/src/extensions.ts index e984bb7..67f4fa3 100644 --- a/vendor/clack-ui/src/extensions.ts +++ b/vendor/clack-ui/src/extensions.ts @@ -1,5 +1,6 @@ import { existsSync, readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; +// oxlint-disable-next-line no-restricted-imports -- Walk filesystem parents from a caller-supplied directory. import { dirname, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import type { ReadStream, WriteStream } from 'node:tty'; @@ -16,6 +17,13 @@ export interface UIExtensionContext { export type UIExtension = (context: UIExtensionContext) => void; +class InvalidUIExtensionError extends TypeError { + constructor(specifier: string) { + super(`@clack/ui extension "${specifier}" must default-export a UIExtension function`); + this.name = 'InvalidUIExtensionError'; + } +} + const REGISTRY = Symbol.for('@clack/ui/extensions'); const globals = globalThis as typeof globalThis & Record; @@ -60,9 +68,7 @@ export async function loadDeclaredExtensions(from: string): Promise('focus'); const FocusableContext = createContext('focusable', false); - interface Range { start: HostElementChild; limit?: HostElement; diff --git a/vendor/clack-ui/src/host.test.ts b/vendor/clack-ui/src/host.test.ts new file mode 100644 index 0000000..5c5edbc --- /dev/null +++ b/vendor/clack-ui/src/host.test.ts @@ -0,0 +1,53 @@ +import { expect, expectTypeOf, test } from 'vitest'; +import { destroy } from './core.ts'; +import { emit } from './emit.ts'; +import { createHost } from './host.ts'; +import type { HostEvent, HostEventListener } from './events.ts'; + +declare module './events.ts' { + interface HostEvents { + __proto__: HostEvent<'__proto__'>; + } +} + +test('custom event names cannot collide with inherited object properties', () => { + const host = createHost(); + const seen: string[] = []; + const listener: HostEventListener<'__proto__'> = (event) => seen.push(event.type); + try { + host.addEventListener(host.element, '__proto__', listener); + emit(host.root, { type: '__proto__' }); + expect(seen).toEqual(['__proto__']); + host.removeEventListener(host.element, '__proto__', listener); + emit(host.root, { type: '__proto__' }); + expect(seen).toEqual(['__proto__']); + } finally { + destroy(host.root); + } +}); + +test('typed listeners receive their event and changes apply on the next dispatch', () => { + const host = createHost(); + const seen: string[] = []; + const later: HostEventListener<'input'> = (event) => { + seen.push(`later: ${event.value}`); + }; + const first: HostEventListener<'input'> = (event) => { + seen.push(`first: ${event.value}`); + host.removeEventListener(host.element, 'input', first); + host.addEventListener(host.element, 'input', later); + }; + try { + host.addEventListener(host.element, 'input', first); + emit(host.root, { type: 'input', value: 'A' }); + expect(seen).toEqual(['first: A']); + emit(host.root, { type: 'input', value: 'B' }); + expect(seen).toEqual(['first: A', 'later: B']); + host.removeEventListener(host.element, 'input', later); + emit(host.root, { type: 'input', value: 'C' }); + expect(seen).toEqual(['first: A', 'later: B']); + expectTypeOf<{ type: 'input' }>().not.toExtend[1]>(); + } finally { + destroy(host.root); + } +}); diff --git a/vendor/clack-ui/src/host.ts b/vendor/clack-ui/src/host.ts index 8b89ac7..8281811 100644 --- a/vendor/clack-ui/src/host.ts +++ b/vendor/clack-ui/src/host.ts @@ -1,5 +1,5 @@ // oxlint-disable max-params -import { text } from '@bomb.sh/tty'; +import { text as textOperation } from '@bomb.sh/tty'; import { type Node, create, createApi, createContext, destroy, LifecycleApi } from './core.ts'; import { getElement, @@ -35,7 +35,7 @@ export interface Host { export function createHost(): Host { const root = create(); - const element: HostElement = { + const rootElement: HostElement = { type: 'element', name: 'root', node: root, @@ -44,8 +44,8 @@ export function createHost(): Host { children: [], }; - setElement(root, element); - useRootLayout(root, element); + setElement(root, rootElement); + useRootLayout(root, rootElement); LifecycleApi.around(root, { destroy([node], next) { @@ -64,21 +64,13 @@ export function createHost(): Host { const target = getElement(node); const map = ListenerContext.expect(node); const types = map.get(target); - if (types) { - const listeners = types.get(event.type); - if (listeners) { - const active = [...listeners]; - for (const listener of active) { - listener(event); - } - } - } + if (types) dispatchListeners(types, event); }, }); const host: Host = { root, - element, + element: rootElement, createElement(type) { return HostApi.methods.createElement(root, type); }, @@ -172,42 +164,59 @@ export const HostApi = createApi('host', { return HostContext.expect(node); }, - addEventListener(node, element, type, listener): void { + addEventListener( + node: Node, + element: HostElement, + type: T, + listener: HostEventListener, + ): void { const map = ListenerContext.expect(node); let types = map.get(element); if (!types) { - map.set(element, (types = new Map())); - } - let listeners = types.get(type); - if (!listeners) { - types.set(type, (listeners = new Set())); + // Event names come from an open interface, not Object.prototype. + types = Object.create(null) as Listeners; + map.set(element, types); } + // TS cannot correlate a generic mapped key with the Set created for that key. + const listeners = (types[type] ??= new Set>() as NonNullable< + Listeners[T] + >); listeners.add(listener); }, - removeEventListener(node, element, type, listener): void { + removeEventListener( + node: Node, + element: HostElement, + type: T, + listener: HostEventListener, + ): void { const map = ListenerContext.expect(node); const types = map.get(element); - if (types) { - const listeners = types.get(type); - if (listeners) { - listeners.delete(listener); - if (listeners.size === 0) { - types.delete(type); - } - } - if (types.size === 0) { - map.delete(element); - } + if (!types) return; + const listeners = types[type]; + if (listeners) { + listeners.delete(listener); + if (listeners.size === 0) delete types[type]; } + if (Reflect.ownKeys(types).length === 0) map.delete(element); }, }); const HostContext = createContext('host'); -const ListenerContext = - createContext>>>>( - 'listeners', - ); +type Listeners = { [T in HostEventType]?: Set> }; +const ListenerContext = createContext>('listeners'); + +function dispatchListeners( + types: Listeners, + event: HostEvents[T] & { type: T }, +): void { + const listeners = types[event.type]; + if (listeners) { + // Listeners may remove themselves or add other listeners during dispatch. + const active = [...listeners]; + for (const listener of active) listener(event); + } +} function isAttached(element: HostElement): boolean { return !!element.node; @@ -243,7 +252,7 @@ function useRootLayout(root: Node, element: HostElement): void { for (const child of element.children) { if (child.type === 'element') { if (content !== '') { - yield text(content); + yield textOperation(content); content = ''; } yield* layout(child.node!); @@ -252,7 +261,7 @@ function useRootLayout(root: Node, element: HostElement): void { } } if (content !== '') { - yield text(content); + yield textOperation(content); } }, }); diff --git a/vendor/clack-ui/src/input-loop.ts b/vendor/clack-ui/src/input-loop.ts index 6d823aa..6459dec 100644 --- a/vendor/clack-ui/src/input-loop.ts +++ b/vendor/clack-ui/src/input-loop.ts @@ -76,7 +76,7 @@ export function createInputLoop(options: InputLoopOptions): AsyncIterable>, + read: Promise>, pending: Pending, ): Promise> { let timeoutId: NodeJS.Timeout | undefined = undefined; @@ -89,16 +89,11 @@ async function race( }) : new Promise>(() => {}); - const data: Promise> = read.then((item) => { - if (item.done) { - return { done: true } as IteratorResult; - } else { - const [data] = item.value; - return { - done: false, - value: { type: 'data', data }, - } as IteratorResult; - } + const data = read.then((item): IteratorResult => { + if (item.done) return { done: true, value: undefined }; + const [chunk] = item.value; + if (!Buffer.isBuffer(chunk)) throw new InvalidInputChunkError(); + return { done: false, value: { type: 'data', data: chunk } }; }); try { @@ -108,13 +103,20 @@ async function race( } } +class InvalidInputChunkError extends TypeError { + constructor() { + super('Terminal input must emit Buffer chunks; do not set a text encoding on stdin'); + this.name = 'InvalidInputChunkError'; + } +} + type Pending = ScanResult['pending']; type ReadEvent = DataEvent | TimeoutEvent; type DataEvent = { type: 'data'; - data: Buffer; + data: Buffer; }; type TimeoutEvent = { @@ -134,14 +136,14 @@ async function abortable(signal: AbortSignal, op: Promise): Promise {}; + let listener: (() => void) | undefined; try { return await Promise.race([ - op.then((value: T) => ({ type: 'resolved', value }) as Abortable), - new Promise((resolve) => { + op.then>((value) => ({ type: 'resolved', value })), + new Promise>((resolve) => { signal.addEventListener('abort', (listener = () => resolve({ type: 'aborted' }))); }), - ] as Promise>[]); + ]); } finally { if (listener) { signal.removeEventListener('abort', listener); diff --git a/vendor/clack-ui/src/ui.ts b/vendor/clack-ui/src/ui.ts index 2053ddb..9ea6ee1 100644 --- a/vendor/clack-ui/src/ui.ts +++ b/vendor/clack-ui/src/ui.ts @@ -43,7 +43,7 @@ export interface UI extends AsyncDisposable { export async function createUI(options: UIOptions): Promise { const { output } = options; const { inline = false } = options; - const surfaceAt = () => ({ + const surfaceAt = (): { width: number; height: number } => ({ width: options.width || output.columns || 80, height: options.height || output.rows || 24, }); @@ -130,7 +130,7 @@ export async function createUI(options: UIOptions): Promise { // the new dimensions and re-render the whole tree. createTerm is async, so // rapid resizes race; only the newest term may win the swap. let resizeToken = 0; - const onResize = () => { + const onResize = (): void => { ({ width, height } = surfaceAt()); const token = ++resizeToken; void createTerm({ width, height }).then((next) => { diff --git a/vendor/ui/src/render/ids.test.ts b/vendor/ui/src/render/ids.test.ts index 7537c9a..f8396a8 100644 --- a/vendor/ui/src/render/ids.test.ts +++ b/vendor/ui/src/render/ids.test.ts @@ -22,7 +22,8 @@ describe('resolveIds — hierarchical key paths', () => { conditionalSidebar ? box({ key: 'sidebar' }) : null, box({ key: 'body' }), ); - const bodyId = (resolved: string[]) => resolved.find((id) => id.endsWith('body')); + const bodyId = (resolved: string[]): string | undefined => + resolved.find((id) => id.endsWith('body')); expect(bodyId(ids(resolveIds(withSidebar)))).toBe('app/body'); expect(bodyId(ids(resolveIds(withoutSidebar)))).toBe('app/body'); });