From 965c7c13fcf9e9fbc6baddf54a921ab69e0df4da Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 2 Sep 2026 13:57:29 -0400 Subject: [PATCH 1/4] add imperative and Preact pizza demos Add equivalent pizza delivery applications for clack/ui and its Preact adapter. Drive both through Ghostwright from separate processes, including responsive layout, modal focus containment, restored tab order, form submission, and native caret behavior. --- packages/pizza-preact/package.json | 32 ++ packages/pizza-preact/src/app.tsx | 112 +++++++ packages/pizza-preact/src/index.tsx | 9 + .../pizza-preact/test/pizza-preact.test.ts | 148 +++++++++ packages/pizza-preact/tsconfig.json | 13 + packages/pizza-preact/vitest.config.ts | 10 + packages/pizza/package.json | 38 +++ packages/pizza/src/pizza.ts | 192 ++++++++++++ packages/pizza/test/pizza.test.ts | 282 ++++++++++++++++++ packages/pizza/vitest.config.ts | 11 + pnpm-lock.yaml | 86 +++++- 11 files changed, 924 insertions(+), 9 deletions(-) create mode 100644 packages/pizza-preact/package.json create mode 100644 packages/pizza-preact/src/app.tsx create mode 100644 packages/pizza-preact/src/index.tsx create mode 100644 packages/pizza-preact/test/pizza-preact.test.ts create mode 100644 packages/pizza-preact/tsconfig.json create mode 100644 packages/pizza-preact/vitest.config.ts create mode 100644 packages/pizza/package.json create mode 100644 packages/pizza/src/pizza.ts create mode 100644 packages/pizza/test/pizza.test.ts create mode 100644 packages/pizza/vitest.config.ts diff --git a/packages/pizza-preact/package.json b/packages/pizza-preact/package.json new file mode 100644 index 0000000..2ede353 --- /dev/null +++ b/packages/pizza-preact/package.json @@ -0,0 +1,32 @@ +{ + "name": "@ghostwright/pizza-preact", + "version": "0.0.0", + "description": "A Preact clack/ui pizza delivery example tested outside-in with Ghostwright", + "private": true, + "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..ee3feb0 --- /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..7179f86 --- /dev/null +++ b/packages/pizza-preact/test/pizza-preact.test.ts @@ -0,0 +1,148 @@ +import { expect, test } from 'vitest'; +import { expectTerminal, withTerminalAsync } from 'ghostwright'; +import { + clackTtyExtension, + expectFocused, + expectTreeCondition, + type ClackTtySession, +} from '@ghostwright/clack-tty'; + +// The Preact application is a process-level black box. This test drives the +// real terminal and observes only its visible screen and semantic tree. +const extension = clackTtyExtension(); + +const entry = () => ({ + 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' }, + trace: 'off' as const, + extensions: [extension], +}); + +type Terminal = Parameters[1]>[0]; + +function semantic(terminal: Terminal) { + return terminal.extension(extension) as ClackTtySession; +} + +async function tabTo(terminal: Terminal, session: ClackTtySession, expectedLabel: string) { + const previousLabel = session.locator('[focused]').matches()[0]?.attrs.label; + for (let attempt = 0; attempt < 3; attempt++) { + await terminal.keyboard.press('Tab'); + try { + await expectTreeCondition( + terminal, + () => session.locator('[focused]').matches()[0]?.attrs.label !== previousLabel, + `focus leaves ${previousLabel}`, + 1200, + ); + } catch { + if (attempt < 2) continue; + throw new Error(`focus did not leave ${previousLabel}`); + } + + const actualLabel = session.locator('[focused]').matches()[0]?.attrs.label; + expect(actualLabel).toBe(expectedLabel); + return; + } +} + +test('Preact pizza completes both forms and restores the delivery tab order', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const session = semantic(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + + const name = session.locator('input[label="name"]'); + const address = session.locator('input[label="address"]'); + const addCard = session.locator('button[label="add-card"]'); + const cardNumber = session.locator('input[label="card-number"]'); + const expiry = session.locator('input[label="expiry"]'); + const cvc = session.locator('input[label="cvc"]'); + const submitCard = session.locator('button[label="submit-card"]'); + const dialog = session.locator('dialog[role="dialog"][label="card"]'); + + await expectFocused(terminal, name); + await terminal.keyboard.type('Ryan'); + await expectTerminal(name.getByText('Ryan')).toBePresent(); + await tabTo(terminal, session, 'address'); + await terminal.keyboard.type('1 Main St'); + await expectTerminal(address.getByText('1 Main St')).toBePresent(); + await tabTo(terminal, session, 'add-card'); + await expectFocused(terminal, addCard); + await terminal.keyboard.press('Enter'); + + await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens'); + await expectFocused(terminal, cardNumber); + await tabTo(terminal, session, 'expiry'); + await expectFocused(terminal, expiry); + await tabTo(terminal, session, 'cvc'); + await expectFocused(terminal, cvc); + await tabTo(terminal, session, 'submit-card'); + await expectFocused(terminal, submitCard); + await terminal.keyboard.press('Enter'); + + await expectTreeCondition(terminal, () => dialog.matches().length === 0, 'dialog closes'); + await expectFocused(terminal, addCard); + await expectTerminal(name.getByText('Ryan')).toBePresent(); + await expectTerminal(address.getByText('1 Main St')).toBePresent(); + await tabTo(terminal, session, 'name'); + await expectFocused(terminal, name); + }); +}); + +test('focused inputs show a native cursor that follows the caret', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const session = semantic(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + + const name = session.locator('input[label="name"]'); + const address = session.locator('input[label="address"]'); + const addCard = session.locator('button[label="add-card"]'); + const cursorIsInside = (selector: string) => { + const cursor = terminal.screen.snapshot().cursor; + const rect = session.locator(selector).matches()[0]?.geo?.term; + return ( + cursor.visible && + rect !== undefined && + cursor.column > rect.column && + cursor.column < rect.column + rect.width - 1 && + cursor.row > rect.row && + cursor.row < rect.row + rect.height - 1 + ); + }; + + await expectFocused(terminal, name); + const initial = await expectTerminal(terminal).toSatisfy( + () => cursorIsInside('input[label="name"]'), + { settleMs: 100 }, + ); + + await terminal.keyboard.type('cat'); + const typed = await expectTerminal(terminal).toSatisfy( + () => terminal.screen.snapshot().cursor.column === initial.cursor.column + 3, + { settleMs: 100 }, + ); + + await terminal.keyboard.press('ArrowLeft'); + await expectTerminal(terminal).toSatisfy( + () => terminal.screen.snapshot().cursor.column === typed.cursor.column - 1, + { settleMs: 100 }, + ); + + await terminal.keyboard.press('Tab'); + await expectFocused(terminal, address); + await expectTerminal(terminal).toSatisfy( + () => cursorIsInside('input[label="address"]'), + { settleMs: 100 }, + ); + + await terminal.keyboard.press('Tab'); + await expectFocused(terminal, addCard); + await expectTerminal(terminal).toSatisfy( + () => !terminal.screen.snapshot().cursor.visible, + { settleMs: 100 }, + ); + }); +}); diff --git a/packages/pizza-preact/tsconfig.json b/packages/pizza-preact/tsconfig.json new file mode 100644 index 0000000..ff65e5c --- /dev/null +++ b/packages/pizza-preact/tsconfig.json @@ -0,0 +1,13 @@ +{ + "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"] +} 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..72edf99 --- /dev/null +++ b/packages/pizza/package.json @@ -0,0 +1,38 @@ +{ + "name": "@ghostwright/pizza", + "version": "0.0.0", + "description": "A clack/ui pizza delivery form with a card dialog, validated with ghostwright tree locators", + "private": true, + "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" + }, + "@clack/ui": { + "extensions": ["@ghostwright/clack-tty/auto"] + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "10.7.0", + "onFail": "error" + }, + "runtime": { + "name": "node", + "version": "22.14.0", + "onFail": "error" + } + } +} diff --git a/packages/pizza/src/pizza.ts b/packages/pizza/src/pizza.ts new file mode 100644 index 0000000..86efecd --- /dev/null +++ b/packages/pizza/src/pizza.ts @@ -0,0 +1,192 @@ +/** + * 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; +} + +function submitNote(content: string): HostElement { + const element = host.createElement('text'); + host.setProperty(element, 'color', gray); + host.insertBefore(element, host.createLiteral(content)); + 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..0309498 --- /dev/null +++ b/packages/pizza/test/pizza.test.ts @@ -0,0 +1,282 @@ +import { expect, test } from 'vitest'; +import { expectTerminal, withTerminalAsync } from 'ghostwright'; +import { + clackTtyExtension, + expectFocused, + expectTreeCondition, + type ClackTtySession, +} from '@ghostwright/clack-tty'; + +// Outside-in acceptance suite: the pizza application is a black box. The tests +// drive it through the real terminal (ghostwright PTY) and observe only the +// visible screen and the semantic tree it emits. No implementation knowledge. +const extension = clackTtyExtension(); + +const entry = () => ({ + 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' }, + trace: 'off' as const, + extensions: [extension], +}); + +type Terminal = Parameters[1]>[0]; + +function semantic(terminal: Terminal) { + return terminal.extension(extension) as ClackTtySession; +} + +async function tabTo(terminal: Terminal, session: ClackTtySession, expectedLabel: string) { + const previousLabel = session.locator('[focused]').matches()[0]?.attrs.label; + for (let attempt = 0; attempt < 3; attempt++) { + await terminal.keyboard.press('Tab'); + try { + await expectTreeCondition( + terminal, + () => session.locator('[focused]').matches()[0]?.attrs.label !== previousLabel, + `focus leaves ${previousLabel}`, + 1200, + ); + } catch { + if (attempt < 2) continue; + throw new Error(`focus did not leave ${previousLabel}`); + } + + const actualLabel = session.locator('[focused]').matches()[0]?.attrs.label; + expect(actualLabel).toBe(expectedLabel); + return; + } +} + +test('renders the delivery form and focuses the first field', async () => { + await withTerminalAsync(entry(), async (terminal) => { + // visible screen + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + + // semantic tree: a delivery form with name and address fields + const form = semantic(terminal).locator('form[label="delivery"]'); + await expectTreeCondition(terminal, () => form.matches().length === 1, 'form in tree'); + expect(semantic(terminal).locator('input[label="name"]').matches()).toHaveLength(1); + expect(semantic(terminal).locator('input[label="address"]').matches()).toHaveLength(1); + + // focus starts on the first field + await expectFocused(terminal, semantic(terminal).locator('input[label="name"]')); + }); +}); + +test('reflows forms when the terminal resizes', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const session = semantic(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + + await terminal.resize({ columns: 36, rows: 20 }); + + const fitsSurface = (selector: string) => { + const frame = session.current(); + const geometry = session.locator(selector).matches()[0]?.geo; + const term = geometry?.term; + const visible = geometry?.visible; + return ( + frame?.surface.columns === 36 && + frame.surface.rows === 20 && + term !== undefined && + visible !== undefined && + term.column >= 0 && + term.row >= 0 && + term.column + term.width <= frame.surface.columns && + term.row + term.height <= frame.surface.rows && + visible.column === term.column && + visible.row === term.row && + visible.width === term.width && + visible.height === term.height + ); + }; + + await expectTreeCondition( + terminal, + () => fitsSurface('form[label="delivery"]'), + 'delivery form fits resized surface', + ); + + await terminal.keyboard.press('Enter'); + await expectTreeCondition( + terminal, + () => fitsSurface('dialog[role="dialog"][label="card"]'), + 'card dialog fits resized surface', + ); + }); +}); + +test('Tab cycles the delivery fields and wraps', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const session = semantic(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + const name = session.locator('input[label="name"]'); + const address = session.locator('input[label="address"]'); + + const addCard = session.locator('button[label="add-card"]'); + await expectFocused(terminal, name); + await terminal.keyboard.press('Tab'); + await expectFocused(terminal, address); + await terminal.keyboard.press('Tab'); + await expectFocused(terminal, addCard); + // the dialog is closed, so the cycle wraps back to the first field + await terminal.keyboard.press('Tab'); + await expectFocused(terminal, name); + }); +}); + +test('typing updates the field value on screen and in the tree', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const session = semantic(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + const name = session.locator('input[label="name"]'); + const address = session.locator('input[label="address"]'); + + await terminal.keyboard.type('Ryan'); + await expectTerminal(name.getByText('Ryan')).toBePresent(); + + await terminal.keyboard.press('Tab'); + await terminal.keyboard.type('1 Main St'); + await expectTerminal(address.getByText('1 Main St')).toBePresent(); + + // the greeting-style header is untouched + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + }); +}); + +test('Enter opens the card dialog and focuses the card number', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const session = semantic(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + + await terminal.keyboard.press('Enter'); + + const dialog = session.locator('dialog[role="dialog"][label="card"]'); + await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens'); + expect(session.locator('input[label="card-number"]').matches()).toHaveLength(1); + expect(session.locator('input[label="expiry"]').matches()).toHaveLength(1); + expect(session.locator('input[label="cvc"]').matches()).toHaveLength(1); + await expectFocused(terminal, session.locator('input[label="card-number"]')); + + // the delivery form stays mounted with its values + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + }); +}); + +test('the card journey: type through the dialog fields', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const session = semantic(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + await terminal.keyboard.press('Enter'); + const dialog = session.locator('dialog[role="dialog"][label="card"]'); + await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens'); + + const cardNumber = session.locator('input[label="card-number"]'); + const expiry = session.locator('input[label="expiry"]'); + const cvc = session.locator('input[label="cvc"]'); + + await terminal.keyboard.type('4111111'); + await expectTerminal(cardNumber.getByText('4111111')).toBePresent(); + + await terminal.keyboard.press('Tab'); + await expectFocused(terminal, expiry); + await terminal.keyboard.type('12/26'); + await expectTerminal(expiry.getByText('12/26')).toBePresent(); + + await terminal.keyboard.press('Tab'); + await expectFocused(terminal, cvc); + await terminal.keyboard.type('123'); + await expectTerminal(cvc.getByText('123')).toBePresent(); + }); +}); + +test('Enter closes the dialog, keeps form values, and restores focus', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const session = semantic(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + const name = session.locator('input[label="name"]'); + const dialog = session.locator('dialog[role="dialog"][label="card"]'); + + // build state: name typed, dialog opened + await terminal.keyboard.type('Ryan'); + await expectTerminal(name.getByText('Ryan')).toBePresent(); + await terminal.keyboard.press('Enter'); + await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens'); + + // close: Enter on a focused card field + await terminal.keyboard.press('Enter'); + await expectTreeCondition(terminal, () => dialog.matches().length === 0, 'dialog closes'); + expect(session.locator('input[label="card-number"]').matches()).toHaveLength(0); + + // form values survive the dialog round trip + await expectTerminal(name.getByText('Ryan')).toBePresent(); + + // focus returns to the control that opened the modal + await expectFocused(terminal, name); + }); +}); + +test('button submission restores the delivery tab order', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const session = semantic(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + + const name = session.locator('input[label="name"]'); + const address = session.locator('input[label="address"]'); + const addCard = session.locator('button[label="add-card"]'); + const cardNumber = session.locator('input[label="card-number"]'); + const expiry = session.locator('input[label="expiry"]'); + const cvc = session.locator('input[label="cvc"]'); + const submitCard = session.locator('button[label="submit-card"]'); + const dialog = session.locator('dialog[role="dialog"][label="card"]'); + + await expectFocused(terminal, name); + await tabTo(terminal, session, 'address'); + await expectFocused(terminal, address); + await tabTo(terminal, session, 'add-card'); + await expectFocused(terminal, addCard); + await terminal.keyboard.press('Enter'); + + await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens'); + await expectFocused(terminal, cardNumber); + await tabTo(terminal, session, 'expiry'); + await expectFocused(terminal, expiry); + await tabTo(terminal, session, 'cvc'); + await expectFocused(terminal, cvc); + await tabTo(terminal, session, 'submit-card'); + await expectFocused(terminal, submitCard); + await terminal.keyboard.press('Enter'); + + await expectTreeCondition(terminal, () => dialog.matches().length === 0, 'dialog closes'); + await expectFocused(terminal, addCard); + await tabTo(terminal, session, 'name'); + await expectFocused(terminal, name); + }); +}); + +test('with the dialog open, Tab is contained by the modal', async () => { + await withTerminalAsync(entry(), async (terminal) => { + const session = semantic(terminal); + await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + const dialog = session.locator('dialog[role="dialog"][label="card"]'); + const order = ['expiry', 'cvc', 'submit-card', 'card-number']; + + await terminal.keyboard.press('Enter'); + await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens'); + + // the app focuses card-number when the dialog opens; walk the full cycle + const labels: (string | undefined)[] = []; + await expectFocused(terminal, session.locator('input[label="card-number"]')); + for (const label of order) { + await tabTo(terminal, session, label); + labels.push(session.locator('[focused]').matches()[0]?.attrs.label); + } + expect(labels).toEqual(order); + + await terminal.keyboard.press('Shift+Tab'); + await expectFocused(terminal, session.locator('button[label="submit-card"]')); + }); +}); diff --git a/packages/pizza/vitest.config.ts b/packages/pizza/vitest.config.ts new file mode 100644 index 0000000..48cdfd7 --- /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', + poolOptions: { forks: { singleFork: true } }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 685c731..dfda6ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,7 +31,7 @@ importers: version: 0.3.1 '@bomb.sh/tools': specifier: ^0.6.1 - version: 0.6.1(@types/node@22.20.1)(oxc-resolver@11.21.3)(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)(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 @@ -119,6 +119,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': @@ -149,7 +208,7 @@ 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)(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)) @@ -1663,6 +1722,11 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + 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==} @@ -1805,7 +1869,7 @@ snapshots: '@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)(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 +1880,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) 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 +1912,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)(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 +1923,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) 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))) @@ -2916,7 +2980,7 @@ snapshots: 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 +2991,7 @@ snapshots: yuku-parser: 0.6.3 optionalDependencies: '@typescript/native-preview': 7.0.0-dev.20260623.1 + typescript: 5.9.3 transitivePeerDependencies: - oxc-resolver @@ -3008,7 +3073,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): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -3019,7 +3084,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,6 +3093,7 @@ snapshots: optionalDependencies: publint: 0.3.21 tsx: 4.23.13 + typescript: 5.9.3 unrun: 0.2.39 transitivePeerDependencies: - '@ts-macro/tsc' @@ -3044,6 +3110,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + typescript@5.9.3: {} + ultramatter@0.0.4: {} unbash@4.0.1: {} From f78b1f7c45ca492292162af8bab5d56ad13b0526 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Sat, 5 Sep 2026 17:21:41 -0400 Subject: [PATCH 2/4] Build scoped terminal evidence testing on the Rust PTY host --- experiments/ghostwright/HOST-COMPARISON.md | 4 +- experiments/ghostwright/README.md | 26 +- experiments/ghostwright/docs/architecture.md | 33 +- .../ghostwright/docs/scoped-execution.md | 118 ++++ experiments/ghostwright/ghostty.lock.json | 15 +- .../ghostwright/native/pty-host-c/main.c | 190 ------ .../ghostwright/native/pty-host-c/protocol.c | 572 ------------------ .../ghostwright/native/pty-host-c/protocol.h | 107 ---- .../ghostwright/native/pty-host-c/session.c | 293 --------- .../ghostwright/native/pty-host-c/session.h | 33 - .../pty-host-rust/src/contract_tests.rs | 73 +++ .../native/pty-host-rust/src/main.rs | 69 ++- .../native/pty-host-rust/src/protocol.rs | 96 ++- .../native/pty-host-rust/src/session.rs | 149 ++++- experiments/ghostwright/package.json | 5 +- .../ghostwright/scripts/build-artifacts.ts | 5 +- .../ghostwright/scripts/build-host-c.ts | 36 -- .../ghostwright/scripts/build-host-rust.ts | 40 +- .../ghostwright/scripts/compare-hosts.ts | 163 ----- .../ghostwright/src/assertions/index.ts | 14 +- experiments/ghostwright/src/async.ts | 13 +- experiments/ghostwright/src/conditions.ts | 121 ++++ .../ghostwright/src/effection/index.ts | 49 +- experiments/ghostwright/src/errors.ts | 10 + experiments/ghostwright/src/execution.ts | 370 +++++++++++ experiments/ghostwright/src/index.ts | 12 +- experiments/ghostwright/src/inspection.ts | 113 ++++ experiments/ghostwright/src/locators.ts | 78 +++ experiments/ghostwright/src/matchers.ts | 156 +++++ experiments/ghostwright/src/observations.ts | 119 ++++ experiments/ghostwright/src/profile.ts | 1 + experiments/ghostwright/src/pty/client.ts | 45 +- experiments/ghostwright/src/pty/protocol.ts | 3 +- .../ghostwright/src/terminal/extensions.ts | 45 +- .../ghostwright/src/terminal/output.ts | 66 ++ .../ghostwright/src/terminal/session.ts | 394 ++++++------ experiments/ghostwright/src/terminal/wasm.ts | 5 +- experiments/ghostwright/src/tracing/replay.ts | 192 +++--- experiments/ghostwright/src/tracing/trace.ts | 5 +- experiments/ghostwright/src/types.ts | 30 +- .../ghostwright/test/backpressure.test.ts | 73 +++ .../ghostwright/test/conformance.test.ts | 2 +- .../ghostwright/test/extensions.test.ts | 7 + experiments/ghostwright/test/scoped.test.ts | 285 +++++++++ experiments/ghostwright/tsconfig.types.json | 9 + experiments/ghostwright/type-tests/api.ts | 40 ++ packages/clack-tty/src/auto.ts | 1 + packages/clack-tty/src/expectations.ts | 66 +- packages/clack-tty/src/extension.ts | 328 +++------- packages/clack-tty/src/index.ts | 4 +- packages/clack-tty/src/producer.ts | 34 +- packages/clack-tty/src/protocol.ts | 76 +-- packages/clack-tty/test/e2e.test.ts | 172 +----- packages/clack-tty/test/locator.test.ts | 269 ++------ packages/clack-tty/test/protocol.test.ts | 314 +++------- packages/clack-tty/test/structural.test.ts | 97 --- packages/hello-world/test/hello-world.test.ts | 111 +--- .../pizza-preact/test/pizza-preact.test.ts | 227 +++---- packages/pizza/test/pizza.test.ts | 361 ++++------- 59 files changed, 2967 insertions(+), 3377 deletions(-) create mode 100644 experiments/ghostwright/docs/scoped-execution.md delete mode 100644 experiments/ghostwright/native/pty-host-c/main.c delete mode 100644 experiments/ghostwright/native/pty-host-c/protocol.c delete mode 100644 experiments/ghostwright/native/pty-host-c/protocol.h delete mode 100644 experiments/ghostwright/native/pty-host-c/session.c delete mode 100644 experiments/ghostwright/native/pty-host-c/session.h create mode 100644 experiments/ghostwright/native/pty-host-rust/src/contract_tests.rs delete mode 100644 experiments/ghostwright/scripts/build-host-c.ts delete mode 100644 experiments/ghostwright/scripts/compare-hosts.ts create mode 100644 experiments/ghostwright/src/conditions.ts create mode 100644 experiments/ghostwright/src/execution.ts create mode 100644 experiments/ghostwright/src/inspection.ts create mode 100644 experiments/ghostwright/src/locators.ts create mode 100644 experiments/ghostwright/src/matchers.ts create mode 100644 experiments/ghostwright/src/observations.ts create mode 100644 experiments/ghostwright/src/terminal/output.ts create mode 100644 experiments/ghostwright/test/backpressure.test.ts create mode 100644 experiments/ghostwright/test/scoped.test.ts create mode 100644 experiments/ghostwright/tsconfig.types.json create mode 100644 experiments/ghostwright/type-tests/api.ts delete mode 100644 packages/clack-tty/test/structural.test.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..ecc3e9d 100644 --- a/experiments/ghostwright/README.md +++ b/experiments/ghostwright/README.md @@ -58,6 +58,14 @@ The callback owns the terminal. Normal return, throw, assertion failure, and can Effection users get the same operations and lifecycle through `withTerminal`; 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. + ## Synchronization model Ghostwright assertions are revision-driven rather than polling-based: @@ -157,7 +165,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. @@ -177,7 +185,7 @@ 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: @@ -187,18 +195,16 @@ bun test examples `ghostty.lock.json` is the source of truth for the build contract and is edited by hand. `bun run update:manifest` only refreshes the `artifacts` checksum map, and only for targets built on the current machine; entries for targets built elsewhere (for example the Linux hosts when building on macOS) are preserved. `bun run verify:artifacts` skips and reports artifacts that are absent locally, and fails hard on any artifact that is present but does not match. -The PTY host has two side-by-side implementations: - -- `native/pty-host-c`: packaged pure-C default, compiled with Apple Clang or native `musl-gcc` -- `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/architecture.md b/experiments/ghostwright/docs/architecture.md index b2fd59e..d42cc01 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,7 +68,7 @@ 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 @@ -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/scoped-execution.md b/experiments/ghostwright/docs/scoped-execution.md new file mode 100644 index 0000000..bee1d4c --- /dev/null +++ b/experiments/ghostwright/docs/scoped-execution.md @@ -0,0 +1,118 @@ +# 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. + +## Async API + +```ts +import { withTerminalAsync, 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 withTerminalAsync( + { + 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` uses the same session resource, matcher executor, and capture operation: + +```ts +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. Use the scoped region API above for the new ownership and capture model. Runner fixtures and bound-locator convenience methods are not part of this pass. diff --git a/experiments/ghostwright/ghostty.lock.json b/experiments/ghostwright/ghostty.lock.json index ecaaa45..7ff04d4 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": "704d2273677552ee22841b16eb06fd9d3d00216176d682220906152f8cbcec8e" }, "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..82cf46c 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,25 @@ 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) }; + loop { + match waitpid(child, None) { + Err(nix::errno::Errno::EINTR) => continue, + _ => break, + } + } + } + } +} + fn parse_signal(name: &str) -> Result { match name { "SIGINT" | "INT" => Ok(Signal::SIGINT), @@ -407,6 +522,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..05bc0d9 100644 --- a/experiments/ghostwright/package.json +++ b/experiments/ghostwright/package.json @@ -38,11 +38,10 @@ "build": "rm -rf dist && bun build src/index.ts src/async.ts src/pty/protocol.ts --outdir dist --target node --format esm --packages external --sourcemap=external && bunx tsc -p tsconfig.build.json && bun scripts/fix-declarations.ts", "fetch:ghostty": "bun scripts/fetch-ghostty.ts", "build:ghostty-vt": "bun scripts/build-ghostty-vt.ts", - "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": "bunx 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", 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..1733371 100644 --- a/experiments/ghostwright/scripts/build-host-rust.ts +++ b/experiments/ghostwright/scripts/build-host-rust.ts @@ -1,19 +1,25 @@ import { $ } from 'bun'; -import { mkdir } from 'node:fs/promises'; +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((target) => targets[target] === local); +if (!target || !targets[target]) throw new Error(`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.log(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/src/assertions/index.ts b/experiments/ghostwright/src/assertions/index.ts index e750bbd..1a52e3f 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,7 +204,7 @@ 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 ?? @@ -235,7 +236,7 @@ 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 ?? @@ -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..e887d0e 100644 --- a/experiments/ghostwright/src/async.ts +++ b/experiments/ghostwright/src/async.ts @@ -1,15 +1,16 @@ import { call, run } from 'effection'; -import type { AsyncTerminal, TerminalLaunchOptions } from './types.ts'; -import { TerminalSession } from './terminal/session.ts'; +import type { TerminalLaunchOptions } from './types.ts'; +import { execution, useSession, type AsyncExecution } from './execution.ts'; /** Launch a terminal session, run an async body, and clean up when done. */ export async function withTerminalAsync( options: TerminalLaunchOptions, - body: (terminal: AsyncTerminal) => Promise, + body: (terminal: AsyncExecution) => Promise, ): Promise { return run(function* () { - const session: TerminalSession = yield* call(() => TerminalSession.launch(options)); + const session = yield* useSession(options); + const terminal = yield* execution(session); try { - const result: T = yield* call(() => body(session)); + const result: T = yield* call(() => Promise.resolve().then(() => body(terminal))); if (session.trace.policy === 'on') yield* call(() => session.trace.persist( @@ -33,8 +34,6 @@ export async function withTerminalAsync( (error as Error & { suppressed?: unknown[] }).suppressed = [traceError]; } throw error; - } finally { - yield* call(() => session.close()); } }); } 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..b3b4a7d 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,17 @@ 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 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 +51,30 @@ 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() { + return this.inner.signal; + } + assert(locator: RegionLocator, matcher: Matcher) { + return assertRegion(this.inner.session, locator, matcher); + } + expect(locator: RegionLocator) { + return expectRegion.operation(this, locator); + } + click(locator: RegionLocator, options?: MouseOptions) { + return op(() => this.inner.click(locator, options)); + } + capture(options: CaptureOptions, body: (terminal: EffectionTerminal) => Operation) { + 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)), @@ -95,21 +123,22 @@ 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)); + const result: T = yield* body(new EffectionTerminal(terminal)); if (session.trace.policy === 'on') yield* call(() => session.trace.persist( @@ -133,8 +162,6 @@ export function* withTerminal( (error as Error & { suppressed?: unknown[] }).suppressed = [traceError]; } throw error; - } finally { - yield* call(() => session.close()); } } /** Effection locator assertion expectation. */ @@ -171,7 +198,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..adb32dd 100644 --- a/experiments/ghostwright/src/errors.ts +++ b/experiments/ghostwright/src/errors.ts @@ -47,6 +47,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..808aabd --- /dev/null +++ b/experiments/ghostwright/src/execution.ts @@ -0,0 +1,370 @@ +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 { AsyncTerminal, MouseOptions, TerminalLaunchOptions } from './types.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) => 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 = () => 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) => { + 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(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 keyboard: AsyncTerminal['keyboard']; + readonly mouse: AsyncTerminal['mouse']; + 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 = this.#bind(session.mouseFor(signal)); + 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); + } + #bind Promise>>(methods: T): T { + return Object.fromEntries( + Object.entries(methods).map(([name, method]) => [ + name, + (...args: unknown[]) => this.#promise(() => method(...args)), + ]), + ) 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 outcome = await this.scope.run(function* () { + try { + return { ok: true as const, value: yield* scoped(operation) }; + } 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)); + } + get screen() { + return this.session.screen; + } + getByText(...args: Parameters) { + return this.session.getByText(...args); + } + region(...args: Parameters) { + return this.session.region(...args); + } + resize(viewport: Parameters[0]) { + return this.#promise(() => this.session.resize(viewport, this.signal)); + } + close() { + return this.#promise(() => this.session.close()); + } + expect(locator: RegionLocator) { + return expectRegion(this, locator); + } + assert(locator: RegionLocator, matcher: Matcher): Promise { + return this.#run(() => assertRegion(this.session, locator, matcher)); + } + async click(locator: RegionLocator, options?: MouseOptions) { + const region = await this.assert(locator, (actual) => ({ + pass: !!actual.visibleBounds, + expected: 'on-screen region', + actual: actual.bounds, + })); + this.signal.throwIfAborted(); + const bounds = region.visibleBounds!; + return this.mouse.click( + { + column: bounds.column + Math.floor((bounds.width - 1) / 2), + row: bounds.row + Math.floor((bounds.height - 1) / 2), + }, + options, + ); + } + capture( + options: CaptureOptions, + body: (execution: AsyncExecution) => Promise, + ): Promise { + return 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 }, + ); + 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 = () => {}, + offStatus = () => {}; + const stop = () => { + finished = true; + off(); + offStatus(); + clearTimeout(timer); + }; + const finish = () => { + stop(); + completion.resolve( + Object.freeze({ + baseline, + startedAt, + completedAt: performance.now(), + observations: Object.freeze([...observations]), + }), + ); + }; + const fail = (cause: unknown) => { + stop(); + completion.reject(cause as Error); + }; + const schedule = () => { + 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..371e73a 100644 --- a/experiments/ghostwright/src/index.ts +++ b/experiments/ghostwright/src/index.ts @@ -1,10 +1,17 @@ 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 { 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 { withTerminal, 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'; @@ -31,6 +38,7 @@ export function expectTerminal( | AsyncLocatorExpectation | OperationTerminalExpectation | AsyncTerminalExpectation { + if (target instanceof AsyncExecution) return expectAsync(target.session); return ( target instanceof EffectionLocator || target instanceof EffectionTerminal ? expectOperation(target) diff --git a/experiments/ghostwright/src/inspection.ts b/experiments/ghostwright/src/inspection.ts new file mode 100644 index 0000000..b9debc3 --- /dev/null +++ b/experiments/ghostwright/src/inspection.ts @@ -0,0 +1,113 @@ +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() { + 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) { + return Object.freeze({ region: (bounds: Rect) => new RegionInspection(screen, bounds) }); +} diff --git a/experiments/ghostwright/src/locators.ts b/experiments/ghostwright/src/locators.ts new file mode 100644 index 0000000..931189c --- /dev/null +++ b/experiments/ghostwright/src/locators.ts @@ -0,0 +1,78 @@ +import { GhostwrightError, InvalidOptionsError } from './errors.ts'; +import { RegionInspection } from './inspection.ts'; +import type { Observation } from './observations.ts'; +import type { Rect } 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; + 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] : []; + }); + }, + 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) : [], + ); +} +/** Fixed coordinates are an explicit alternative to semantic location. */ +export function regionLocator(bounds: Rect): RegionLocator { + const copy = Object.freeze({ ...bounds }); + return query(JSON.stringify(copy), undefined, () => [copy]); +} diff --git a/experiments/ghostwright/src/matchers.ts b/experiments/ghostwright/src/matchers.ts new file mode 100644 index 0000000..fd3f0b7 --- /dev/null +++ b/experiments/ghostwright/src/matchers.ts @@ -0,0 +1,156 @@ +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, + }; + }; + +// Each method may have its own argument tuple. `any` is confined to this +// heterogeneous registry constraint; the inferred public methods preserve it. +export type MatcherDefinitions = Record< + string, + (actual: RegionInspection, ...args: any[]) => MatchResult +>; +export function defineMatchers(matchers: M): Readonly { + return Object.freeze({ ...matchers }); +} +export const builtInMatchers = defineMatchers({ + 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 factory(definitions: M): ExpectFactory { + const expect = (executor: AssertionExecutor, locator: RegionLocator): Expectations => + Object.fromEntries( + Object.entries(definitions).map(([name, matcher]) => [ + name, + (...args: unknown[]) => executor.assert(locator, (actual) => matcher(actual, ...args)), + ]), + ) 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, + (...args: unknown[]) => executor.assert(locator, (actual) => matcher(actual, ...args)), + ]), + ) 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/observations.ts b/experiments/ghostwright/src/observations.ts new file mode 100644 index 0000000..6226675 --- /dev/null +++ b/experiments/ghostwright/src/observations.ts @@ -0,0 +1,119 @@ +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; + } + get sequence() { + 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..8dddcf8 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, diff --git a/experiments/ghostwright/src/pty/client.ts b/experiments/ghostwright/src/pty/client.ts index 13c0e61..4ff8db3 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 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..455ed10 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, @@ -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/terminal/extensions.ts b/experiments/ghostwright/src/terminal/extensions.ts index 8471592..d489003 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[]) {} @@ -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..dceafed --- /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((extension) => extension.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..7ea4530 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, @@ -54,16 +54,12 @@ import type { 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[]) { const result = new Uint8Array(parts.reduce((total, part) => total + part.length, 0)); @@ -89,10 +85,12 @@ function observableKey(s: ScreenSnapshot) { } 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 +102,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' @@ -210,7 +178,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, @@ -297,86 +289,8 @@ export class TerminalSession implements AsyncTerminal { get lastAction() { return this.#lastAction; } - extension(definition: TerminalExtensionDefinition): T { - const registered = this.#extensions.get(definition.id); - if (!registered || registered.definition !== definition) - throw new GhostwrightError({ - code: 'GW_EXTENSION_NOT_REGISTERED', - message: `Extension ${definition.id} was not registered for this terminal`, - }); - return registered.session as T; - } - #initializeExtensions() { - for (const [id, record] of this.#extensions) { - const context = this.#extensionContext(id); - record.session = record.definition.createSession(context); - } - } - #extensionContext(id: string): ExtensionSessionContext { - return Object.freeze({ - terminal: this, - screen: this.screen, - publish: (commit: ExtensionCommit) => this.#publishExtension(id, commit), - diagnostic: (error: GhostwrightError) => { - this.#trace.add('extension-diagnostic', { - extensionId: id, - code: error.code, - message: error.message.slice(0, 1024), - }); - }, - }); - } - #publishExtension(id: string, commit: ExtensionCommit): ExtensionRevision { - const record = this.#extensions.get(id); - if (!record) - throw new GhostwrightError({ - code: 'GW_EXTENSION_NOT_REGISTERED', - message: `Unknown extension ${id}`, - }); - const revision = Object.freeze({ - sequence: ++record.sequence, - timestamp: this.#engine.now(), - extensionId: id, - protocolFrame: commit.protocolFrame, - screenSequence: this.#snapshot.sequence, - value: commit.value, - }); - record.revisions.push(revision); - this.#trace.add('extension-revision', { - extensionId: id, - sequence: revision.sequence, - protocolFrame: revision.protocolFrame, - screenSequence: revision.screenSequence, - }); - this.#notify(); - return revision; - } - #acceptOsc( - registration: TerminalExtensionDefinition['osc'], - message: RegisteredOscMessage, - ) { - if (!registration) return; - const record = [...this.#extensions.values()].find( - (candidate) => candidate.definition.osc === registration, - ); - if (!record) return; - const context = this.#extensionContext(record.definition.id); - try { - const commit = registration.decode(message); - record.definition.accept?.(record.session, commit, context); - } catch (cause) { - const error = - cause instanceof GhostwrightError - ? cause - : new GhostwrightError({ - code: 'GW_EXTENSION_OSC', - message: - cause instanceof Error - ? cause.message.slice(0, 1024) - : 'Extension OSC decode failed', - }); - context.diagnostic(error); - } + hasExtension(id: string): boolean { + return (this.options.extensions ?? []).some((extension) => extension.id === id); } now() { return this.#engine.now(); @@ -394,24 +308,8 @@ export class TerminalSession implements AsyncTerminal { this.#raw.push(bytes.slice()); const max = this.options.history?.maxRawBytes ?? 4 * 1024 * 1024; while (this.#raw.reduce((n, b) => n + b.length, 0) > max) this.#raw.shift(); - 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,13 +317,29 @@ 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) { @@ -490,30 +404,34 @@ 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(kind: FrameKind, value: unknown, 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 if (kind === FrameKind.RESIZE) + ack = await this.#command(() => { + signal?.throwIfAborted(); + return this.#host.resize(value); + }); + else if (kind === FrameKind.SIGNAL) + ack = await this.#command(() => { + signal?.throwIfAborted(); + return this.#host.signal(value); + }); else throw new GhostwrightError({ code: 'GW_UNSUPPORTED_ACTION', message: 'Unsupported action' }); const receipt: Readonly = Object.freeze({ actionSequence: sequence, screenSequenceBefore: before, acknowledgedAt: this.#engine.now(), - deliveredToChild: delivered, bytesWritten: ack.bytesWritten ?? 0, }); this.#lastAction = receipt as ActionReceipt; @@ -526,11 +444,8 @@ export class TerminalSession implements AsyncTerminal { return receipt as ActionReceipt; } // oxlint-disable-next-line bombshell-dev/max-params -- internal method - async #write( - data: Uint8Array, - delivered = data.length > 0, - traceMode: 'record' | 'redact' = 'record', - ) { + async #write(data: Uint8Array, traceMode: 'record' | 'redact' = 'record', signal?: AbortSignal) { + signal?.throwIfAborted(); this.#ensure('write input'); const before = this.#revision, sequence = ++this.#action; @@ -538,14 +453,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,22 +482,28 @@ 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), - }; + keyboardFor(signal?: AbortSignal) { + 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) { if ( !Number.isInteger(p.column) || @@ -586,7 +518,14 @@ 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'); this.#point(p); const wasDown = this.#mouseDown; if (action === 'down') this.#mouseDown = true; @@ -597,41 +536,59 @@ 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) { + 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) => { + 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, + ) { + return this.#send(FrameKind.SIGNAL, { 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') => @@ -679,22 +636,25 @@ export class TerminalSession implements AsyncTerminal { this.#point(r); this.#point({ column: r.column + r.width - 1, row: r.row + r.height - 1 }); } - async resize(v: Viewport) { + async resize(v: Viewport, signal?: AbortSignal) { const viewport = normalizeViewport(v); - const receipt = await this.#send(FrameKind.RESIZE, viewport); + const receipt = await this.#send(FrameKind.RESIZE, viewport, signal); this.#viewport = viewport; this.#engine.resize(viewport); this.#terminalHistoryGeneration++; this.#publish('resize'); + this.observations.screen(this.#snapshot); return receipt; } - async close() { + close(): Promise { + return (this.#closePromise ??= this.#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 +668,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; diff --git a/experiments/ghostwright/src/terminal/wasm.ts b/experiments/ghostwright/src/terminal/wasm.ts index 084d493..4720067 100644 --- a/experiments/ghostwright/src/terminal/wasm.ts +++ b/experiments/ghostwright/src/terminal/wasm.ts @@ -379,8 +379,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, diff --git a/experiments/ghostwright/src/tracing/replay.ts b/experiments/ghostwright/src/tracing/replay.ts index 207e428..d19cea2 100644 --- a/experiments/ghostwright/src/tracing/replay.ts +++ b/experiments/ghostwright/src/tracing/replay.ts @@ -1,95 +1,147 @@ import { readFile } from 'node:fs/promises'; -// oxlint-disable-next-line no-restricted-imports -- path module needed for path resolution 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 { 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 !== 1) 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']) => { + const next = engine.snapshot(cause); + const observable = (s: ScreenSnapshot) => + 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) => 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 === 'action' && 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..fc00a92 100644 --- a/experiments/ghostwright/src/tracing/trace.ts +++ b/experiments/ghostwright/src/tracing/trace.ts @@ -4,6 +4,7 @@ import { resolve } from 'node:path'; import { randomBytes } from 'node:crypto'; import type { ProcessStatus, ScreenSnapshot, TerminalLaunchOptions } from '../types.ts'; import { TraceWriteError } from '../errors.ts'; +import { normalizeViewport } from '../profile.ts'; export interface TraceEvent { schemaVersion: 1; @@ -119,8 +120,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..71bd13c 100644 --- a/experiments/ghostwright/src/types.ts +++ b/experiments/ghostwright/src/types.ts @@ -1,5 +1,4 @@ import type { Operation } from 'effection'; -import type { GhostwrightError } from './errors.ts'; export interface Viewport { columns: number; @@ -45,32 +44,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 +70,7 @@ export interface TerminalLaunchOptions { trace?: TracePolicy | TraceOptions; name?: string; /** Optional framework-specific extensions receiving ordered in-band OSC commits. */ - extensions?: readonly TerminalExtensionDefinition[]; + extensions?: readonly TerminalExtensionDefinition[]; } export interface Point { column: number; @@ -102,7 +84,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; } /** @@ -424,8 +406,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/test/backpressure.test.ts b/experiments/ghostwright/test/backpressure.test.ts new file mode 100644 index 0000000..bf33a38 --- /dev/null +++ b/experiments/ghostwright/test/backpressure.test.ts @@ -0,0 +1,73 @@ +import { expect, test } from 'bun:test'; +import { withTerminalAsync, 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 withTerminalAsync( + { + 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/conformance.test.ts b/experiments/ghostwright/test/conformance.test.ts index ab95608..f8ade76 100644 --- a/experiments/ghostwright/test/conformance.test.ts +++ b/experiments/ghostwright/test/conformance.test.ts @@ -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( diff --git a/experiments/ghostwright/test/extensions.test.ts b/experiments/ghostwright/test/extensions.test.ts index 24111b6..88b0658 100644 --- a/experiments/ghostwright/test/extensions.test.ts +++ b/experiments/ghostwright/test/extensions.test.ts @@ -48,6 +48,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/scoped.test.ts b/experiments/ghostwright/test/scoped.test.ts new file mode 100644 index 0000000..006b6e8 --- /dev/null +++ b/experiments/ghostwright/test/scoped.test.ts @@ -0,0 +1,285 @@ +import { expect, test } from 'bun:test'; +import { run } from 'effection'; +import { mkdtemp, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + withTerminalAsync, + withTerminal, + defineLocator, + defineMatchers, + createExpect, + all, + textContains, + cursorInside, + edgeHasStyle, + sequence, + settled, + replayTrace, + type TerminalExtensionDefinition, + 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 = () => ({ + command: process.execPath, + args: ['-e', application], + extensions: [extension], + trace: 'off' as const, +}); + +test('capture preserves paired moving geometry, first endpoint, and pure replay', async () => { + await withTerminalAsync(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 withTerminalAsync(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 withTerminalAsync(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 withTerminalAsync(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 withTerminalAsync(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 withTerminalAsync(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 withTerminalAsync(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('an already drawn region can settle without a new application commit', async () => { + await withTerminalAsync(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 withTerminalAsync(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 withTerminalAsync(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* withTerminal(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 withTerminalAsync({ ...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 withTerminalAsync(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/tsconfig.types.json b/experiments/ghostwright/tsconfig.types.json new file mode 100644 index 0000000..2e02abf --- /dev/null +++ b/experiments/ghostwright/tsconfig.types.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "noEmit": true, + "emitDeclarationOnly": false, + "rootDir": "." + }, + "include": ["src/**/*.ts", "type-tests/**/*.ts"] +} diff --git a/experiments/ghostwright/type-tests/api.ts b/experiments/ghostwright/type-tests/api.ts new file mode 100644 index 0000000..3bd1136 --- /dev/null +++ b/experiments/ghostwright/type-tests/api.ts @@ -0,0 +1,40 @@ +import { + createExpect, + defineMatchers, + defineLocator, + textContains, + type AsyncExecution, + type RegionInspection, + type EffectionTerminal, +} from '../src/index.ts'; + +declare const ui: AsyncExecution; +declare const native: EffectionTerminal; +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); + }, + }), +); +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/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..89a8089 100644 --- a/packages/clack-tty/src/extension.ts +++ b/packages/clack-tty/src/extension.ts @@ -1,91 +1,46 @@ -/** - * Ghostwright terminal extension for the clack.ui semantic tree protocol, plus - * the tree-aware CSS locator (REQ-015..REQ-021). - * - * Architecture mirrors the retired freedom-tty consumer: strict decode with - * stable error codes, ordered revisions via the extension session context, a - * css-select evaluation over the materialized node tree, and the geometry -> - * screen-region bridge that scopes ghostwright's revision-driven assertions. - */ import { compile, type Options } from 'css-select'; import { AttributeAction, parse, SelectorType, type Selector } from 'css-what'; -import { GhostwrightError } from 'ghostwright'; -import type { - AsyncRegion, - AsyncTerminal, - ExtensionRevision, - ExtensionSessionContext, - Rect, - RegisteredOscMessage, - TerminalExtensionDefinition, - TextLocatorOptions, -} from 'ghostwright'; +import { defineLocator, GhostwrightError, 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[]) { + 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 value = name === 'role' ? node.attrs.role @@ -98,24 +53,20 @@ function attribute(node: Element, name: string): string | undefined { : 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 +78,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 +94,89 @@ 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) { 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) + 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', - `Selector traversal ${token.type} is not supported`, - ); - if (token.type === SelectorType.Attribute && token.action === AttributeAction.Not) - fail( - 'GW_CLACK_SELECTOR_INVALID', - 'The nonstandard != attribute operator is not supported', + '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; -} - -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; +/** Construct a reusable, session-free query. Resolution never reads a live UI. */ +export function locator(source: string) { + const predicate = compile(selector(source), { adapter, xmlMode: true, cacheResults: false }); + return defineLocator(ID, source, (frame) => + materialize(frame) + .filter(predicate) + .map((node) => { + if (!node.geo) + return fail( + 'GW_CLACK_NO_GEOMETRY', + `${source}: ${node.key}/${node.name} has no geometry`, + ); + return node.geo.term; // Preserve original edges. Core inspection handles viewport clipping. }), - ); - } - unique(): TreeMatch { - const matches = this.matches(); - if (matches.length !== 1) - fail( - 'GW_CLACK_LOCATOR_STRICT', - `Selector ${JSON.stringify(this.source)} matched ${matches.length}: ${matches - .slice(0, 20) - .map((node) => `${node.key}/${node.name}`) - .join(', ')}`, - ); - return matches[0]!; - } - nth(index: number): ClackTtyLocator { - if (!Number.isSafeInteger(index) || index < 0) - fail('GW_CLACK_LOCATOR_RANGE', 'Locator index must be a nonnegative safe integer'); - return new ClackTtyLocator(this.session, this.source, index); - } - #regionBounds(): Rect { - const node = this.unique(); - const rect = node.range; - if (!rect) - fail( - 'GW_CLACK_NO_GEOMETRY', - `Selector ${JSON.stringify(this.source)} matched ${node.key}/${node.name} without geometry`, - ); - return rect; - } - /** Screen region scoped to the match's geometry (REQ-020). */ - region(): AsyncRegion { - return this.session.terminal.region(this.#regionBounds()); - } - /** Text assertion scoped to the match's on-screen rect (REQ-020). */ - getByText(textValue: string, textOptions?: TextLocatorOptions) { - return this.region().getByText(textValue, textOptions); - } -} - -export class ClackTtySession { - #current?: ClackFrameV1; - #revisions: ExtensionRevision[] = []; - #documentFrame = -1; - #document: Element[] = []; - readonly terminal: AsyncTerminal; - constructor(terminal: AsyncTerminal) { - this.terminal = terminal; - } - validateNext(frame: ClackFrameV1) { - if (this.#current && frame.frame !== this.#current.frame + 1) - fail( - 'GW_CLACK_FRAME', - `Semantic frame ${frame.frame} does not follow accepted frame ${this.#current.frame}`, - ); - } - setCurrent(frame: ClackFrameV1) { - this.#current = frame; - this.#documentFrame = -1; - } - record(revision: ExtensionRevision) { - this.#revisions.push(revision); - } - current() { - return this.#current; - } - frames() { - return Object.freeze(this.#revisions.map((revision) => revision.value)); - } - revisions() { - return Object.freeze([...this.#revisions]); - } - document(): readonly Element[] { - if (!this.#current) return []; - if (this.#documentFrame !== this.#current.frame) { - this.#document = materialize(this.#current); - this.#documentFrame = this.#current.frame; - } - return this.#document; - } - /** Tree-aware CSS locator against the newest accepted frame (REQ-017). */ - locator(source: string) { - return new ClackTtyLocator(this, source); - } + ); } -/** Ghostwright extension definition for the clack.ui semantic tree (REQ-015). */ -export function clackTtyExtension(): TerminalExtensionDefinition< - ClackTtySession, - ClackFrameV1 -> { +/** 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..cc8f104 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 } 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..3b3aa74 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'; @@ -96,7 +95,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); @@ -138,24 +138,16 @@ 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; + 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; @@ -179,10 +171,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 } : {}), }, - states: { focused, focusRoot: focused }, ...(geo !== undefined ? { geo } : {}), }); entry.children.forEach((child, index) => visit(child, entry.key, index)); @@ -199,11 +189,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 +202,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); diff --git a/packages/clack-tty/src/protocol.ts b/packages/clack-tty/src/protocol.ts index 8fe19f8..576e65f 100644 --- a/packages/clack-tty/src/protocol.ts +++ b/packages/clack-tty/src/protocol.ts @@ -1,9 +1,8 @@ /** * 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). */ @@ -43,43 +42,35 @@ 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 isScalar = (value: unknown): value is JsonScalar => value === null || typeof value === 'string' || @@ -87,7 +78,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 +91,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,7 +114,7 @@ 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 { +export function validateFrame(input: unknown): ClackFrame { if (!input || typeof input !== 'object' || Array.isArray(input)) fail('GW_CLACK_SCHEMA', 'Semantic frame must be an object'); const frame = input as Record; @@ -139,9 +133,6 @@ export function validateFrame(input: unknown): ClackFrameV1 { (surface.row as number) <= 0 ) fail('GW_CLACK_SCHEMA', 'Invalid render surface'); - const focusStack = frame.focusStack; - if (!Array.isArray(focusStack) || !focusStack.every((key) => typeof key === 'string')) - fail('GW_CLACK_SCHEMA', 'Invalid focus stack'); if (!Array.isArray(frame.nodes)) fail('GW_CLACK_SCHEMA', 'Invalid semantic node list'); const rawNodes = frame.nodes as unknown[]; if (rawNodes.length > LIMITS.nodes) @@ -156,12 +147,11 @@ export function validateFrame(input: unknown): ClackFrameV1 { rows: surface.rows as number, row: surface.row as number, }, - focusStack: Object.freeze([...(focusStack as string[])]), nodes: Object.freeze(nodes), }; } -function validateNode(raw: unknown, index: number): ClackNodeV1 { +function validateNode(raw: unknown, index: number): ClackNode { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) fail('GW_CLACK_SCHEMA', `Node ${index} must be an object`); const node = raw as Record; @@ -172,7 +162,7 @@ function validateNode(raw: unknown, index: number): ClackNodeV1 { if (!Number.isInteger(node.order) || (node.order as number) < 0) fail('GW_CLACK_SCHEMA', `Node ${key} has an invalid sibling order`); const attrs = node.attrs as Record | undefined; - if (!attrs || typeof attrs.focusable !== 'boolean') + if (!attrs || typeof attrs !== 'object' || Array.isArray(attrs)) 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); @@ -193,13 +183,6 @@ function validateNode(raw: unknown, index: number): ClackNodeV1 { custom[name] = value as JsonScalar; } } - const states = node.states as Record | undefined; - if ( - !states || - typeof states.focused !== 'boolean' || - typeof states.focusRoot !== 'boolean' - ) - fail('GW_CLACK_SCHEMA', `Node ${key} has invalid states`); return { key, name, @@ -209,25 +192,22 @@ function validateNode(raw: unknown, index: number): ClackNodeV1 { ...(attrs.role !== undefined ? { role: attrs.role as string } : {}), ...(attrs.label !== undefined ? { label: attrs.label as string } : {}), ...(attrs.input !== undefined ? { input: attrs.input as boolean } : {}), - focusable: attrs.focusable as boolean, ...(custom !== undefined ? { custom } : {}), }, - states: { focused: states.focused as boolean, focusRoot: states.focusRoot as boolean }, ...(node.geo !== undefined ? { geo: validateGeometry(node.geo, key) } : {}), }; } function validateGeometry(raw: unknown, key: string): ClackNodeGeometry { - if (!raw || typeof raw !== 'object') - fail('GW_CLACK_SCHEMA', `Node ${key} has invalid geometry`); + if (!raw || typeof raw !== 'object') fail('GW_CLACK_SCHEMA', `Node ${key} has invalid geometry`); const geo = raw as Record; const layout = floatRect(geo.layout, key, 'layout'); const term = cellRect(geo.term, key, 'term'); - const visible = - geo.visible === undefined ? undefined : cellRect(geo.visible, key, 'visible'); + 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`); @@ -245,6 +225,7 @@ function floatRect(raw: unknown, key: string, field: string): FloatRect { }; } +// 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`); @@ -262,23 +243,24 @@ function cellRect(raw: unknown, key: string, field: string): Rect { }; } +// 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; @@ -296,7 +278,7 @@ 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. + * `surface.row` is 1-based; the result is in zero-based terminal cell space. */ export function geometryFor( layoutBounds: FloatRect, diff --git a/packages/clack-tty/test/e2e.test.ts b/packages/clack-tty/test/e2e.test.ts index 5011e80..cf3e2cf 100644 --- a/packages/clack-tty/test/e2e.test.ts +++ b/packages/clack-tty/test/e2e.test.ts @@ -1,155 +1,47 @@ -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 { withTerminalAsync, regionLocator } 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 = () => ({ command: process.execPath, - args: ['--import', 'tsx', 'src/hello-world.ts', ...extra], - cwd: demoRoot, - viewport: { columns: 80, rows: 24 }, + args: ['--import', '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('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('producer and CSS adapter compose with core assertions over real terminal output', async () => { + await withTerminalAsync(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('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 withTerminalAsync(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 withTerminalAsync({ ...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/locator.test.ts b/packages/clack-tty/test/locator.test.ts index 9efa71f..e95bc5c 100644 --- a/packages/clack-tty/test/locator.test.ts +++ b/packages/clack-tty/test/locator.test.ts @@ -1,217 +1,80 @@ -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 { withTerminalAsync, 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' }, + 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 withTerminalAsync( + { + 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(), }; + 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('form').resolve(observation)).toThrow(/no geometry/); + expect( + name.resolve({ kind: 'screen', sequence: 2, timestamp: 1, screen: t.screen.current() }), + ).toEqual([]); }, - diagnostic() {}, - }; - const session = extension.createSession(context); - extension.accept(session, frame, context); - return session; -} - -describe('selector evaluation over a crafted tree (TC-U5, REQ-017)', () => { - const session = sessionWith(demoFrame); - - test('tag selectors match element names', () => { - expect(session.locator('input').matches().map((match) => match.key)).toEqual(['5', '6']); - }); - - test('attribute selectors expose semantic attributes', () => { - expect(session.locator('[role="textbox"]').matches().map((match) => match.key)).toEqual(['5', '6']); - expect(session.locator('[label="say"]').matches().map((match) => match.key)).toEqual(['5']); - expect(session.locator('input[input]').matches().map((match) => match.key)).toEqual(['5', '6']); - expect(session.locator('[focusable]').matches().map((match) => match.key)).toEqual(['5', '6']); - expect(session.locator('[focused]').matches().map((match) => match.key)).toEqual(['5']); - expect(session.locator('[focus-root]').matches().map((match) => match.key)).toEqual(['5']); - expect(session.locator('[data-kind="meta"]').matches().map((match) => match.key)).toEqual(['7']); - }); - - test('combinators resolve over parent/order links', () => { - expect(session.locator('box > text').matches().map((match) => match.key)).toEqual(['2']); - expect(session.locator('box[label="hello"] > box > input[label="to"]').matches().map((match) => match.key)).toEqual(['6']); - expect(session.locator('input[label="say"] + input').matches().map((match) => match.key)).toEqual(['6']); - expect(session.locator('box[label="hello"] input').matches().map((match) => match.key)).toEqual(['5', '6']); - }); - - test('focus pseudos mirror the attribute form', () => { - expect(session.locator('input:focus').matches().map((match) => match.key)).toEqual(['5']); - }); - - test('zero matches yield an empty list', () => { - expect(session.locator('input[label="nope"]').matches()).toEqual([]); - }); -}); - -describe('match semantics and diagnostics (TC-U6, REQ-019)', () => { - const session = sessionWith(demoFrame); - - test('nth selects deterministic document-ordered matches', () => { - expect(session.locator('input').nth(0).unique().key).toBe('5'); - expect(session.locator('input').nth(1).unique().key).toBe('6'); - expect(session.locator('input').nth(1).matches()).toHaveLength(1); - }); - - test('nonnegative validation; beyond-count indices are lazy (empty), not errors', () => { - expect(() => session.locator('input').nth(2)).not.toThrow(); - expect(session.locator('input').nth(2).matches()).toEqual([]); - expect(() => session.locator('input').nth(-1)).toThrowError( - expect.objectContaining({ code: 'GW_CLACK_LOCATOR_RANGE' }), - ); - expect(() => session.locator('input').nth(1.5)).toThrowError( - expect.objectContaining({ code: 'GW_CLACK_LOCATOR_RANGE' }), - ); - }); - - test('strict single-match requirement lists candidate keys', () => { - try { - session.locator('input').unique(); - expect.unreachable('unique() must throw on ambiguity'); - } catch (error) { - const message = (error as Error).message; - expect(message).toContain('matched 2'); - expect(message).toContain('5/input'); - expect(message).toContain('6/input'); - } - }); -}); - -describe('geometry bridge (REQ-020, TC-I8 support)', () => { - const session = sessionWith(demoFrame); - - test('range prefers visible bounds and falls back to term', () => { - const say = session.locator('input[label="say"]').unique(); - expect(say.range).toEqual({ column: 2, row: 6, width: 10, height: 3 }); - const group = session.locator('box[label="hello"]').unique(); - expect(group.range).toEqual({ column: 0, row: 0, width: 40, height: 8 }); - }); - - test('a match without geometry fails with GW_CLACK_NO_GEOMETRY', () => { - const text = session.locator('text').unique(); - expect(text.geo).toBeUndefined(); - expect(() => session.locator('text').region()).toThrowError( - expect.objectContaining({ code: 'GW_CLACK_NO_GEOMETRY' }), - ); - }); -}); - -describe('selector bounds (TC-U4, REQ-018)', () => { - const session = sessionWith(demoFrame); - const cases: [string, string][] = [ - ['4097 bytes', `box${':has(box)'.repeat(300)}`.slice(0, 4097)], - ['malformed syntax', 'box:'], - ['pseudo-element', 'box::before'], - ['unsupported traversal', 'box < input'], - ['unsupported pseudo', 'box:contains(x)'], - ]; - for (const [name, source] of cases) { - test(`${name} is rejected before evaluation`, () => { - expect(() => session.locator(source)).toThrowError( - expect.objectContaining({ - code: expect.stringMatching(/^GW_CLACK_SELECTOR_(LIMIT|INVALID)$/), - }), - ); - }); - } -}); - -describe('revision replacement (REQ-016)', () => { - test('locators resolve against the newest accepted frame', () => { - const extension = clackTtyExtension(); - let sequence = 0; - const context: ExtensionSessionContext = { - terminal: {} as ExtensionSessionContext['terminal'], - screen: {} as ExtensionSessionContext['screen'], - publish(commit): ExtensionRevision { - return { - sequence: ++sequence, - timestamp: 0, - extensionId: 'test', - protocolFrame: commit.protocolFrame, - screenSequence: 0, - value: commit.value, - }; - }, - diagnostic() {}, - }; - const session = extension.createSession(context); - extension.accept(session, demoFrame, context); - const lazy = session.locator('[focused]'); - expect(lazy.matches().map((match) => match.key)).toEqual(['5']); - - const next: ClackFrameV1 = { - ...demoFrame, - frame: 2, - focusStack: ['6'], - nodes: demoFrame.nodes.map((node) => - node.key === '6' - ? { ...node, states: { focused: true, focusRoot: true } } - : node.key === '5' - ? { ...node, states: { focused: false, focusRoot: false } } - : node, - ), - }; - extension.accept(session, next, context); - expect(lazy.matches().map((match) => match.key)).toEqual(['6']); - }); + ); }); +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..0b5fe7a 100644 --- a/packages/clack-tty/test/protocol.test.ts +++ b/packages/clack-tty/test/protocol.test.ts @@ -1,245 +1,113 @@ -import { describe, expect, test } from 'vitest'; +import { expect, test } from 'vitest'; import { decodeFrame, encodeFrame, geometryFor, - LIMITS, - type ClackFrameV1, - type ClackNodeV1, + validateFrame, + type ClackFrame, } from '../src/protocol.ts'; -import { clackTtyExtension, ClackTtySession } from '../src/extension.ts'; -import type { - ExtensionSessionContext, - ExtensionRevision, - GhostwrightError, -} from 'ghostwright'; -function node(overrides: Partial = {}): ClackNodeV1 { - return { - key: '1', - name: 'box', - parent: null, - order: 0, - attrs: { focusable: false }, - states: { focused: false, focusRoot: false }, - ...overrides, - }; -} - -function frame(overrides: Partial = {}, nodes: ClackNodeV1[] = [node()]): ClackFrameV1 { - return { - v: 1, - frame: 1, - surface: { columns: 80, rows: 24, row: 1 }, - focusStack: [], - nodes, - ...overrides, - }; -} - -/** Minimal recording extension context: the real accept path, recorded revisions. */ -function recordingContext() { - const revisions: ExtensionRevision[] = []; - const diagnostics: GhostwrightError[] = []; - let sequence = 0; - const context: ExtensionSessionContext = { - terminal: {} as ExtensionSessionContext['terminal'], - screen: {} as ExtensionSessionContext['screen'], - publish(commit) { - const revision: ExtensionRevision = { - sequence: ++sequence, - timestamp: 0, - extensionId: 'test', - protocolFrame: commit.protocolFrame, - screenSequence: 0, - value: commit.value, - }; - revisions.push(revision); - return revision; - }, - diagnostic(error) { - diagnostics.push(error); +const frame = (): ClackFrame => ({ + v: 1, + frame: 1, + surface: { columns: 80, rows: 24, row: 1 }, + nodes: [ + { + 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 }, + }, }, - }; - return { context, revisions, diagnostics }; -} - -/** Extract the payload section from an encoded envelope, as the OSC stream would. */ -function payloadOf(bytes: Uint8Array): Uint8Array { - const raw = Buffer.from(bytes).toString('latin1'); - const start = raw.indexOf(';v=1;') + 5; - return Buffer.from(raw.slice(start, raw.length - 2), 'latin1'); -} - -describe('protocol codec (TC-U1)', () => { - test('encode/decode round-trips a full tree deterministically', () => { - const tree = frame( - { frame: 7, focusStack: ['3'] }, - [ - node({ - key: '1', - name: 'box', - attrs: { role: 'group', label: 'hello', focusable: false, custom: { 'x': 1 } }, - geo: { - layout: { x: 0, y: 0, width: 40.5, height: 8 }, - term: { column: 0, row: 0, width: 40, height: 8 }, - visible: { column: 0, row: 0, width: 40, height: 8 }, - }, - }), - node({ key: '2', name: 'text', parent: '1', order: 0 }), - node({ - key: '3', - name: 'input', - parent: '1', - order: 1, - attrs: { role: 'textbox', label: 'say', input: true, focusable: true }, - states: { focused: true, focusRoot: true }, - }), - ], - ); - const bytes = encodeFrame(tree); - expect(decodeFrame(payloadOf(bytes))).toStrictEqual(tree); - expect(encodeFrame(decodeFrame(payloadOf(bytes)))).toStrictEqual(bytes); - }); + ], +}); - test('envelope is the registered OSC 7777;clack.ui;v=1 with ST terminator', () => { - const bytes = Buffer.from(encodeFrame(frame())); - expect(bytes.subarray(0, 20).toString('latin1')).toBe('\u001b]7777;clack.ui;v=1;'); - expect(bytes.subarray(bytes.length - 2).toString('latin1')).toBe('\u001b\\'); - }); +test('identity and geometry round-trip without application focus/value state', () => { + 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(), + ); + expect(JSON.stringify(frame())).not.toMatch(/focused|focusStack|caret|value/); }); -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 }), +for (const [name, mutate] of [ + [ + 'version', + (f: any) => { + f.v = 9; }, - { 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}` }))), + ], + [ + 'frame number', + (f: any) => { + f.frame = 0; }, - { - name: 'non-scalar custom attribute', - code: 'GW_CLACK_SCHEMA', - frame: () => frame({}, [node({ attrs: { focusable: false, custom: { x: { deep: true } } } })]), + ], + [ + 'missing parent', + (f: any) => { + f.nodes[0].parent = 'absent'; }, - { - name: 'missing focusable attribute', - code: 'GW_CLACK_SCHEMA', - frame: () => frame({}, [node({ attrs: {} as ClackNodeV1['attrs'] })]), + ], + [ + 'parent cycle', + (f: any) => { + f.nodes[0].parent = 'name'; }, - { - name: 'missing states', - code: 'GW_CLACK_SCHEMA', - frame: () => frame({}, [node({ states: undefined as unknown as ClackNodeV1['states'] })]), + ], + [ + 'duplicate key', + (f: any) => { + f.nodes.push(f.nodes[0]); }, - { - 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 } }, - })]), + ], + [ + 'negative size', + (f: any) => { + f.nodes[0].geo.term.width = -1; }, - { - 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 } }, - })]), + ], + [ + 'fractional cell', + (f: any) => { + f.nodes[0].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', + (f: any) => { + f.nodes[0].attrs.label = 'x'.repeat(1025); + }, + ], +] as const) + test(`rejects ${name}`, () => { + const value = frame(); + mutate(value); + 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/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/hello-world/test/hello-world.test.ts b/packages/hello-world/test/hello-world.test.ts index 89e951c..65dcccf 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'; - -// 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(); +import { test } from 'vitest'; +import { withTerminalAsync } from 'ghostwright'; +import { clackTtyExtension, expectUI, locator } from '@ghostwright/clack-tty'; const entry = () => ({ command: process.execPath, args: ['--import', 'tsx', 'src/hello-world.ts'], cwd: new URL('..', import.meta.url).pathname, - viewport: { columns: 80, rows: 24 }, env: { CLACK_UI_SEMANTIC: '1' }, - trace: 'off' as const, - extensions: [extension], -}); - -test('the greeting renders and the semantic tree exposes it (selector syntax)', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const semantic = terminal.extension(extension) as ClackTtySession; - - // The group box is in the tree before we ever look at the screen. - const group = semantic.locator('box[role="group"][label="hello"]'); - await expectTreeCondition(terminal, () => group.matches().length === 1, 'group present'); - - // The bridge: text assertions scoped to the group's on-screen rect. - await expectTerminal(group.getByText('Hello, World!')).toBeStable(); - }); -}); - -test('typing into the say input updates the greeting (region-scoped)', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const semantic = terminal.extension(extension) as ClackTtySession; - const group = semantic.locator('box[role="group"][label="hello"]'); - const say = semantic.locator('input[label="say"]'); - - await expectTerminal(terminal.getByText('Hello, World!')).toBeStable(); - await expectFocused(terminal, say); - - await terminal.keyboard.type('Hi'); - - // The greeting text element and the input's own model both updated. - await expectTerminal(group.getByText('Hi, World!')).toBeStable(); - await expectTerminal(say.getByText('Hi')).toBePresent(); - }); -}); - -test('Tab moves focus and the focused input paints its focus ring', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const semantic = terminal.extension(extension) as ClackTtySession; - const say = semantic.locator('input[label="say"]'); - const to = semantic.locator('input[label="to"]'); - - await expectFocused(terminal, say); - - // Focus is visual: the focused input draws white, the other gray. - const foregroundOf = (locator: typeof say) => { - const [match] = locator.matches(); - const cells = terminal.screen.getCells(match!.range!); - const focused = cells.some((cell) => cellsMatchStyle([cell], { foreground: '#ffffff' })); - const gray = cells.some((cell) => cellsMatchStyle([cell], { foreground: '#646464' })); - return { focused, gray }; - }; - expect(foregroundOf(say)).toEqual({ focused: true, gray: false }); - expect(foregroundOf(to)).toEqual({ focused: false, gray: true }); - - await terminal.keyboard.press('Tab'); - await expectFocused(terminal, to); - expect(foregroundOf(to)).toEqual({ focused: true, gray: false }); - expect(foregroundOf(say)).toEqual({ focused: false, gray: true }); - }); + 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 withTerminalAsync(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/pizza-preact/test/pizza-preact.test.ts b/packages/pizza-preact/test/pizza-preact.test.ts index 7179f86..7782bc1 100644 --- a/packages/pizza-preact/test/pizza-preact.test.ts +++ b/packages/pizza-preact/test/pizza-preact.test.ts @@ -1,148 +1,89 @@ -import { expect, test } from 'vitest'; -import { expectTerminal, withTerminalAsync } from 'ghostwright'; -import { - clackTtyExtension, - expectFocused, - expectTreeCondition, - type ClackTtySession, -} from '@ghostwright/clack-tty'; - -// The Preact application is a process-level black box. This test drives the -// real terminal and observes only its visible screen and semantic tree. -const extension = clackTtyExtension(); - -const entry = () => ({ - 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' }, - trace: 'off' as const, - extensions: [extension], +import { test } from 'vitest'; +import { withTerminalAsync } from 'ghostwright'; +import { clackTtyExtension, expectUI, locator } from '@ghostwright/clack-tty'; + +// Launch the Preact app in a real terminal. Locators find the controls; +// assertions check the text, borders, and cursor drawn on that terminal. +const pizza = () => ({ + 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()], }); -type Terminal = Parameters[1]>[0]; - -function semantic(terminal: Terminal) { - return terminal.extension(extension) as ClackTtySession; -} - -async function tabTo(terminal: Terminal, session: ClackTtySession, expectedLabel: string) { - const previousLabel = session.locator('[focused]').matches()[0]?.attrs.label; - for (let attempt = 0; attempt < 3; attempt++) { - await terminal.keyboard.press('Tab'); - try { - await expectTreeCondition( - terminal, - () => session.locator('[focused]').matches()[0]?.attrs.label !== previousLabel, - `focus leaves ${previousLabel}`, - 1200, - ); - } catch { - if (attempt < 2) continue; - throw new Error(`focus did not leave ${previousLabel}`); - } - - const actualLabel = session.locator('[focused]').matches()[0]?.attrs.label; - expect(actualLabel).toBe(expectedLabel); - return; - } -} - -test('Preact pizza completes both forms and restores the delivery tab order', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const session = semantic(terminal); - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); - - const name = session.locator('input[label="name"]'); - const address = session.locator('input[label="address"]'); - const addCard = session.locator('button[label="add-card"]'); - const cardNumber = session.locator('input[label="card-number"]'); - const expiry = session.locator('input[label="expiry"]'); - const cvc = session.locator('input[label="cvc"]'); - const submitCard = session.locator('button[label="submit-card"]'); - const dialog = session.locator('dialog[role="dialog"][label="card"]'); - - await expectFocused(terminal, name); - await terminal.keyboard.type('Ryan'); - await expectTerminal(name.getByText('Ryan')).toBePresent(); - await tabTo(terminal, session, 'address'); - await terminal.keyboard.type('1 Main St'); - await expectTerminal(address.getByText('1 Main St')).toBePresent(); - await tabTo(terminal, session, 'add-card'); - await expectFocused(terminal, addCard); - await terminal.keyboard.press('Enter'); - - await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens'); - await expectFocused(terminal, cardNumber); - await tabTo(terminal, session, 'expiry'); - await expectFocused(terminal, expiry); - await tabTo(terminal, session, 'cvc'); - await expectFocused(terminal, cvc); - await tabTo(terminal, session, 'submit-card'); - await expectFocused(terminal, submitCard); - await terminal.keyboard.press('Enter'); - - await expectTreeCondition(terminal, () => dialog.matches().length === 0, 'dialog closes'); - await expectFocused(terminal, addCard); - await expectTerminal(name.getByText('Ryan')).toBePresent(); - await expectTerminal(address.getByText('1 Main St')).toBePresent(); - await tabTo(terminal, session, 'name'); - await expectFocused(terminal, name); - }); +const delivery = locator('form[label="delivery"]'); +const name = locator('input[label="name"]'); +const address = locator('input[label="address"]'); +const addCard = locator('button[label="add-card"]'); +const cardDetails = locator('dialog[label="card"]'); +const cardNumber = locator('input[label="card-number"]'); +const expiry = locator('input[label="expiry"]'); +const cvc = locator('input[label="cvc"]'); +const submitCard = locator('button[label="submit-card"]'); + +test('return from card details without losing the delivery address', async () => { + await withTerminalAsync(pizza(), async (ui) => { + // Tell the shop who we are and where to deliver. + await ui.expect(delivery).toContainText('Pizza Delivery'); + await expectUI(ui, name).toHaveInputFocus(); + await ui.keyboard.type('Ryan'); + await ui.expect(name).toContainText('Ryan'); + + await ui.keyboard.press('Tab'); + await expectUI(ui, address).toHaveInputFocus(); + await ui.keyboard.type('1 Main St'); + await ui.expect(address).toContainText('1 Main St'); + + // Open the card form with the keyboard. Focus moves into the dialog. + await ui.keyboard.press('Tab'); + await expectUI(ui, addCard).toHaveButtonFocus('Add card'); + await ui.keyboard.press('Enter'); + await ui.expect(cardDetails).toContainText('Card Details'); + + await expectUI(ui, cardNumber).toHaveInputFocus(); + await ui.keyboard.type('4242'); + await ui.expect(cardNumber).toContainText('4242'); + + await ui.keyboard.press('Tab'); + await expectUI(ui, expiry).toHaveInputFocus(); + await ui.keyboard.type('12/30'); + await ui.expect(expiry).toContainText('12/30'); + + await ui.keyboard.press('Tab'); + await expectUI(ui, cvc).toHaveInputFocus(); + await ui.keyboard.type('123'); + await ui.expect(cvc).toContainText('123'); + + // Submit the form. We return to the opener, with our delivery details intact. + await ui.keyboard.press('Tab'); + await expectUI(ui, submitCard).toHaveButtonFocus('Submit card'); + await ui.keyboard.press('Enter'); + await expectUI(ui, addCard).toHaveButtonFocus('Add card'); + await ui.expect(name).toContainText('Ryan'); + await ui.expect(address).toContainText('1 Main St'); + + await ui.keyboard.press('Tab'); + await expectUI(ui, name).toHaveInputFocus(); + }); }); -test('focused inputs show a native cursor that follows the caret', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const session = semantic(terminal); - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); - - const name = session.locator('input[label="name"]'); - const address = session.locator('input[label="address"]'); - const addCard = session.locator('button[label="add-card"]'); - const cursorIsInside = (selector: string) => { - const cursor = terminal.screen.snapshot().cursor; - const rect = session.locator(selector).matches()[0]?.geo?.term; - return ( - cursor.visible && - rect !== undefined && - cursor.column > rect.column && - cursor.column < rect.column + rect.width - 1 && - cursor.row > rect.row && - cursor.row < rect.row + rect.height - 1 - ); - }; - - await expectFocused(terminal, name); - const initial = await expectTerminal(terminal).toSatisfy( - () => cursorIsInside('input[label="name"]'), - { settleMs: 100 }, - ); - - await terminal.keyboard.type('cat'); - const typed = await expectTerminal(terminal).toSatisfy( - () => terminal.screen.snapshot().cursor.column === initial.cursor.column + 3, - { settleMs: 100 }, - ); - - await terminal.keyboard.press('ArrowLeft'); - await expectTerminal(terminal).toSatisfy( - () => terminal.screen.snapshot().cursor.column === typed.cursor.column - 1, - { settleMs: 100 }, - ); - - await terminal.keyboard.press('Tab'); - await expectFocused(terminal, address); - await expectTerminal(terminal).toSatisfy( - () => cursorIsInside('input[label="address"]'), - { settleMs: 100 }, - ); - - await terminal.keyboard.press('Tab'); - await expectFocused(terminal, addCard); - await expectTerminal(terminal).toSatisfy( - () => !terminal.screen.snapshot().cursor.visible, - { settleMs: 100 }, - ); - }); +test('correct a typo before moving to the address', async () => { + await withTerminalAsync(pizza(), async (ui) => { + await expectUI(ui, name).toHaveInputFocus(); + await ui.keyboard.type('Ryn'); + await ui.expect(name).toContainText('Ryn'); + + // Move before the final letter and insert the missing "a". + await ui.keyboard.press('ArrowLeft'); + await ui.keyboard.type('a'); + await ui.expect(name).toContainText('Ryan'); + await ui.expect(name).toContainCursor({ visible: true }); + + // Tab changes focus, not the name we just corrected. + await ui.keyboard.press('Tab'); + await expectUI(ui, address).toHaveInputFocus(); + await ui.expect(name).toContainText('Ryan'); + }); }); diff --git a/packages/pizza/test/pizza.test.ts b/packages/pizza/test/pizza.test.ts index 0309498..39c097f 100644 --- a/packages/pizza/test/pizza.test.ts +++ b/packages/pizza/test/pizza.test.ts @@ -1,282 +1,123 @@ import { expect, test } from 'vitest'; -import { expectTerminal, withTerminalAsync } from 'ghostwright'; -import { - clackTtyExtension, - expectFocused, - expectTreeCondition, - type ClackTtySession, -} from '@ghostwright/clack-tty'; +import { withTerminalAsync, settled } from 'ghostwright'; +import { clackTtyExtension, expectUI, locator } from '@ghostwright/clack-tty'; -// Outside-in acceptance suite: the pizza application is a black box. The tests -// drive it through the real terminal (ghostwright PTY) and observe only the -// visible screen and the semantic tree it emits. No implementation knowledge. -const extension = clackTtyExtension(); - -const entry = () => ({ +// No application internals: launch the CLI, use its keyboard, and check what +// appears in the terminal. Locators give those visible controls useful names. +const pizza = () => ({ 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' }, - trace: 'off' as const, - extensions: [extension], + extensions: [clackTtyExtension()], }); -type Terminal = Parameters[1]>[0]; - -function semantic(terminal: Terminal) { - return terminal.extension(extension) as ClackTtySession; -} - -async function tabTo(terminal: Terminal, session: ClackTtySession, expectedLabel: string) { - const previousLabel = session.locator('[focused]').matches()[0]?.attrs.label; - for (let attempt = 0; attempt < 3; attempt++) { - await terminal.keyboard.press('Tab'); - try { - await expectTreeCondition( - terminal, - () => session.locator('[focused]').matches()[0]?.attrs.label !== previousLabel, - `focus leaves ${previousLabel}`, - 1200, - ); - } catch { - if (attempt < 2) continue; - throw new Error(`focus did not leave ${previousLabel}`); - } - - const actualLabel = session.locator('[focused]').matches()[0]?.attrs.label; - expect(actualLabel).toBe(expectedLabel); - return; - } -} - -test('renders the delivery form and focuses the first field', async () => { - await withTerminalAsync(entry(), async (terminal) => { - // visible screen - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); - - // semantic tree: a delivery form with name and address fields - const form = semantic(terminal).locator('form[label="delivery"]'); - await expectTreeCondition(terminal, () => form.matches().length === 1, 'form in tree'); - expect(semantic(terminal).locator('input[label="name"]').matches()).toHaveLength(1); - expect(semantic(terminal).locator('input[label="address"]').matches()).toHaveLength(1); - - // focus starts on the first field - await expectFocused(terminal, semantic(terminal).locator('input[label="name"]')); +const delivery = locator('form[label="delivery"]'); +const name = locator('input[label="name"]'); +const address = locator('input[label="address"]'); +const addCard = locator('button[label="add-card"]'); +const cardDetails = locator('dialog[label="card"]'); +const cardNumber = locator('input[label="card-number"]'); +const expiry = locator('input[label="expiry"]'); +const cvc = locator('input[label="cvc"]'); +const submitCard = locator('button[label="submit-card"]'); + +test('tell the pizza shop where to deliver', async () => { + await withTerminalAsync(pizza(), async (ui) => { + await ui.expect(delivery).toContainText('Pizza Delivery'); + + // The name field is ready to type into as soon as the form opens. + await expectUI(ui, name).toHaveInputFocus(); + await ui.keyboard.type('Ryan'); + await ui.expect(name).toContainText('Ryan'); + + // Continue with the keyboard. Assertions wait for the visible result. + await ui.keyboard.press('Tab'); + await expectUI(ui, address).toHaveInputFocus(); + await ui.keyboard.type('1 Main St'); + await ui.expect(address).toContainText('1 Main St'); + await ui.expect(name).toContainText('Ryan'); }); }); -test('reflows forms when the terminal resizes', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const session = semantic(terminal); - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); - - await terminal.resize({ columns: 36, rows: 20 }); - - const fitsSurface = (selector: string) => { - const frame = session.current(); - const geometry = session.locator(selector).matches()[0]?.geo; - const term = geometry?.term; - const visible = geometry?.visible; - return ( - frame?.surface.columns === 36 && - frame.surface.rows === 20 && - term !== undefined && - visible !== undefined && - term.column >= 0 && - term.row >= 0 && - term.column + term.width <= frame.surface.columns && - term.row + term.height <= frame.surface.rows && - visible.column === term.column && - visible.row === term.row && - visible.width === term.width && - visible.height === term.height - ); - }; - - await expectTreeCondition( - terminal, - () => fitsSurface('form[label="delivery"]'), - 'delivery form fits resized surface', - ); - - await terminal.keyboard.press('Enter'); - await expectTreeCondition( - terminal, - () => fitsSurface('dialog[role="dialog"][label="card"]'), - 'card dialog fits resized surface', - ); +test('keep keyboard navigation inside card details until the form is submitted', async () => { + await withTerminalAsync(pizza(), async (ui) => { + // Reach "Add card" from the delivery form. + await expectUI(ui, name).toHaveInputFocus(); + await ui.keyboard.press('Tab'); + await expectUI(ui, address).toHaveInputFocus(); + await ui.keyboard.press('Tab'); + await expectUI(ui, addCard).toHaveButtonFocus('Add card'); + await ui.keyboard.press('Enter'); + await ui.expect(cardDetails).toContainText('Card Details'); + + // Tab visits each card field in order. + await expectUI(ui, cardNumber).toHaveInputFocus(); + await ui.keyboard.press('Tab'); + await expectUI(ui, expiry).toHaveInputFocus(); + await ui.keyboard.press('Tab'); + await expectUI(ui, cvc).toHaveInputFocus(); + await ui.keyboard.press('Tab'); + await expectUI(ui, submitCard).toHaveButtonFocus('Submit card'); + + // Neither direction lets focus escape into the form behind the dialog. + await ui.keyboard.press('Tab'); + await expectUI(ui, cardNumber).toHaveInputFocus(); + await ui.keyboard.press('Shift+Tab'); + await expectUI(ui, submitCard).toHaveButtonFocus('Submit card'); + + // Closing the dialog returns us to the button that opened it. + await ui.keyboard.press('Enter'); + await expectUI(ui, addCard).toHaveButtonFocus('Add card'); + await ui.keyboard.press('Tab'); + await expectUI(ui, name).toHaveInputFocus(); }); }); -test('Tab cycles the delivery fields and wraps', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const session = semantic(terminal); - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); - const name = session.locator('input[label="name"]'); - const address = session.locator('input[label="address"]'); +test('keep the delivery form usable in a narrow terminal', async () => { + await withTerminalAsync(pizza(), async (ui) => { + await expectUI(ui, name).toHaveInputFocus(); - const addCard = session.locator('button[label="add-card"]'); - await expectFocused(terminal, name); - await terminal.keyboard.press('Tab'); - await expectFocused(terminal, address); - await terminal.keyboard.press('Tab'); - await expectFocused(terminal, addCard); - // the dialog is closed, so the cycle wraps back to the first field - await terminal.keyboard.press('Tab'); - await expectFocused(terminal, name); - }); -}); - -test('typing updates the field value on screen and in the tree', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const session = semantic(terminal); - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); - const name = session.locator('input[label="name"]'); - const address = session.locator('input[label="address"]'); - - await terminal.keyboard.type('Ryan'); - await expectTerminal(name.getByText('Ryan')).toBePresent(); - - await terminal.keyboard.press('Tab'); - await terminal.keyboard.type('1 Main St'); - await expectTerminal(address.getByText('1 Main St')).toBePresent(); - - // the greeting-style header is untouched - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); - }); -}); - -test('Enter opens the card dialog and focuses the card number', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const session = semantic(terminal); - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); + // Record the resize until this form settles, rather than sleeping and hoping. + const recording = await ui.capture({ until: settled(delivery, 50) }, async (capture) => { + await capture.resize({ columns: 36, rows: 20 }); + }); - await terminal.keyboard.press('Enter'); - - const dialog = session.locator('dialog[role="dialog"][label="card"]'); - await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens'); - expect(session.locator('input[label="card-number"]').matches()).toHaveLength(1); - expect(session.locator('input[label="expiry"]').matches()).toHaveLength(1); - expect(session.locator('input[label="cvc"]').matches()).toHaveLength(1); - await expectFocused(terminal, session.locator('input[label="card-number"]')); - - // the delivery form stays mounted with its values - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); - }); -}); - -test('the card journey: type through the dialog fields', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const session = semantic(terminal); - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); - await terminal.keyboard.press('Enter'); - const dialog = session.locator('dialog[role="dialog"][label="card"]'); - await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens'); - - const cardNumber = session.locator('input[label="card-number"]'); - const expiry = session.locator('input[label="expiry"]'); - const cvc = session.locator('input[label="cvc"]'); - - await terminal.keyboard.type('4111111'); - await expectTerminal(cardNumber.getByText('4111111')).toBePresent(); - - await terminal.keyboard.press('Tab'); - await expectFocused(terminal, expiry); - await terminal.keyboard.type('12/26'); - await expectTerminal(expiry.getByText('12/26')).toBePresent(); - - await terminal.keyboard.press('Tab'); - await expectFocused(terminal, cvc); - await terminal.keyboard.type('123'); - await expectTerminal(cvc.getByText('123')).toBePresent(); - }); -}); - -test('Enter closes the dialog, keeps form values, and restores focus', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const session = semantic(terminal); - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); - const name = session.locator('input[label="name"]'); - const dialog = session.locator('dialog[role="dialog"][label="card"]'); - - // build state: name typed, dialog opened - await terminal.keyboard.type('Ryan'); - await expectTerminal(name.getByText('Ryan')).toBePresent(); - await terminal.keyboard.press('Enter'); - await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens'); - - // close: Enter on a focused card field - await terminal.keyboard.press('Enter'); - await expectTreeCondition(terminal, () => dialog.matches().length === 0, 'dialog closes'); - expect(session.locator('input[label="card-number"]').matches()).toHaveLength(0); - - // form values survive the dialog round trip - await expectTerminal(name.getByText('Ryan')).toBePresent(); - - // focus returns to the control that opened the modal - await expectFocused(terminal, name); - }); -}); - -test('button submission restores the delivery tab order', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const session = semantic(terminal); - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); - - const name = session.locator('input[label="name"]'); - const address = session.locator('input[label="address"]'); - const addCard = session.locator('button[label="add-card"]'); - const cardNumber = session.locator('input[label="card-number"]'); - const expiry = session.locator('input[label="expiry"]'); - const cvc = session.locator('input[label="cvc"]'); - const submitCard = session.locator('button[label="submit-card"]'); - const dialog = session.locator('dialog[role="dialog"][label="card"]'); - - await expectFocused(terminal, name); - await tabTo(terminal, session, 'address'); - await expectFocused(terminal, address); - await tabTo(terminal, session, 'add-card'); - await expectFocused(terminal, addCard); - await terminal.keyboard.press('Enter'); - - await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens'); - await expectFocused(terminal, cardNumber); - await tabTo(terminal, session, 'expiry'); - await expectFocused(terminal, expiry); - await tabTo(terminal, session, 'cvc'); - await expectFocused(terminal, cvc); - await tabTo(terminal, session, 'submit-card'); - await expectFocused(terminal, submitCard); - await terminal.keyboard.press('Enter'); - - await expectTreeCondition(terminal, () => dialog.matches().length === 0, 'dialog closes'); - await expectFocused(terminal, addCard); - await tabTo(terminal, session, 'name'); - await expectFocused(terminal, name); + // Inspect the form as it was drawn in the recording, not the live screen. + const renderedForms = recording.observations.flatMap((observation) => + delivery.resolve(observation), + ); + const resizedForm = renderedForms.at(-1)!; + expect(resizedForm.screen.viewport.columns).toBe(36); + expect(resizedForm.visibleBounds).toEqual(resizedForm.bounds); + expect(resizedForm.text()).toContain('Pizza Delivery'); + + // The smaller window still lets us continue to card details. + await ui.keyboard.press('Enter'); + await ui.expect(cardDetails).toContainText('Card Details'); + await expectUI(ui, cardNumber).toHaveInputFocus(); }); }); -test('with the dialog open, Tab is contained by the modal', async () => { - await withTerminalAsync(entry(), async (terminal) => { - const session = semantic(terminal); - await expectTerminal(terminal.getByText('Pizza Delivery')).toBeStable(); - const dialog = session.locator('dialog[role="dialog"][label="card"]'); - const order = ['expiry', 'cvc', 'submit-card', 'card-number']; - - await terminal.keyboard.press('Enter'); - await expectTreeCondition(terminal, () => dialog.matches().length === 1, 'dialog opens'); - - // the app focuses card-number when the dialog opens; walk the full cycle - const labels: (string | undefined)[] = []; - await expectFocused(terminal, session.locator('input[label="card-number"]')); - for (const label of order) { - await tabTo(terminal, session, label); - labels.push(session.locator('[focused]').matches()[0]?.attrs.label); - } - expect(labels).toEqual(order); - - await terminal.keyboard.press('Shift+Tab'); - await expectFocused(terminal, session.locator('button[label="submit-card"]')); +test('edit the name with the cursor, then continue to the next control', async () => { + await withTerminalAsync(pizza(), async (ui) => { + await expectUI(ui, name).toHaveInputFocus(); + await ui.keyboard.type('Ryn'); + await ui.expect(name).toContainText('Ryn'); + + // Correct the typo in place, just as a person would. + await ui.keyboard.press('ArrowLeft'); + await ui.keyboard.type('a'); + await ui.expect(name).toContainText('Ryan'); + await ui.expect(name).toContainCursor({ visible: true }); + + await ui.keyboard.press('Tab'); + await expectUI(ui, address).toHaveInputFocus(); + await ui.keyboard.press('Tab'); + const focusedButton = await expectUI(ui, addCard).toHaveButtonFocus('Add card'); + + // Buttons show focus, but not a text-entry cursor. + expect(focusedButton.screen.cursor.visible).toBe(false); }); }); From 763c0896d542d320fdd4e1aff40a4c119c39d929 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Sun, 6 Sep 2026 08:24:17 -0400 Subject: [PATCH 3/4] Add screen-derived and DOM-scoped locators with a Vim netrw spike --- .../ghostwright/docs/scoped-execution.md | 2 + experiments/ghostwright/examples/README.md | 6 +- .../ghostwright/examples/vim-netrw/README.md | 64 ++++++ .../examples/vim-netrw/netrw.test.ts | 110 +++++++++ .../ghostwright/examples/vim-netrw/netrw.ts | 163 ++++++++++++++ .../examples/vim-netrw/recognition.test.ts | 97 ++++++++ experiments/ghostwright/package.json | 3 + experiments/ghostwright/src/locators.ts | 20 +- .../ghostwright/test/child-locator.test.ts | 140 ++++++++++++ .../ghostwright/test/screen-locator.test.ts | 72 ++++++ experiments/ghostwright/type-tests/api.ts | 13 ++ packages/clack-tty/src/extension.ts | 74 ++++-- packages/clack-tty/src/index.ts | 2 +- .../clack-tty/test/scoped-locator.test.ts | 210 ++++++++++++++++++ .../pizza-preact/test/pizza-preact.test.ts | 14 +- packages/pizza/test/pizza.test.ts | 14 +- pnpm-lock.yaml | 18 ++ 17 files changed, 988 insertions(+), 34 deletions(-) create mode 100644 experiments/ghostwright/examples/vim-netrw/README.md create mode 100644 experiments/ghostwright/examples/vim-netrw/netrw.test.ts create mode 100644 experiments/ghostwright/examples/vim-netrw/netrw.ts create mode 100644 experiments/ghostwright/examples/vim-netrw/recognition.test.ts create mode 100644 experiments/ghostwright/test/child-locator.test.ts create mode 100644 experiments/ghostwright/test/screen-locator.test.ts create mode 100644 packages/clack-tty/test/scoped-locator.test.ts diff --git a/experiments/ghostwright/docs/scoped-execution.md b/experiments/ghostwright/docs/scoped-execution.md index bee1d4c..22ec52a 100644 --- a/experiments/ghostwright/docs/scoped-execution.md +++ b/experiments/ghostwright/docs/scoped-execution.md @@ -8,6 +8,8 @@ The output pipeline publishes descriptions with the immutable screen that preced `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 diff --git a/experiments/ghostwright/examples/README.md b/experiments/ghostwright/examples/README.md index 2ce650f..387972d 100644 --- a/experiments/ghostwright/examples/README.md +++ b/experiments/ghostwright/examples/README.md @@ -9,12 +9,14 @@ Both example suites automate the same interactive CLI so the two public API styl - [`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/vim-netrw/README.md b/experiments/ghostwright/examples/vim-netrw/README.md new file mode 100644 index 0000000..0715ebf --- /dev/null +++ b/experiments/ghostwright/examples/vim-netrw/README.md @@ -0,0 +1,64 @@ +# 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 fixture disables user configuration, swap files, viminfo, and netrw history. It explicitly loads the bundled netrw and uses standard display options. 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. + +## 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 + +The only new core primitive is: + +```ts +const locator = defineScreenLocator('description', (screen) => { + // Pure spatial reasoning over 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, and refusal to navigate from the wrong window. Focused recognition tests use grids decoded by real Ghostty to check banner scoping, duplicate matches, missing boundaries, and ambiguity. 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..002ccdb --- /dev/null +++ b/experiments/ghostwright/examples/vim-netrw/netrw.test.ts @@ -0,0 +1,110 @@ +import { expect, test } from 'bun:test'; +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 { withTerminalAsync, type AsyncExecution, type Viewport } from '../../src/index.ts'; +import { netrw } from './netrw.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({ 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({ 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' }); + }); +}); + +/** A real, isolated Vim with its bundled netrw. No application instrumentation. */ +async function withVim(viewport: Viewport, body: (ui: AsyncExecution) => 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', + ); + await withTerminalAsync( + { + 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', + '-c', + 'Vexplore .', + 'WELCOME.txt', + ], + cwd: directory, + env: { HOME: directory, EXINIT: '', VIMINIT: '', LC_ALL: 'C' }, + viewport, + trace: 'off', + }, + body, + ); + } finally { + await rm(directory, { recursive: true, force: true }); + } +} diff --git a/experiments/ghostwright/examples/vim-netrw/netrw.ts b/experiments/ghostwright/examples/vim-netrw/netrw.ts new file mode 100644 index 0000000..ebdcee0 --- /dev/null +++ b/experiments/ghostwright/examples/vim-netrw/netrw.ts @@ -0,0 +1,163 @@ +import { + defineScreenLocator, + GhostwrightError, + inspect, + type AsyncExecution, + type Rect, + type RegionInspection, + 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) => { + const candidate = screen.lines[row]?.cells[cell.column]; + return ( + 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((cell) => cell.style.inverse && !cell.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) { + 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) { + 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) { + 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..064d655 --- /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(screen: ScreenSnapshot): Observation { + return { kind: 'screen', screen, 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/package.json b/experiments/ghostwright/package.json index 05bc0d9..5a0b27a 100644 --- a/experiments/ghostwright/package.json +++ b/experiments/ghostwright/package.json @@ -51,6 +51,9 @@ "dependencies": { "effection": "^4.0.2" }, + "devDependencies": { + "@types/bun": "^1.3.9" + }, "engines": { "bun": ">=1.2.0", "deno": ">=2.2.0", diff --git a/experiments/ghostwright/src/locators.ts b/experiments/ghostwright/src/locators.ts index 931189c..0641cb7 100644 --- a/experiments/ghostwright/src/locators.ts +++ b/experiments/ghostwright/src/locators.ts @@ -1,7 +1,7 @@ import { GhostwrightError, InvalidOptionsError } from './errors.ts'; import { RegionInspection } from './inspection.ts'; import type { Observation } from './observations.ts'; -import type { Rect } from './types.ts'; +import type { Rect, ScreenSnapshot } from './types.ts'; import type { Matcher } from './matchers.ts'; import type { Condition } from './conditions.ts'; @@ -12,6 +12,9 @@ export interface RegionLocator { 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 @@ -42,6 +45,11 @@ function query( 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: () => ({ @@ -71,8 +79,16 @@ export function defineLocator( 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 query(JSON.stringify(copy), undefined, () => [copy]); + return defineScreenLocator(JSON.stringify(copy), () => [copy]); } diff --git a/experiments/ghostwright/test/child-locator.test.ts b/experiments/ghostwright/test/child-locator.test.ts new file mode 100644 index 0000000..9650c98 --- /dev/null +++ b/experiments/ghostwright/test/child-locator.test.ts @@ -0,0 +1,140 @@ +import { expect, test } from 'bun:test'; +import { + defineLocator, + defineScreenLocator, + textContains, + withTerminalAsync, + 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 withTerminalAsync( + { + 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/screen-locator.test.ts b/experiments/ghostwright/test/screen-locator.test.ts new file mode 100644 index 0000000..6cdc914 --- /dev/null +++ b/experiments/ghostwright/test/screen-locator.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from 'bun:test'; +import { + defineScreenLocator, + type Observation, + type ScreenSnapshot, + type RegionInspection, +} 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) => [ + { ...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/type-tests/api.ts b/experiments/ghostwright/type-tests/api.ts index 3bd1136..556008c 100644 --- a/experiments/ghostwright/type-tests/api.ts +++ b/experiments/ghostwright/type-tests/api.ts @@ -2,6 +2,7 @@ import { createExpect, defineMatchers, defineLocator, + defineScreenLocator, textContains, type AsyncExecution, type RegionInspection, @@ -10,6 +11,12 @@ import { 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]); @@ -20,6 +27,12 @@ const expect = createExpect().extend( }, }), ); +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'); diff --git a/packages/clack-tty/src/extension.ts b/packages/clack-tty/src/extension.ts index 89a8089..65b852d 100644 --- a/packages/clack-tty/src/extension.ts +++ b/packages/clack-tty/src/extension.ts @@ -1,6 +1,12 @@ import { compile, type Options } from 'css-select'; import { AttributeAction, parse, SelectorType, type Selector } from 'css-what'; -import { defineLocator, GhostwrightError, type TerminalExtensionDefinition } from 'ghostwright'; +import { + defineLocator, + GhostwrightError, + InvalidOptionsError, + type RegionLocator, + type TerminalExtensionDefinition, +} from 'ghostwright'; import { CLACK_TTY_NAMESPACE, CLACK_TTY_OSC, @@ -146,21 +152,59 @@ function selector(source: string): Selector[][] { return ast; } -/** Construct a reusable, session-free query. Resolution never reads a live UI. */ -export function locator(source: string) { - const predicate = compile(selector(source), { adapter, xmlMode: true, cacheResults: false }); - return defineLocator(ID, source, (frame) => - materialize(frame) - .filter(predicate) - .map((node) => { - if (!node.geo) - return fail( - 'GW_CLACK_NO_GEOMETRY', - `${source}: ${node.key}/${node.name} has no geometry`, - ); - return node.geo.term; // Preserve original edges. Core inspection handles viewport clipping. - }), +/** 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; +} + +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), + ); + }, + 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; + }); + }); + }, + }); +} + +/** 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)); } /** Pure decoder shared by live sessions and replay. */ diff --git a/packages/clack-tty/src/index.ts b/packages/clack-tty/src/index.ts index cc8f104..edbfca5 100644 --- a/packages/clack-tty/src/index.ts +++ b/packages/clack-tty/src/index.ts @@ -1,3 +1,3 @@ -export { clackTtyExtension, locator } from './extension.ts'; +export { clackTtyExtension, locator, type ClackLocator } from './extension.ts'; export { useSemantic, type SemanticOptions } from './producer.ts'; export { clackMatchers, expectUI } from './expectations.ts'; 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..260aaea --- /dev/null +++ b/packages/clack-tty/test/scoped-locator.test.ts @@ -0,0 +1,210 @@ +import { expect, expectTypeOf, test } from 'vitest'; +import { textContains, withTerminalAsync, type Observation, type RegionLocator } from 'ghostwright'; +import { clackTtyExtension, locator, type ClackLocator } from '../src/index.ts'; +import { encodeFrame, type ClackFrame, type ClackNode } from '../src/protocol.ts'; + +const geo = ({ column, row, width }: { column: number; row: number; width: number }) => ({ + 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 = () => ({ + 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 withTerminalAsync(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 withTerminalAsync(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) => + 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/pizza-preact/test/pizza-preact.test.ts b/packages/pizza-preact/test/pizza-preact.test.ts index 7782bc1..06254f7 100644 --- a/packages/pizza-preact/test/pizza-preact.test.ts +++ b/packages/pizza-preact/test/pizza-preact.test.ts @@ -14,14 +14,14 @@ const pizza = () => ({ }); const delivery = locator('form[label="delivery"]'); -const name = locator('input[label="name"]'); -const address = locator('input[label="address"]'); -const addCard = locator('button[label="add-card"]'); +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 = locator('input[label="card-number"]'); -const expiry = locator('input[label="expiry"]'); -const cvc = locator('input[label="cvc"]'); -const submitCard = locator('button[label="submit-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 () => { await withTerminalAsync(pizza(), async (ui) => { diff --git a/packages/pizza/test/pizza.test.ts b/packages/pizza/test/pizza.test.ts index 39c097f..9b3226b 100644 --- a/packages/pizza/test/pizza.test.ts +++ b/packages/pizza/test/pizza.test.ts @@ -14,14 +14,14 @@ const pizza = () => ({ }); const delivery = locator('form[label="delivery"]'); -const name = locator('input[label="name"]'); -const address = locator('input[label="address"]'); -const addCard = locator('button[label="add-card"]'); +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 = locator('input[label="card-number"]'); -const expiry = locator('input[label="expiry"]'); -const cvc = locator('input[label="cvc"]'); -const submitCard = locator('button[label="submit-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 withTerminalAsync(pizza(), async (ui) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfda6ea..1e332ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -65,6 +65,10 @@ importers: effection: specifier: ^4.0.2 version: 4.0.3 + devDependencies: + '@types/bun': + specifier: ^1.3.9 + version: 1.4.1 packages/clack-tty: dependencies: @@ -1096,6 +1100,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/bun@1.4.1': + resolution: {integrity: sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1312,6 +1319,9 @@ packages: resolution: {integrity: sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==} engines: {node: '>=20.19.0'} + 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'} @@ -2496,6 +2506,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/bun@1.4.1': + dependencies: + bun-types: 1.4.1 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -2657,6 +2671,10 @@ snapshots: boolbase@2.0.0: {} + bun-types@1.4.1: + dependencies: + '@types/node': 22.20.1 + cac@7.0.0: {} chai@6.2.2: {} From bf9f2f9269e9d9cbf54dd843e62791a0f65e58b0 Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Sun, 6 Sep 2026 13:00:44 -0400 Subject: [PATCH 4/4] cleanup: Run all the quality gates, fix up typescript issues and run formatters Runs all the linter, formatters and ci checks --- .gitignore | 3 + .../examples/vim-netrw/netrw.test.ts | 5 +- .../ghostwright/examples/vim-netrw/netrw.ts | 19 +- .../examples/vim-netrw/recognition.test.ts | 4 +- experiments/ghostwright/ghostty.lock.json | 2 +- .../native/pty-host-rust/src/session.rs | 7 +- experiments/ghostwright/package.json | 7 +- experiments/ghostwright/scripts/benchmark.ts | 24 +-- .../ghostwright/scripts/build-host-rust.ts | 8 +- .../ghostwright/scripts/update-manifest.ts | 3 +- .../ghostwright/scripts/verify-artifacts.ts | 3 +- .../ghostwright/src/assertions/index.ts | 6 +- .../ghostwright/src/effection/index.ts | 17 +- experiments/ghostwright/src/errors.ts | 5 +- experiments/ghostwright/src/execution.ts | 54 +++--- experiments/ghostwright/src/index.ts | 17 +- experiments/ghostwright/src/inspection.ts | 6 +- experiments/ghostwright/src/matchers.ts | 17 +- experiments/ghostwright/src/observations.ts | 2 +- experiments/ghostwright/src/profile.ts | 30 ++- experiments/ghostwright/src/pty/client.ts | 2 +- experiments/ghostwright/src/pty/protocol.ts | 2 +- .../ghostwright/src/terminal/extensions.ts | 4 +- .../ghostwright/src/terminal/output.ts | 2 +- .../ghostwright/src/terminal/session.ts | 116 ++++++------ experiments/ghostwright/src/terminal/wasm.ts | 95 +++++----- experiments/ghostwright/src/tracing/replay.ts | 13 +- experiments/ghostwright/src/tracing/trace.ts | 16 +- .../ghostwright/test/assertions-trace.test.ts | 44 +++++ .../ghostwright/test/extensions.test.ts | 6 +- experiments/ghostwright/test/host-contract.ts | 3 +- .../ghostwright/test/runtime-smoke.mjs | 6 +- experiments/ghostwright/test/scoped.test.ts | 4 +- .../ghostwright/test/screen-locator.test.ts | 3 +- experiments/ghostwright/tsconfig.types.json | 11 +- package.json | 17 +- packages/clack-tty/package.json | 2 +- packages/clack-tty/src/extension.ts | 14 +- packages/clack-tty/src/producer.ts | 20 +- packages/clack-tty/src/protocol.ts | 159 +++++++++------- packages/clack-tty/test/e2e.test.ts | 26 ++- .../clack-tty/test/fixtures/action-target.ts | 115 ++++++++++++ .../test/fixtures/custom-attributes.ts | 16 ++ packages/clack-tty/test/locator.test.ts | 6 +- packages/clack-tty/test/protocol.test.ts | 105 ++++++----- .../clack-tty/test/scoped-actions.test.ts | 133 ++++++++++++++ .../clack-tty/test/scoped-locator.test.ts | 33 +++- packages/clack-tty/tsconfig.json | 5 +- packages/clack-tty/vitest.config.ts | 2 +- packages/hello-world/package.json | 76 ++++---- packages/hello-world/src/hello-world.ts | 25 +-- packages/hello-world/test/hello-world.test.ts | 4 +- packages/hello-world/vitest.config.ts | 2 +- packages/pizza-preact/package.json | 2 +- packages/pizza-preact/src/app.tsx | 172 +++++++++--------- .../pizza-preact/test/pizza-preact.test.ts | 4 +- packages/pizza-preact/tsconfig.json | 8 +- packages/pizza/package.json | 10 +- packages/pizza/src/pizza.ts | 7 - packages/pizza/test/pizza.test.ts | 4 +- packages/pizza/vitest.config.ts | 2 +- pnpm-lock.yaml | 13 ++ tsconfig.json | 12 +- vendor/clack-ui-preact/package.json | 2 +- vendor/clack-ui-preact/src/facade.ts | 49 +++-- vendor/clack-ui-preact/src/root.ts | 2 +- vendor/clack-ui/package.json | 3 + vendor/clack-ui/src/core/api.test.ts | 42 +++++ vendor/clack-ui/src/core/api.ts | 56 +++--- vendor/clack-ui/src/core/lifecycle.ts | 2 +- vendor/clack-ui/src/elements/box.ts | 7 +- vendor/clack-ui/src/elements/form.ts | 7 +- vendor/clack-ui/src/elements/input.ts | 17 +- vendor/clack-ui/src/elements/text.ts | 2 +- vendor/clack-ui/src/emit.ts | 9 +- vendor/clack-ui/src/extensions.ts | 12 +- vendor/clack-ui/src/focus.ts | 5 +- vendor/clack-ui/src/host.test.ts | 53 ++++++ vendor/clack-ui/src/host.ts | 85 +++++---- vendor/clack-ui/src/input-loop.ts | 34 ++-- vendor/clack-ui/src/ui.ts | 4 +- vendor/ui/src/render/ids.test.ts | 3 +- 82 files changed, 1272 insertions(+), 682 deletions(-) create mode 100644 packages/clack-tty/test/fixtures/action-target.ts create mode 100644 packages/clack-tty/test/fixtures/custom-attributes.ts create mode 100644 packages/clack-tty/test/scoped-actions.test.ts create mode 100644 vendor/clack-ui/src/core/api.test.ts create mode 100644 vendor/clack-ui/src/host.test.ts 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/examples/vim-netrw/netrw.test.ts b/experiments/ghostwright/examples/vim-netrw/netrw.test.ts index 002ccdb..821263c 100644 --- a/experiments/ghostwright/examples/vim-netrw/netrw.test.ts +++ b/experiments/ghostwright/examples/vim-netrw/netrw.test.ts @@ -62,7 +62,10 @@ test('refuse to navigate when the cursor belongs to the neighboring editor', asy }); /** A real, isolated Vim with its bundled netrw. No application instrumentation. */ -async function withVim(viewport: Viewport, body: (ui: AsyncExecution) => Promise) { +async function withVim( + viewport: Viewport, + body: (ui: AsyncExecution) => Promise, +): Promise { const directory = await mkdtemp(join(tmpdir(), 'ghostwright-netrw-')); try { await writeFile( diff --git a/experiments/ghostwright/examples/vim-netrw/netrw.ts b/experiments/ghostwright/examples/vim-netrw/netrw.ts index ebdcee0..151b3b0 100644 --- a/experiments/ghostwright/examples/vim-netrw/netrw.ts +++ b/experiments/ghostwright/examples/vim-netrw/netrw.ts @@ -5,6 +5,7 @@ import { type AsyncExecution, type Rect, type RegionInspection, + type RegionLocator, type ScreenSnapshot, } from '../../src/index.ts'; @@ -24,10 +25,11 @@ 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) => { + const isSeparator = (row: number): boolean => { const candidate = screen.lines[row]?.cells[cell.column]; return ( - candidate?.style.inverse && + candidate !== undefined && + candidate.style.inverse && !candidate.style.invisible && ['|', '│'].includes(candidate.text) ); @@ -40,7 +42,7 @@ function leftWindow(screen: ScreenSnapshot): Rect | undefined { if ( bottom !== screen.viewport.rows - 2 || status?.length !== cell.column || - !status.every((cell) => cell.style.inverse && !cell.style.invisible) + !status.every((edgeCell) => edgeCell.style.inverse && !edgeCell.style.invisible) ) continue; candidates.push({ column: 0, row: 0, width: cell.column, height: bottom }); @@ -89,7 +91,7 @@ const listingRegion = explorerRegion.derive('listing', (window) => { }); /** Thin-list entries with plain ASCII filenames; never a path or a Vim command. */ -export function fileEntry(name: string) { +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) => @@ -111,7 +113,7 @@ export function fileEntry(name: string) { ); } -function editorFor(name: string) { +function editorFor(name: string): RegionLocator { return defineScreenLocator( `Vim editor for ${JSON.stringify(name)} in the left split`, (screen) => { @@ -129,7 +131,12 @@ function editorFor(name: string) { } /** Small authored control model. Recognition is pure; actions use its owning executor. */ -export function netrw(ui: AsyncExecution) { +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) { diff --git a/experiments/ghostwright/examples/vim-netrw/recognition.test.ts b/experiments/ghostwright/examples/vim-netrw/recognition.test.ts index 064d655..2f1eed3 100644 --- a/experiments/ghostwright/examples/vim-netrw/recognition.test.ts +++ b/experiments/ghostwright/examples/vim-netrw/recognition.test.ts @@ -58,8 +58,8 @@ async function screen( terminal.free(); } } -function observation(screen: ScreenSnapshot): Observation { - return { kind: 'screen', screen, sequence: 1, timestamp: 0 }; +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 () => { diff --git a/experiments/ghostwright/ghostty.lock.json b/experiments/ghostwright/ghostty.lock.json index 7ff04d4..3ed18cb 100644 --- a/experiments/ghostwright/ghostty.lock.json +++ b/experiments/ghostwright/ghostty.lock.json @@ -125,7 +125,7 @@ "sha256": "9cc284061558b47237e478107dbbe8eabd1f6139038f61b1e403bb97e74ced1f" }, "artifacts/pty-host-darwin-arm64": { - "sha256": "704d2273677552ee22841b16eb06fd9d3d00216176d682220906152f8cbcec8e" + "sha256": "e17474ad808b84548e3d354da573cc7d6d1f434b27b51fac367e5ba3624f758d" }, "artifacts/terminfo/67/ghostty": { "sha256": "8ac69a6a57378edd05bcca8769ff49ce3d01e9496ff134781af5b9ee1d934b7b" diff --git a/experiments/ghostwright/native/pty-host-rust/src/session.rs b/experiments/ghostwright/native/pty-host-rust/src/session.rs index 82cf46c..862804f 100644 --- a/experiments/ghostwright/native/pty-host-rust/src/session.rs +++ b/experiments/ghostwright/native/pty-host-rust/src/session.rs @@ -500,12 +500,7 @@ impl Drop for Session { } if let Some(child) = self.child.filter(|_| !self.child_exited) { unsafe { nix::libc::kill(child.as_raw(), nix::libc::SIGKILL) }; - loop { - match waitpid(child, None) { - Err(nix::errno::Errno::EINTR) => continue, - _ => break, - } - } + while let Err(nix::errno::Errno::EINTR) = waitpid(child, None) {} } } } diff --git a/experiments/ghostwright/package.json b/experiments/ghostwright/package.json index 5a0b27a..64ea828 100644 --- a/experiments/ghostwright/package.json +++ b/experiments/ghostwright/package.json @@ -35,13 +35,13 @@ }, "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 --outdir dist --target node --format esm --packages external --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:rust": "bun scripts/build-host-rust.ts", "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 .", - "typecheck": "bunx tsc -p tsconfig.types.json", + "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", @@ -52,7 +52,8 @@ "effection": "^4.0.2" }, "devDependencies": { - "@types/bun": "^1.3.9" + "@types/bun": "^1.3.9", + "typescript": "^5.9.3" }, "engines": { "bun": ">=1.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-host-rust.ts b/experiments/ghostwright/scripts/build-host-rust.ts index 1733371..3c34881 100644 --- a/experiments/ghostwright/scripts/build-host-rust.ts +++ b/experiments/ghostwright/scripts/build-host-rust.ts @@ -1,4 +1,5 @@ import { $ } from 'bun'; +import { UnsupportedPlatformError } from '../src/errors.ts'; import { copyFile, mkdir, chmod } from 'node:fs/promises'; const root = new URL('..', import.meta.url).pathname; @@ -12,8 +13,9 @@ const targets: Record = { const local = `${process.platform}-${process.arch}`; const target = process.env.GHOSTWRIGHT_RUST_TARGET ?? - Object.keys(targets).find((target) => targets[target] === local); -if (!target || !targets[target]) throw new Error(`Unsupported Rust PTY target: ${target ?? local}`); + 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); @@ -22,4 +24,4 @@ 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.log(output); +console.info(output); 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 1a52e3f..264d5eb 100644 --- a/experiments/ghostwright/src/assertions/index.ts +++ b/experiments/ghostwright/src/assertions/index.ts @@ -210,7 +210,7 @@ class LocatorExpectation implements AsyncLocatorExpectation { 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); }; @@ -242,7 +242,7 @@ class LocatorExpectation implements AsyncLocatorExpectation { 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], @@ -329,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) diff --git a/experiments/ghostwright/src/effection/index.ts b/experiments/ghostwright/src/effection/index.ts index b3b4a7d..f196ee5 100644 --- a/experiments/ghostwright/src/effection/index.ts +++ b/experiments/ghostwright/src/effection/index.ts @@ -58,19 +58,22 @@ export class EffectionLocator implements OperationLocator { /** Effection wrapper around a TerminalSession. */ export class EffectionTerminal implements OperationTerminal { constructor(readonly inner: AsyncExecution) {} - get signal() { + get signal(): AbortSignal { return this.inner.signal; } - assert(locator: RegionLocator, matcher: Matcher) { + assert(locator: RegionLocator, matcher: Matcher): ReturnType { return assertRegion(this.inner.session, locator, matcher); } - expect(locator: RegionLocator) { + expect(locator: RegionLocator): ReturnType { return expectRegion.operation(this, locator); } - click(locator: RegionLocator, options?: MouseOptions) { + click(locator: RegionLocator, options?: MouseOptions): Operation { return op(() => this.inner.click(locator, options)); } - capture(options: CaptureOptions, body: (terminal: EffectionTerminal) => Operation) { + capture( + options: CaptureOptions, + body: (terminal: EffectionTerminal) => Operation, + ): ReturnType { return captureOperation(this.inner.session, options, (terminal) => body(new EffectionTerminal(terminal)), ); @@ -97,7 +100,7 @@ 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() { + get screen(): OperationTerminal['screen'] { return this.inner.screen; } revisions = { @@ -114,7 +117,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); diff --git a/experiments/ghostwright/src/errors.ts b/experiments/ghostwright/src/errors.ts index adb32dd..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 }); diff --git a/experiments/ghostwright/src/execution.ts b/experiments/ghostwright/src/execution.ts index 808aabd..97c688e 100644 --- a/experiments/ghostwright/src/execution.ts +++ b/experiments/ghostwright/src/execution.ts @@ -26,7 +26,7 @@ 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 { AsyncTerminal, MouseOptions, TerminalLaunchOptions } from './types.ts'; +import type { ActionReceipt, AsyncTerminal, MouseOptions, TerminalLaunchOptions } from './types.ts'; export interface CaptureOptions { readonly until: Condition; @@ -42,7 +42,8 @@ export interface Capture { readonly observations: readonly Observation[]; } const expectRegion = createExpect(); -const error = (code: string, message: string) => new GhostwrightError({ code, message }); +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'); @@ -53,7 +54,7 @@ function timeout(milliseconds: number, code: string): Operation { } function aborted(signal: AbortSignal): Operation { return action((_resolve, reject) => { - const abort = () => reject(signal.reason); + const abort = (): void => reject(signal.reason); signal.addEventListener('abort', abort, { once: true }); if (signal.aborted) abort(); return () => signal.removeEventListener('abort', abort); @@ -88,7 +89,7 @@ function awaitMatch( matcher: Matcher, ): Operation { return action((resolve, reject) => { - const check = (observation?: Observation) => { + const check = (observation?: Observation): void => { if (!observation || !locator.accepts(observation)) return; try { const matches = locator.resolve(observation); @@ -143,12 +144,14 @@ export class AsyncExecution implements AsyncTerminal { this.history = this.#bind(session.history); this.graphics = this.#bind(session.graphics); } - #bind Promise>>(methods: T): T { + #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, - (...args: unknown[]) => this.#promise(() => method(...args)), - ]), + Object.entries(methods).map(([name, method]) => [name, bind(method)]), ) as T; } async #run(operation: () => Operation): Promise { @@ -168,28 +171,30 @@ export class AsyncExecution implements AsyncTerminal { #promise(fn: () => Promise): Promise { return this.#run(() => call(fn)); } - get screen() { + get screen(): AsyncTerminal['screen'] { return this.session.screen; } - getByText(...args: Parameters) { + getByText( + ...args: Parameters + ): ReturnType { return this.session.getByText(...args); } - region(...args: Parameters) { + region(...args: Parameters): ReturnType { return this.session.region(...args); } - resize(viewport: Parameters[0]) { + resize(viewport: Parameters[0]): Promise { return this.#promise(() => this.session.resize(viewport, this.signal)); } - close() { + close(): Promise { return this.#promise(() => this.session.close()); } - expect(locator: RegionLocator) { + expect(locator: RegionLocator): ReturnType { return expectRegion(this, locator); } assert(locator: RegionLocator, matcher: Matcher): Promise { return this.#run(() => assertRegion(this.session, locator, matcher)); } - async click(locator: RegionLocator, options?: MouseOptions) { + async click(locator: RegionLocator, options?: MouseOptions): Promise { const region = await this.assert(locator, (actual) => ({ pass: !!actual.visibleBounds, expected: 'on-screen region', @@ -238,6 +243,11 @@ export function* assertRegion( `${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; } } @@ -269,15 +279,15 @@ export function* captureOperation( finished = false; const recording = yield* resource<{ stop(): void }>(function* (provide) { let timer: ReturnType | undefined; - let off = () => {}, - offStatus = () => {}; - const stop = () => { + let off = (): void => {}, + offStatus = (): void => {}; + const stop = (): void => { finished = true; off(); offStatus(); clearTimeout(timer); }; - const finish = () => { + const finish = (): void => { stop(); completion.resolve( Object.freeze({ @@ -288,11 +298,11 @@ export function* captureOperation( }), ); }; - const fail = (cause: unknown) => { + const fail = (cause: unknown): void => { stop(); completion.reject(cause as Error); }; - const schedule = () => { + const schedule = (): void => { clearTimeout(timer); if (!finished && state.wakeAt !== undefined) timer = setTimeout( diff --git a/experiments/ghostwright/src/index.ts b/experiments/ghostwright/src/index.ts index 371e73a..4251aec 100644 --- a/experiments/ghostwright/src/index.ts +++ b/experiments/ghostwright/src/index.ts @@ -14,7 +14,8 @@ export { withTerminal, 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, @@ -39,13 +40,9 @@ export function expectTerminal( | OperationTerminalExpectation | AsyncTerminalExpectation { if (target instanceof AsyncExecution) return expectAsync(target.session); - 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 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 index b9debc3..e62a1cd 100644 --- a/experiments/ghostwright/src/inspection.ts +++ b/experiments/ghostwright/src/inspection.ts @@ -82,7 +82,7 @@ export class RegionInspection { }, ); } - cursor() { + cursor(): Readonly { const cursor = this.screen.cursor, r = this.visibleBounds; return Object.freeze({ @@ -108,6 +108,8 @@ export class RegionInspection { ]); } } -export function inspect(screen: ScreenSnapshot) { +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/matchers.ts b/experiments/ghostwright/src/matchers.ts index fd3f0b7..92d0a14 100644 --- a/experiments/ghostwright/src/matchers.ts +++ b/experiments/ghostwright/src/matchers.ts @@ -84,11 +84,11 @@ export const all = }; }; -// Each method may have its own argument tuple. `any` is confined to this -// heterogeneous registry constraint; the inferred public methods preserve it. +// 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: any[]) => MatchResult + (actual: RegionInspection, ...args: never[]) => MatchResult >; export function defineMatchers(matchers: M): Readonly { return Object.freeze({ ...matchers }); @@ -121,12 +121,19 @@ export interface ExpectFactory { 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, - (...args: unknown[]) => executor.assert(locator, (actual) => matcher(actual, ...args)), + bindMatcher(matcher, (assertion) => executor.assert(locator, assertion)), ]), ) as unknown as Expectations; // Object.fromEntries erases each method's argument tuple. return Object.freeze( @@ -138,7 +145,7 @@ function factory(definitions: M): ExpectFactory return Object.fromEntries( Object.entries(definitions).map(([name, matcher]) => [ name, - (...args: unknown[]) => executor.assert(locator, (actual) => matcher(actual, ...args)), + bindMatcher(matcher, (assertion) => executor.assert(locator, assertion)), ]), ) as unknown as OperationExpectations; }, diff --git a/experiments/ghostwright/src/observations.ts b/experiments/ghostwright/src/observations.ts index 6226675..eafb2e1 100644 --- a/experiments/ghostwright/src/observations.ts +++ b/experiments/ghostwright/src/observations.ts @@ -59,7 +59,7 @@ export class Observations { const paired = this.#extensions.get(extensionId); return paired?.screen.sequence === this.#latest.screen.sequence ? paired : undefined; } - get sequence() { + get sequence(): number { return this.#sequence; } subscribe(listener: (observation: Observation) => void): () => void { diff --git a/experiments/ghostwright/src/profile.ts b/experiments/ghostwright/src/profile.ts index 8dddcf8..dcf0a8e 100644 --- a/experiments/ghostwright/src/profile.ts +++ b/experiments/ghostwright/src/profile.ts @@ -35,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. */ @@ -76,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 4ff8db3..0fc1d93 100644 --- a/experiments/ghostwright/src/pty/client.ts +++ b/experiments/ghostwright/src/pty/client.ts @@ -235,7 +235,7 @@ export class SidecarClient { signal?.throwIfAborted(); const sequence = this.#sequence; const response = this.request(FrameKind.WRITE, data, true); - const abort = () => { + const abort = (): void => { void this.request(FrameKind.CANCEL_WRITE, { sequence }).catch(() => undefined); }; signal?.addEventListener('abort', abort, { once: true }); diff --git a/experiments/ghostwright/src/pty/protocol.ts b/experiments/ghostwright/src/pty/protocol.ts index 455ed10..e89e900 100644 --- a/experiments/ghostwright/src/pty/protocol.ts +++ b/experiments/ghostwright/src/pty/protocol.ts @@ -78,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(); diff --git a/experiments/ghostwright/src/terminal/extensions.ts b/experiments/ghostwright/src/terminal/extensions.ts index d489003..0d909b1 100644 --- a/experiments/ghostwright/src/terminal/extensions.ts +++ b/experiments/ghostwright/src/terminal/extensions.ts @@ -36,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'; diff --git a/experiments/ghostwright/src/terminal/output.ts b/experiments/ghostwright/src/terminal/output.ts index dceafed..07597c7 100644 --- a/experiments/ghostwright/src/terminal/output.ts +++ b/experiments/ghostwright/src/terminal/output.ts @@ -39,7 +39,7 @@ export class TerminalOutput { continue; } const registration = item.kind === 'event' ? item.event.registration : item.registration; - const extension = this.extensions.find((extension) => extension.osc === 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); diff --git a/experiments/ghostwright/src/terminal/session.ts b/experiments/ghostwright/src/terminal/session.ts index 7ea4530..91e1a32 100644 --- a/experiments/ghostwright/src/terminal/session.ts +++ b/experiments/ghostwright/src/terminal/session.ts @@ -61,7 +61,11 @@ import type { import { TerminalOutput } from './output.ts'; import type { Observations } from '../observations.ts'; import { GhosttyWasmTerminal } from './wasm.ts'; -function concatBytes(parts: readonly Uint8Array[]) { +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) { @@ -70,7 +74,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, @@ -80,7 +84,7 @@ 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 { @@ -125,7 +129,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({ @@ -280,29 +284,29 @@ 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; } hasExtension(id: string): boolean { return (this.options.extensions ?? []).some((extension) => extension.id === id); } - 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()); @@ -342,7 +346,7 @@ export class TerminalSession implements AsyncTerminal { 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]) @@ -407,44 +411,46 @@ export class TerminalSession implements AsyncTerminal { 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, signal?: AbortSignal): 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(() => { - signal?.throwIfAborted(); - return this.#host.resize(value); - }); - else if (kind === FrameKind.SIGNAL) - ack = await this.#command(() => { - signal?.throwIfAborted(); - return 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(), 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, traceMode: 'record' | 'redact' = 'record', signal?: AbortSignal) { + async #write( + data: Uint8Array, + traceMode: 'record' | 'redact' = 'record', + signal?: AbortSignal, + ): Promise { signal?.throwIfAborted(); this.#ensure('write input'); const before = this.#revision, @@ -482,7 +488,7 @@ export class TerminalSession implements AsyncTerminal { }); return receipt; } - keyboardFor(signal?: AbortSignal) { + keyboardFor(signal?: AbortSignal): AsyncTerminal['keyboard'] { return { press: async (key: KeyName | KeyPress) => { signal?.throwIfAborted(); @@ -504,7 +510,7 @@ export class TerminalSession implements AsyncTerminal { }; } keyboard = this.keyboardFor(); - #point(p: Point) { + #point(p: Point): void { if ( !Number.isInteger(p.column) || !Number.isInteger(p.row) || @@ -538,7 +544,7 @@ export class TerminalSession implements AsyncTerminal { ); return this.#write(bytes, 'record', signal); } - mouseFor(signal?: AbortSignal) { + 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), @@ -586,13 +592,13 @@ export class TerminalSession implements AsyncTerminal { signal: string, target: 'child' | 'process-group' = 'process-group', abort?: AbortSignal, - ) { - return this.#send(FrameKind.SIGNAL, { signal, target }, abort); + ): Promise { + return this.#send({ kind: FrameKind.SIGNAL, value: { signal, target } }, abort); } 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, @@ -617,7 +623,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 { @@ -627,24 +633,17 @@ 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, signal?: AbortSignal) { - const viewport = normalizeViewport(v); - const receipt = await this.#send(FrameKind.RESIZE, viewport, signal); - this.#viewport = viewport; - this.#engine.resize(viewport); - this.#terminalHistoryGeneration++; - this.#publish('resize'); - this.observations.screen(this.#snapshot); - 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()); @@ -689,7 +688,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(() => { @@ -789,14 +788,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); @@ -1096,8 +1096,10 @@ export class Locator implements AsyncLocator { const chosen = this.index === undefined ? out : out[this.index] ? [out[this.index]] : []; return Object.freeze(chosen); } - async unique(timeout = this.session.options.assertionTimeoutMs ?? 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( @@ -1115,7 +1117,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/wasm.ts b/experiments/ghostwright/src/terminal/wasm.ts index 4720067..9f80b2e 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); @@ -434,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); @@ -457,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); @@ -470,7 +476,7 @@ export class GhosttyWasmTerminal { this.#release(p, size); } } - mode(n: number) { + mode(n: number): boolean { const p = this.#alloc(1); try { return ( @@ -481,7 +487,7 @@ export class GhosttyWasmTerminal { this.#release(p, 1); } } - text() { + text(): string { const lp = this.#alloc(4); let p = 0, n = 0; @@ -534,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; @@ -565,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 { @@ -578,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, @@ -594,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. */ @@ -722,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); }; @@ -763,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); } @@ -792,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 @@ -905,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) @@ -921,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) @@ -950,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, @@ -1216,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; @@ -1278,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( @@ -1337,7 +1346,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); @@ -1359,7 +1368,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); @@ -1372,7 +1381,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/replay.ts b/experiments/ghostwright/src/tracing/replay.ts index d19cea2..1f24740 100644 --- a/experiments/ghostwright/src/tracing/replay.ts +++ b/experiments/ghostwright/src/tracing/replay.ts @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; +// 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 { @@ -9,6 +10,7 @@ import type { } 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 { @@ -26,7 +28,8 @@ export async function replayTrace( options: ReplayOptions = {}, ): Promise { const metadata = JSON.parse(await readFile(join(directory, 'metadata.json'), 'utf8')); - if (metadata.schemaVersion !== 1) throw new AssetIntegrityError('Unsupported trace schema'); + if (metadata.schemaVersion !== TRACE_SCHEMA_VERSION) + throw new AssetIntegrityError('Unsupported trace schema'); const lock = JSON.parse( await readFile( new URL( @@ -58,9 +61,9 @@ export async function replayTrace( sequence = 0, sourceFrameSequence = 0, timestamp = 0; - const publish = (cause: ScreenRevision['cause']) => { + const publish = (cause: ScreenRevision['cause']): ScreenSnapshot => { const next = engine.snapshot(cause); - const observable = (s: ScreenSnapshot) => + const observable = (s: ScreenSnapshot): string => JSON.stringify([ s.lines, s.cursor, @@ -75,7 +78,7 @@ export async function replayTrace( const changedRows = next.lines.flatMap((line, row) => JSON.stringify(line) === JSON.stringify(previous.lines[row]) ? [] : [row], ); - const visual = (s: ScreenSnapshot) => + const visual = (s: ScreenSnapshot): string => JSON.stringify([ s.lines, s.cursor, @@ -131,7 +134,7 @@ export async function replayTrace( sourceFrameSequence = event.frameSequence; output.push(raw.slice(offset, offset + length)); engine.takeEffects(); // Responses are already present in the recorded transport. - } else if (event.type === 'action' && event.viewport) { + } else if (event.type === 'resize' && event.viewport) { engine.resize(event.viewport); output.observations.screen(publish('resize')); } diff --git a/experiments/ghostwright/src/tracing/trace.ts b/experiments/ghostwright/src/tracing/trace.ts index fc00a92..05f46c8 100644 --- a/experiments/ghostwright/src/tracing/trace.ts +++ b/experiments/ghostwright/src/tracing/trace.ts @@ -4,10 +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 { normalizeViewport } from '../profile.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; @@ -41,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, @@ -98,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: { diff --git a/experiments/ghostwright/test/assertions-trace.test.ts b/experiments/ghostwright/test/assertions-trace.test.ts index 0b80340..0b7f92f 100644 --- a/experiments/ghostwright/test/assertions-trace.test.ts +++ b/experiments/ghostwright/test/assertions-trace.test.ts @@ -98,6 +98,50 @@ 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 withTerminalAsync( + { + 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('marked input is redacted from trace bytes', async () => { const directory = await mkdtemp(join(tmpdir(), 'ghostwright-redaction-test-')); try { diff --git a/experiments/ghostwright/test/extensions.test.ts b/experiments/ghostwright/test/extensions.test.ts index 88b0658..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'); } diff --git a/experiments/ghostwright/test/host-contract.ts b/experiments/ghostwright/test/host-contract.ts index 0f828b1..97d28ba 100644 --- a/experiments/ghostwright/test/host-contract.ts +++ b/experiments/ghostwright/test/host-contract.ts @@ -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/runtime-smoke.mjs b/experiments/ghostwright/test/runtime-smoke.mjs index cdf7fd0..336a300 100644 --- a/experiments/ghostwright/test/runtime-smoke.mjs +++ b/experiments/ghostwright/test/runtime-smoke.mjs @@ -1,5 +1,4 @@ -import { expectTerminal, withTerminalAsync } from '../dist/index.js'; -import { GhostwrightError } from '../src/errors.ts'; +import { expectTerminal, GhostwrightError, withTerminalAsync } from '../dist/index.js'; await withTerminalAsync( { command: '/bin/sh', args: ['-c', 'printf runtime-smoke'], trace: 'off' }, @@ -14,5 +13,4 @@ await withTerminalAsync( } }, ); -// oxlint-disable-next-line no-console -- test script -console.log('Ghostwright runtime smoke passed'); +console.info('Ghostwright runtime smoke passed'); diff --git a/experiments/ghostwright/test/scoped.test.ts b/experiments/ghostwright/test/scoped.test.ts index 006b6e8..aa227ac 100644 --- a/experiments/ghostwright/test/scoped.test.ts +++ b/experiments/ghostwright/test/scoped.test.ts @@ -2,6 +2,7 @@ import { expect, test } from 'bun:test'; import { run } from 'effection'; 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 { withTerminalAsync, @@ -17,6 +18,7 @@ import { settled, replayTrace, type TerminalExtensionDefinition, + type TerminalLaunchOptions, type RegionInspection, } from '../src/index.ts'; @@ -59,7 +61,7 @@ const extension: TerminalExtensionDefinition = { }, }; const field = defineLocator('rig', 'field', (description) => [description.bounds]); -const launch = () => ({ +const launch = (): TerminalLaunchOptions => ({ command: process.execPath, args: ['-e', application], extensions: [extension], diff --git a/experiments/ghostwright/test/screen-locator.test.ts b/experiments/ghostwright/test/screen-locator.test.ts index 6cdc914..a05c9fe 100644 --- a/experiments/ghostwright/test/screen-locator.test.ts +++ b/experiments/ghostwright/test/screen-locator.test.ts @@ -4,6 +4,7 @@ import { type Observation, type ScreenSnapshot, type RegionInspection, + type Rect, } from '../src/index.ts'; import { GhosttyWasmTerminal } from '../src/terminal/wasm.ts'; @@ -39,7 +40,7 @@ test('a screen locator re-resolves geometry while historical matches keep their // The resolver defines the relationship. Here the child is the next // cell, not a cell geometrically contained by its parent. - const nextCell = (parent: RegionInspection) => [ + const nextCell = (parent: RegionInspection): Rect[] => [ { ...parent.bounds, column: parent.bounds.column + 1 }, ]; const letters = marks.derive('next cell', nextCell); diff --git a/experiments/ghostwright/tsconfig.types.json b/experiments/ghostwright/tsconfig.types.json index 2e02abf..734f170 100644 --- a/experiments/ghostwright/tsconfig.types.json +++ b/experiments/ghostwright/tsconfig.types.json @@ -3,7 +3,14 @@ "compilerOptions": { "noEmit": true, "emitDeclarationOnly": false, - "rootDir": "." + "rootDir": ".", + "types": ["node", "bun"] }, - "include": ["src/**/*.ts", "type-tests/**/*.ts"] + "include": [ + "src/**/*.ts", + "type-tests/**/*.ts", + "test/**/*.ts", + "examples/**/*.ts", + "scripts/**/*.ts" + ] } 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..9b6aec0 100644 --- a/packages/clack-tty/package.json +++ b/packages/clack-tty/package.json @@ -1,8 +1,8 @@ { "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": { diff --git a/packages/clack-tty/src/extension.ts b/packages/clack-tty/src/extension.ts index 65b852d..89493c6 100644 --- a/packages/clack-tty/src/extension.ts +++ b/packages/clack-tty/src/extension.ts @@ -34,7 +34,7 @@ function materialize(frame: ClackFrame): Element[] { } } const ordered: Element[] = []; - function visit(siblings: Element[]) { + function visit(siblings: Element[]): void { siblings.sort((a, b) => a.order - b.order); for (const node of siblings) { ordered.push(node); @@ -47,16 +47,16 @@ function materialize(frame: ClackFrame): Element[] { function attribute(node: Element, name: string): string | undefined { if (name === 'id') return node.key; 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']> = { @@ -113,7 +113,7 @@ function selector(source: string): Selector[][] { let tokens = 0, branches = 0; // oxlint-disable-next-line bombshell-dev/max-params -- traversal tracks independent selector depth limits - function visit(lists: Selector[][], depth: number, hasDepth: number) { + function visit(lists: Selector[][], depth: number, hasDepth: number): void { branches += lists.length; if (depth > 8 || branches > 32) fail('GW_CLACK_SELECTOR_LIMIT', 'Selector nesting/list limit exceeded'); diff --git a/packages/clack-tty/src/producer.ts b/packages/clack-tty/src/producer.ts index 3b3aa74..aa1d765 100644 --- a/packages/clack-tty/src/producer.ts +++ b/packages/clack-tty/src/producer.ts @@ -70,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(); @@ -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. @@ -146,14 +139,14 @@ export function useSemantic(host: Host, options: SemanticOptions): void { // 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 custom: Record = {}; + 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 @@ -171,7 +164,7 @@ export function useSemantic(host: Host, options: SemanticOptions): void { ...(role !== undefined ? { role } : {}), ...(label !== undefined ? { label } : {}), ...(entry.name === 'input' ? { input: true } : {}), - ...(Object.keys(custom).length > 0 ? { custom } : {}), + ...(custom.length > 0 ? { custom: Object.fromEntries(custom) } : {}), }, ...(geo !== undefined ? { geo } : {}), }); @@ -220,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 576e65f..96f6c96 100644 --- a/packages/clack-tty/src/protocol.ts +++ b/packages/clack-tty/src/protocol.ts @@ -6,6 +6,7 @@ * * 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; @@ -71,6 +72,8 @@ const utf8 = new TextEncoder(); 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' || @@ -115,83 +118,93 @@ export function decodeFrame(payload: Uint8Array): ClackFrame { /** Validate an already-parsed frame against the v1 schema and limits (REQ-006, REQ-008). */ export function validateFrame(input: unknown): ClackFrame { - if (!input || typeof input !== 'object' || Array.isArray(input)) - fail('GW_CLACK_SCHEMA', 'Semantic frame must be an object'); - const frame = input as Record; + 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'); 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, }, nodes: Object.freeze(nodes), }; } function validateNode(raw: unknown, index: number): ClackNode { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) - fail('GW_CLACK_SCHEMA', `Node ${index} must be an object`); - const node = raw as Record; + 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 !== 'object' || Array.isArray(attrs)) - 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]; + }), + ); } 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 } : {}), + ...(role !== undefined ? { role } : {}), + ...(label !== undefined ? { label } : {}), + ...(attrs.input !== undefined ? { input: attrs.input } : {}), ...(custom !== undefined ? { custom } : {}), }, ...(node.geo !== undefined ? { geo: validateGeometry(node.geo, key) } : {}), @@ -199,8 +212,8 @@ function validateNode(raw: unknown, index: number): ClackNode { } 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'); @@ -209,38 +222,43 @@ function validateGeometry(raw: unknown, key: string): ClackNodeGeometry { // 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 @@ -276,15 +294,15 @@ function validateTree(nodes: readonly ClackNode[]): void { } /** - * Clay-compatible edge truncation from authoritative float bounds, deliberately - * not `floor(origin) + ceil(size)` (carried from the retired freedom producer). + * 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); @@ -305,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/test/e2e.test.ts b/packages/clack-tty/test/e2e.test.ts index cf3e2cf..a0a2a65 100644 --- a/packages/clack-tty/test/e2e.test.ts +++ b/packages/clack-tty/test/e2e.test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest'; -import { withTerminalAsync, regionLocator } from 'ghostwright'; +import { withTerminalAsync, regionLocator, type TerminalLaunchOptions } from 'ghostwright'; import { clackTtyExtension, expectUI, locator } from '../src/index.ts'; -const entry = () => ({ +const entry = (): TerminalLaunchOptions => ({ command: process.execPath, - args: ['--import', 'tsx', 'src/hello-world.ts'], + 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, @@ -25,6 +25,26 @@ test('producer and CSS adapter compose with core assertions over real terminal o }); }); +test('custom attribute names survive the producer, wire decoder, and CSS query', async () => { + await withTerminalAsync( + { + 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('ambiguous location fails immediately, not as an assertion timeout', async () => { await withTerminalAsync(entry(), async (ui) => { await expectUI(ui, locator('input[label="say"]')).toHaveInputFocus(); 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..07ae7fa --- /dev/null +++ b/packages/clack-tty/test/fixtures/action-target.ts @@ -0,0 +1,115 @@ +// 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 !== 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[?1000h\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 e95bc5c..0ccf054 100644 --- a/packages/clack-tty/test/locator.test.ts +++ b/packages/clack-tty/test/locator.test.ts @@ -14,7 +14,7 @@ const description: ClackFrame = { name: 'input', parent: 'form', order: 0, - attrs: { label: 'name', role: 'textbox' }, + 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 }, @@ -58,6 +58,10 @@ test('queries are immutable, pure, ordered, and work against historical descript 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', + ); + 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() }), diff --git a/packages/clack-tty/test/protocol.test.ts b/packages/clack-tty/test/protocol.test.ts index 0b5fe7a..1a6f1cd 100644 --- a/packages/clack-tty/test/protocol.test.ts +++ b/packages/clack-tty/test/protocol.test.ts @@ -5,91 +5,96 @@ import { geometryFor, validateFrame, type ClackFrame, + type ClackNode, } from '../src/protocol.ts'; +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; + const frame = (): ClackFrame => ({ v: 1, frame: 1, surface: { columns: 80, rows: 24, row: 1 }, - nodes: [ - { - 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 }, - }, - }, - ], + nodes: [structuredClone(input)], }); -test('identity and geometry round-trip without application focus/value state', () => { +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(), ); - expect(JSON.stringify(frame())).not.toMatch(/focused|focusStack|caret|value/); }); -for (const [name, mutate] of [ - [ - 'version', - (f: any) => { - f.v = 9; - }, - ], - [ - 'frame number', - (f: any) => { - f.frame = 0; - }, - ], - [ - 'missing parent', - (f: any) => { - f.nodes[0].parent = 'absent'; - }, - ], +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); +}); + +// 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 } }] }], [ - 'parent cycle', - (f: any) => { - f.nodes[0].parent = 'name'; + 'non-numeric layout', + { + ...frame(), + nodes: [{ ...input, geo: { ...input.geo, layout: { ...input.geo.layout, x: '2' } } }], }, ], [ - 'duplicate key', - (f: any) => { - f.nodes.push(f.nodes[0]); + 'non-finite layout', + { + ...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', - (f: any) => { - f.nodes[0].geo.term.width = -1; + { + ...frame(), + nodes: [{ ...input, geo: { ...input.geo, term: { ...input.geo.term, width: -1 } } }], }, ], [ 'fractional cell', - (f: any) => { - f.nodes[0].geo.term.column = 1.5; + { + ...frame(), + nodes: [{ ...input, geo: { ...input.geo, term: { ...input.geo.term, column: 1.5 } } }], }, ], [ 'oversized label', - (f: any) => { - f.nodes[0].attrs.label = 'x'.repeat(1025); - }, + { ...frame(), nodes: [{ ...input, attrs: { ...input.attrs, label: 'x'.repeat(1025) } }] }, ], -] as const) +] as const) { test(`rejects ${name}`, () => { - const value = frame(); - mutate(value); expect(() => validateFrame(value)).toThrow(); }); +} for (const payload of [ '=', 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..bbb70d7 --- /dev/null +++ b/packages/clack-tty/test/scoped-actions.test.ts @@ -0,0 +1,133 @@ +import { expect, test } from 'vitest'; +import { + defineScreenLocator, + regionLocator, + withTerminalAsync, + 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 withTerminalAsync(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.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} ambiguity includes the whole path and sends no input`, async () => { + await withTerminalAsync(launch('ambiguous'), async (ui) => { + await ui.expect(query.status).toContainText('Ready'); + await expect(ui.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 withTerminalAsync(launch('missing-child'), async (ui) => { + await ui.expect(query.status).toContainText('Loading'); + await expect(ui.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 withTerminalAsync(launch('missing-child'), async (ui) => { + await ui.expect(query.status).toContainText('Loading'); + await Promise.all([ + expect(ui.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 index 260aaea..e760f91 100644 --- a/packages/clack-tty/test/scoped-locator.test.ts +++ b/packages/clack-tty/test/scoped-locator.test.ts @@ -1,9 +1,28 @@ import { expect, expectTypeOf, test } from 'vitest'; -import { textContains, withTerminalAsync, type Observation, type RegionLocator } from 'ghostwright'; +import { + textContains, + withTerminalAsync, + type Observation, + type RegionLocator, + type TerminalLaunchOptions, +} from 'ghostwright'; import { clackTtyExtension, locator, type ClackLocator } from '../src/index.ts'; -import { encodeFrame, type ClackFrame, type ClackNode } from '../src/protocol.ts'; +import { + encodeFrame, + type ClackFrame, + type ClackNode, + type ClackNodeGeometry, +} from '../src/protocol.ts'; -const geo = ({ column, row, width }: { column: number; row: number; width: number }) => ({ +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 }, }); @@ -89,7 +108,7 @@ function scene({ return paint + Buffer.from(encodeFrame(description)).toString(); } -const launch = () => ({ +const launch = (): TerminalLaunchOptions => ({ command: process.execPath, args: [ '-e', @@ -165,8 +184,10 @@ test('scope uses ancestry, preserves node-level nth, and never clips to parent g }, ); const sample = movement.baseline; - const texts = (query: ReturnType, observation: Observation = sample) => - query.resolve(observation).map((region) => region.text().trim()); + 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']); 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 65dcccf..479fc26 100644 --- a/packages/hello-world/test/hello-world.test.ts +++ b/packages/hello-world/test/hello-world.test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; -import { withTerminalAsync } from 'ghostwright'; +import { withTerminalAsync, type TerminalLaunchOptions } from 'ghostwright'; import { clackTtyExtension, expectUI, locator } from '@ghostwright/clack-tty'; -const entry = () => ({ +const entry = (): TerminalLaunchOptions => ({ command: process.execPath, args: ['--import', 'tsx', 'src/hello-world.ts'], cwd: new URL('..', import.meta.url).pathname, 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 index 2ede353..60266f5 100644 --- a/packages/pizza-preact/package.json +++ b/packages/pizza-preact/package.json @@ -1,8 +1,8 @@ { "name": "@ghostwright/pizza-preact", "version": "0.0.0", - "description": "A Preact clack/ui pizza delivery example tested outside-in with Ghostwright", "private": true, + "description": "A Preact clack/ui pizza delivery example tested outside-in with Ghostwright", "license": "MIT", "type": "module", "scripts": { diff --git a/packages/pizza-preact/src/app.tsx b/packages/pizza-preact/src/app.tsx index ee3feb0..7d2c579 100644 --- a/packages/pizza-preact/src/app.tsx +++ b/packages/pizza-preact/src/app.tsx @@ -8,105 +8,105 @@ const cyan = rgba(0, 205, 205); const gray = rgba(127, 127, 127); interface SubmitButtonProps { - children: ComponentChildren; - label: string; + children: ComponentChildren; + label: string; } function SubmitButton({ children, label }: SubmitButtonProps): VNode { - return ( - - ); + return ( + + ); } interface FieldRowProps { - label: string; - labelWidth: number; + label: string; + labelWidth: number; } function FieldRow({ label, labelWidth }: FieldRowProps): VNode { - return ( - - - {label}: - - - - ); + return ( + + + {label}: + + + + ); } /** Pizza delivery expressed as a Preact tree over the clack/ui Host. */ export function PizzaDelivery(): VNode { - const [cardOpen, setCardOpen] = useState(false); + 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 - - + 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} -
- ); + {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/test/pizza-preact.test.ts b/packages/pizza-preact/test/pizza-preact.test.ts index 06254f7..d142b69 100644 --- a/packages/pizza-preact/test/pizza-preact.test.ts +++ b/packages/pizza-preact/test/pizza-preact.test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; -import { withTerminalAsync } from 'ghostwright'; +import { withTerminalAsync, type TerminalLaunchOptions } from 'ghostwright'; import { clackTtyExtension, expectUI, locator } from '@ghostwright/clack-tty'; // Launch the Preact app in a real terminal. Locators find the controls; // assertions check the text, borders, and cursor drawn on that terminal. -const pizza = () => ({ +const pizza = (): TerminalLaunchOptions => ({ command: process.execPath, args: ['--import', 'tsx', 'src/index.tsx'], cwd: new URL('..', import.meta.url).pathname, diff --git a/packages/pizza-preact/tsconfig.json b/packages/pizza-preact/tsconfig.json index ff65e5c..9106f5c 100644 --- a/packages/pizza-preact/tsconfig.json +++ b/packages/pizza-preact/tsconfig.json @@ -9,5 +9,11 @@ "noEmit": true, "types": ["node"] }, - "include": ["src/**/*.ts", "src/**/*.tsx"] + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "test/**/*.ts", + "vitest.config.ts", + "../../vendor/clack-ui-preact/src/**/*.ts" + ] } diff --git a/packages/pizza/package.json b/packages/pizza/package.json index 72edf99..290b2f2 100644 --- a/packages/pizza/package.json +++ b/packages/pizza/package.json @@ -1,8 +1,8 @@ { "name": "@ghostwright/pizza", "version": "0.0.0", - "description": "A clack/ui pizza delivery form with a card dialog, validated with ghostwright tree locators", "private": true, + "description": "A clack/ui pizza delivery form with a card dialog, validated with ghostwright tree locators", "license": "MIT", "type": "module", "scripts": { @@ -20,9 +20,6 @@ "tsx": "^4.19.0", "vitest": "^4.1.9" }, - "@clack/ui": { - "extensions": ["@ghostwright/clack-tty/auto"] - }, "devEngines": { "packageManager": { "name": "pnpm", @@ -34,5 +31,10 @@ "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 index 86efecd..c083efc 100644 --- a/packages/pizza/src/pizza.ts +++ b/packages/pizza/src/pizza.ts @@ -55,13 +55,6 @@ function field(name: string): HostElement { return element; } -function submitNote(content: string): HostElement { - const element = host.createElement('text'); - host.setProperty(element, 'color', gray); - host.insertBefore(element, host.createLiteral(content)); - return element; -} - // --- delivery form --------------------------------------------------------- const nameInput = field('name'); diff --git a/packages/pizza/test/pizza.test.ts b/packages/pizza/test/pizza.test.ts index 9b3226b..2c7985a 100644 --- a/packages/pizza/test/pizza.test.ts +++ b/packages/pizza/test/pizza.test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest'; -import { withTerminalAsync, settled } from 'ghostwright'; +import { withTerminalAsync, settled, type TerminalLaunchOptions } from 'ghostwright'; import { clackTtyExtension, expectUI, locator } from '@ghostwright/clack-tty'; // No application internals: launch the CLI, use its keyboard, and check what // appears in the terminal. Locators give those visible controls useful names. -const pizza = () => ({ +const pizza = (): TerminalLaunchOptions => ({ command: process.execPath, args: ['--import', 'tsx', 'src/pizza.ts'], cwd: new URL('..', import.meta.url).pathname, diff --git a/packages/pizza/vitest.config.ts b/packages/pizza/vitest.config.ts index 48cdfd7..866bafa 100644 --- a/packages/pizza/vitest.config.ts +++ b/packages/pizza/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/pnpm-lock.yaml b/pnpm-lock.yaml index 1e332ee..0d8c8d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,12 @@ importers: '@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: @@ -69,6 +75,9 @@ importers: '@types/bun': specifier: ^1.3.9 version: 1.4.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 packages/clack-tty: dependencies: @@ -190,6 +199,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: 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'); });