From 965c7c13fcf9e9fbc6baddf54a921ab69e0df4da Mon Sep 17 00:00:00 2001 From: Ryan Rauh Date: Wed, 2 Sep 2026 13:57:29 -0400 Subject: [PATCH] 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: {}