Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions packages/pizza-preact/package.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
112 changes: 112 additions & 0 deletions packages/pizza-preact/src/app.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
role="button"
label={label}
type="submit"
layout={{
width: fixed(16),
height: fixed(3),
padding: { top: 1, right: 1, bottom: 1, left: 1 },
}}
border={{ color: gray, top: 1, right: 1, bottom: 1, left: 1 }}
>
{children}
</button>
);
}

interface FieldRowProps {
label: string;
labelWidth: number;
}

function FieldRow({ label, labelWidth }: FieldRowProps): VNode {
return (
<box layout={{ direction: 'ltr', gap: 1, width: grow() }}>
<box layout={{ width: fixed(labelWidth) }}>
<text color={gray}>{label}:</text>
</box>
<input role="textbox" label={label} />
</box>
);
}

/** Pizza delivery expressed as a Preact tree over the clack/ui Host. */
export function PizzaDelivery(): VNode {
const [cardOpen, setCardOpen] = useState(false);

return (
<box layout={{ direction: 'ttb', width: grow(), height: grow() }}>
<form
role="form"
label="delivery"
onSubmit={() => 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 }}
>
<text color={cyan}>Pizza Delivery</text>
<FieldRow label="name" labelWidth={9} />
<FieldRow label="address" labelWidth={9} />
<box layout={{ direction: 'ltr', gap: 1, width: grow() }}>
<SubmitButton label="add-card">Add card</SubmitButton>
</box>
</form>

{cardOpen ? (
<dialog
role="dialog"
label="card"
modal={true}
layout={{ direction: 'ttb', width: grow(32, 44) }}
bg={black}
border={{ color: blue, top: 1, right: 1, bottom: 1, left: 1 }}
floating={{
attachTo: 'parent',
attachPoints: { element: 'center-center', parent: 'center-center' },
zIndex: 1,
}}
>
<form
role="form"
label="card-payment"
onSubmit={() => setCardOpen(false)}
layout={{
direction: 'ttb',
gap: 1,
padding: { top: 1, right: 2, bottom: 1, left: 2 },
width: grow(),
}}
>
<text color={cyan}>Card Details</text>
<FieldRow label="card-number" labelWidth={13} />
<FieldRow label="expiry" labelWidth={13} />
<FieldRow label="cvc" labelWidth={13} />
<box layout={{ direction: 'ltr', gap: 1, width: grow() }}>
<SubmitButton label="submit-card">Submit card</SubmitButton>
</box>
</form>
</dialog>
) : null}
</box>
);
}
9 changes: 9 additions & 0 deletions packages/pizza-preact/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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(<PizzaDelivery />);
await ui.main();
148 changes: 148 additions & 0 deletions packages/pizza-preact/test/pizza-preact.test.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof withTerminalAsync>[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 },
);
});
});
13 changes: 13 additions & 0 deletions packages/pizza-preact/tsconfig.json
Original file line number Diff line number Diff line change
@@ -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"]
}
10 changes: 10 additions & 0 deletions packages/pizza-preact/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';

export default defineConfig({
test: {
testTimeout: 60_000,
hookTimeout: 30_000,
teardownTimeout: 30_000,
pool: 'forks',
},
});
38 changes: 38 additions & 0 deletions packages/pizza/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
Loading
Loading