@textui/testing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Softov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,64 @@
1
+ # @textui/testing
2
+
3
+ A headless harness for TextUI applications. No terminal, no timers, no
4
+ snapshots of a screen nobody looked at.
5
+
6
+ ```bash
7
+ npm install --save-dev @textui/testing
8
+ ```
9
+
10
+ ```ts
11
+ import { renderApp } from '@textui/testing';
12
+
13
+ const t = await renderApp(<App />, { width: 80, height: 24 });
14
+
15
+ expect(t.getByRole('button', { name: 'Save' }).focused).toBe(true);
16
+ t.press('enter');
17
+ await t.settle();
18
+ expect(t.text()).toContain('Saved');
19
+ ```
20
+
21
+ ## Query by what it is, not where it is
22
+
23
+ Every component declares a semantic role, so a test asks the way a person would
24
+ - the Save button, the row labelled `api` - rather than by index into a tree
25
+ that changes the moment the layout does.
26
+
27
+ | | |
28
+ |---|---|
29
+ | `getByRole(role, { name })` | The one with that role and accessible name |
30
+ | `getByLabel` / `getByText` | By label, or by rendered text |
31
+ | `getByComponent(name)` | The escape hatch, when the role is not the point |
32
+ | `text()`, `line(y)`, `lines()` | What is actually on the screen |
33
+ | `tree()` | The instance tree, for when the pixels are not the question |
34
+
35
+ An `Element` is a description - role, label, text, rect, focus, props - not a
36
+ handle. You act on the application with keys, the way a person does.
37
+
38
+ `get*` throws with the near misses listed; `query*` returns null.
39
+
40
+ ## Drive it, then let it settle
41
+
42
+ `press('ctrl+s')`, `pressAll('down', 'down', 'enter')`, `type('hello')`, and
43
+ `resize(w, h)`. Effects and re-renders are asynchronous, so `await t.settle()`
44
+ is what separates "I sent the key" from "the screen caught up".
45
+
46
+ ## Test at two sizes
47
+
48
+ A single fixed size skips every breakpoint and hides the layout break you were
49
+ trying to catch. `resize` is cheap, and a loop over two sizes costs one line:
50
+
51
+ ```ts
52
+ for (const size of [{ width: 100, height: 30 }, { width: 60, height: 18 }]) {
53
+ it(`fits at ${size.width}x${size.height}`, async () => { /* ... */ });
54
+ }
55
+ ```
56
+
57
+ ## Runtime
58
+
59
+ Depends on `@textui/core` and `@textui/terminal`. No `node:` imports - it runs
60
+ on the virtual terminal, so there is no tty to have. Node 22+ and Bun.
61
+
62
+ ## Documentation
63
+
64
+ <https://softov.github.io/textui/>
@@ -0,0 +1,154 @@
1
+ import type { CapabilityOverrides, ComponentDefinition, ComponentNode, Disposable, InspectorNode, KeyEvent, TextUIApp, ReactiveStore, SemanticRole, ThemeDefinition } from '@textui/core';
2
+ import { strokeOf } from '@textui/core';
3
+ import { type VirtualTerminalAdapter } from '@textui/terminal';
4
+ /**
5
+ * The testing harness.
6
+ *
7
+ * It drives a real application against a virtual terminal, so what a test
8
+ * asserts is what a terminal would receive. Queries are semantic first -
9
+ * by role, by label, by text - because a test pinned to exact ANSI output
10
+ * fails on every legitimate change and passes on none of the interesting bugs.
11
+ * Snapshots are still here for when the layout itself is the thing under test.
12
+ */
13
+ export interface RenderOptions {
14
+ width?: number;
15
+ height?: number;
16
+ theme?: string;
17
+ themes?: ThemeDefinition[];
18
+ shell?: string;
19
+ components?: ComponentDefinition[];
20
+ capabilities?: CapabilityOverrides;
21
+ locale?: string;
22
+ initialState?: Record<string, unknown>;
23
+ /** Register the shipped catalog. On by default. */
24
+ builtins?: boolean;
25
+ /**
26
+ * Run boot registration before the first frame.
27
+ *
28
+ * Mirrors the application's own `onBoot`, disposable and all - a harness
29
+ * whose boot contract is narrower than the real one is a harness that
30
+ * cannot test what the real one does.
31
+ */
32
+ onBoot?(app: TextUIApp): void | Disposable | Promise<void | Disposable>;
33
+ /** Encode frames to ANSI as a real terminal session would. Off by default. */
34
+ encode?: boolean;
35
+ /** Collect errors instead of printing them. On by default. */
36
+ captureErrors?: boolean;
37
+ /**
38
+ * Whether anything is allowed to move. On by default, and driven by hand -
39
+ * `advance` is the clock.
40
+ *
41
+ * Off is the reader who asked for stillness, and it is a behaviour worth
42
+ * testing rather than assuming: a component that animates has a second
43
+ * rendering nobody sees until somebody sets this.
44
+ */
45
+ animations?: boolean;
46
+ }
47
+ export interface QueryOptions {
48
+ /** Match the whole string rather than a substring. */
49
+ exact?: boolean;
50
+ }
51
+ export interface Element {
52
+ id: string;
53
+ component: string;
54
+ role?: string;
55
+ label?: string;
56
+ text?: string;
57
+ rect?: {
58
+ x: number;
59
+ y: number;
60
+ width: number;
61
+ height: number;
62
+ };
63
+ focused: boolean;
64
+ props: Record<string, unknown>;
65
+ children: Element[];
66
+ }
67
+ export interface Harness {
68
+ readonly app: TextUIApp;
69
+ readonly store: ReactiveStore;
70
+ readonly terminal: VirtualTerminalAdapter;
71
+ /** The frame as plain text, trailing spaces trimmed. */
72
+ text(): string;
73
+ /** One row of the frame. */
74
+ line(y: number): string;
75
+ /** Every line, as an array. */
76
+ lines(): string[];
77
+ /** The encoded bytes written since the last `clearOutput`. */
78
+ output(): string;
79
+ clearOutput(): void;
80
+ /** The semantic tree, for structural assertions. */
81
+ tree(): InspectorNode | null;
82
+ getByRole(role: SemanticRole, options?: QueryOptions & {
83
+ name?: string;
84
+ }): Element;
85
+ queryByRole(role: SemanticRole, options?: QueryOptions & {
86
+ name?: string;
87
+ }): Element | null;
88
+ getAllByRole(role: SemanticRole): Element[];
89
+ getByLabel(label: string, options?: QueryOptions): Element;
90
+ queryByLabel(label: string, options?: QueryOptions): Element | null;
91
+ getByText(text: string, options?: QueryOptions): Element;
92
+ queryByText(text: string, options?: QueryOptions): Element | null;
93
+ getAllByText(text: string, options?: QueryOptions): Element[];
94
+ getByComponent(name: string): Element;
95
+ getAllByComponent(name: string): Element[];
96
+ /** True when the text appears anywhere in the frame. */
97
+ hasText(text: string): boolean;
98
+ /** Send one chord: `a`, `enter`, `ctrl+p`, `shift+tab`. */
99
+ press(chord: string): void;
100
+ /** Send several chords in order. */
101
+ pressAll(...chords: string[]): void;
102
+ /** Type a string, one key at a time. */
103
+ type(text: string): void;
104
+ paste(text: string): void;
105
+ click(x: number, y: number, button?: 'left' | 'middle' | 'right'): void;
106
+ /** Click the centre of an element. */
107
+ clickOn(element: Element): void;
108
+ moveMouse(x: number, y: number): void;
109
+ wheel(x: number, y: number, delta: number): void;
110
+ focusTerminal(focused: boolean): void;
111
+ /** Feed raw terminal bytes, exercising the decoder too. */
112
+ feed(data: string): void;
113
+ resize(width: number, height: number): void;
114
+ setCapabilities(overrides: CapabilityOverrides): void;
115
+ setTheme(id: string): void;
116
+ setShell(id: string): void;
117
+ /** Advance the animation clock and render. */
118
+ advance(ms: number): void;
119
+ /** Render now, outside the scheduler. */
120
+ flush(): void;
121
+ /** Let queued promises settle, then render. */
122
+ settle(): Promise<void>;
123
+ focused(): Element | null;
124
+ focus(id: string): void;
125
+ tab(): void;
126
+ shiftTab(): void;
127
+ errors(): {
128
+ context: string;
129
+ message: string;
130
+ }[];
131
+ stats(): {
132
+ renders: number;
133
+ runs: number;
134
+ instances: number;
135
+ };
136
+ unmount(): Promise<void>;
137
+ }
138
+ /** A harness that has already started. */
139
+ export declare function render(node: ComponentNode, options?: RenderOptions): Promise<Harness>;
140
+ /** Mount an application rather than a bare node. */
141
+ export declare function renderApp(options?: RenderOptions & {
142
+ root?: ComponentNode;
143
+ }): Promise<Harness>;
144
+ /** `ctrl+shift+p`, `enter`, `a`. The inverse of `strokeOf`. */
145
+ export declare function chordToEvents(chord: string): KeyEvent[];
146
+ export { strokeOf };
147
+ /**
148
+ * A stable text snapshot: the frame, with a ruler, so a diff shows which
149
+ * column moved rather than only that something did.
150
+ */
151
+ export declare function snapshot(harness: Harness, options?: {
152
+ ruler?: boolean;
153
+ }): string;
154
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/dist/index.js ADDED
@@ -0,0 +1,314 @@
1
+ import { WRITER_KEY, createApp, strokeOf, splitStroke, createBag } from '@textui/core';
2
+ import { registerBuiltins } from '@textui/widgets';
3
+ import { createVirtualTerminal, createWriter } from '@textui/terminal';
4
+ /** A harness that has already started. */
5
+ export async function render(node, options = {}) {
6
+ return mount({ ...options, root: node });
7
+ }
8
+ /** Mount an application rather than a bare node. */
9
+ export async function renderApp(options = {}) {
10
+ return mount(options);
11
+ }
12
+ async function mount(options) {
13
+ const width = options.width ?? 80;
14
+ const height = options.height ?? 24;
15
+ const terminal = createVirtualTerminal({
16
+ width,
17
+ height,
18
+ capabilities: options.capabilities,
19
+ managed: false,
20
+ });
21
+ const errors = [];
22
+ const app = createApp({
23
+ terminal,
24
+ root: options.root,
25
+ // Only pass a theme when the caller named one, so a shell's own default
26
+ // still applies - passing 'dark' here would silently override it.
27
+ ...(options.theme ? { theme: options.theme } : {}),
28
+ themes: options.themes,
29
+ shell: options.shell ?? 'plain',
30
+ locale: options.locale,
31
+ animations: options.animations ?? true,
32
+ diagnostics: true,
33
+ session: { managed: false, altScreen: false, hideCursor: false },
34
+ onBoot: async (booted) => {
35
+ // Everything registered here comes back out when the app stops, which
36
+ // matters for a harness more than for an application: a test file
37
+ // mounts and unmounts dozens of times in one process.
38
+ const bag = createBag();
39
+ if (options.builtins !== false)
40
+ bag.add(registerBuiltins(booted));
41
+ if (options.components)
42
+ bag.add(booted.components.registerMany(options.components));
43
+ if (options.initialState) {
44
+ booted.store.batch(() => {
45
+ for (const [path, value] of Object.entries(options.initialState)) {
46
+ booted.store.set((path.startsWith('$/') ? path : `$/${path}`), value);
47
+ }
48
+ });
49
+ }
50
+ const booted_ = await options.onBoot?.(booted);
51
+ if (booted_)
52
+ bag.add(booted_);
53
+ return bag;
54
+ },
55
+ });
56
+ if (options.encode) {
57
+ app.services.provide(WRITER_KEY, createWriter(terminal.capabilities()));
58
+ }
59
+ app.store.subscribe('$/modus/diagnostics/errors', (value) => {
60
+ const list = Array.isArray(value) ? value : [];
61
+ errors.length = 0;
62
+ for (const entry of list) {
63
+ errors.push(entry);
64
+ }
65
+ }, { subtree: true });
66
+ await app.start();
67
+ app.flush();
68
+ return createHarness(app, terminal, errors);
69
+ }
70
+ function createHarness(app, terminal, errors) {
71
+ const flush = () => app.flush();
72
+ const collect = (predicate) => {
73
+ const tree = app.inspect();
74
+ if (!tree)
75
+ return [];
76
+ const found = [];
77
+ const walk = (node) => {
78
+ if (predicate(node))
79
+ found.push(toElement(node));
80
+ for (const child of node.children)
81
+ walk(child);
82
+ };
83
+ walk(tree);
84
+ return found;
85
+ };
86
+ const textOf = (node) => {
87
+ const parts = [];
88
+ const walk = (n) => {
89
+ if (typeof n.text === 'string')
90
+ parts.push(n.text);
91
+ for (const child of n.children)
92
+ walk(child);
93
+ };
94
+ walk(node);
95
+ return parts.join(' ');
96
+ };
97
+ const matches = (haystack, needle, exact) => {
98
+ if (haystack === undefined)
99
+ return false;
100
+ return exact ? haystack === needle : haystack.includes(needle);
101
+ };
102
+ const require1 = (found, what) => {
103
+ if (found.length === 0) {
104
+ throw new Error(`[textui/testing] no element matching ${what}\n\n${app.buffer().toText()}`);
105
+ }
106
+ if (found.length > 1) {
107
+ const list = found.map((e) => ` <${e.component}> ${e.label ?? e.text ?? ''}`).join('\n');
108
+ throw new Error(`[textui/testing] ${found.length} elements match ${what}; narrow the query\n${list}`);
109
+ }
110
+ return found[0];
111
+ };
112
+ const sendKey = (chord) => {
113
+ for (const event of chordToEvents(chord))
114
+ app.handleInput(event);
115
+ flush();
116
+ };
117
+ return {
118
+ app,
119
+ store: app.store,
120
+ terminal,
121
+ text: () => app.buffer().toText(),
122
+ line: (y) => app.buffer().toText().split('\n')[y] ?? '',
123
+ lines: () => app.buffer().toText().split('\n'),
124
+ output: () => terminal.output(),
125
+ clearOutput: () => terminal.clearOutput(),
126
+ tree: () => app.inspect(),
127
+ getByRole(role, options = {}) {
128
+ const found = collect((n) => n.role === role && (options.name === undefined || matches(n.label ?? textOf(n), options.name, options.exact)));
129
+ return require1(found, `role "${role}"${options.name ? ` named "${options.name}"` : ''}`);
130
+ },
131
+ queryByRole(role, options = {}) {
132
+ const found = collect((n) => n.role === role && (options.name === undefined || matches(n.label ?? textOf(n), options.name, options.exact)));
133
+ return found[0] ?? null;
134
+ },
135
+ getAllByRole: (role) => collect((n) => n.role === role),
136
+ getByLabel(label, options = {}) {
137
+ return require1(collect((n) => matches(n.label, label, options.exact)), `label "${label}"`);
138
+ },
139
+ queryByLabel(label, options = {}) {
140
+ return collect((n) => matches(n.label, label, options.exact))[0] ?? null;
141
+ },
142
+ getByText(text, options = {}) {
143
+ return require1(collect((n) => matches(n.text, text, options.exact)), `text "${text}"`);
144
+ },
145
+ queryByText(text, options = {}) {
146
+ return collect((n) => matches(n.text, text, options.exact))[0] ?? null;
147
+ },
148
+ getAllByText: (text, options = {}) => collect((n) => matches(n.text, text, options.exact)),
149
+ getByComponent(name) {
150
+ return require1(collect((n) => n.component === name), `component <${name}>`);
151
+ },
152
+ getAllByComponent: (name) => collect((n) => n.component === name),
153
+ hasText: (text) => app.buffer().toText().includes(text),
154
+ press: sendKey,
155
+ pressAll(...chords) {
156
+ for (const chord of chords)
157
+ sendKey(chord);
158
+ },
159
+ type(text) {
160
+ for (const char of [...text]) {
161
+ app.handleInput({
162
+ type: 'key', name: char, char, raw: char,
163
+ ctrl: false, alt: false, meta: false,
164
+ shift: char !== char.toLowerCase(),
165
+ handled: false,
166
+ });
167
+ }
168
+ flush();
169
+ },
170
+ paste(text) {
171
+ app.handleInput({ type: 'paste', text, handled: false });
172
+ flush();
173
+ },
174
+ click(x, y, button = 'left') {
175
+ app.handleInput({ type: 'mouse', action: 'down', button, x, y, ctrl: false, alt: false, shift: false, handled: false });
176
+ app.handleInput({ type: 'mouse', action: 'up', button, x, y, ctrl: false, alt: false, shift: false, handled: false });
177
+ flush();
178
+ },
179
+ clickOn(element) {
180
+ const rect = element.rect;
181
+ if (!rect)
182
+ throw new Error(`[textui/testing] <${element.component}> has no bounds to click`);
183
+ this.click(rect.x + Math.floor(rect.width / 2), rect.y + Math.floor(rect.height / 2));
184
+ },
185
+ moveMouse(x, y) {
186
+ app.handleInput({ type: 'mouse', action: 'move', button: 'none', x, y, ctrl: false, alt: false, shift: false, handled: false });
187
+ flush();
188
+ },
189
+ wheel(x, y, delta) {
190
+ app.handleInput({ type: 'mouse', action: 'wheel', button: 'none', x, y, wheel: delta, ctrl: false, alt: false, shift: false, handled: false });
191
+ flush();
192
+ },
193
+ focusTerminal(focused) {
194
+ app.handleInput({ type: 'terminal-focus', focused });
195
+ flush();
196
+ },
197
+ feed(data) {
198
+ terminal.feed(data);
199
+ flush();
200
+ },
201
+ resize(width, height) {
202
+ terminal.resize(width, height);
203
+ flush();
204
+ },
205
+ setCapabilities(overrides) {
206
+ app.setCapabilityOverrides(overrides);
207
+ flush();
208
+ },
209
+ setTheme(id) {
210
+ app.setTheme(id);
211
+ flush();
212
+ },
213
+ setShell(id) {
214
+ app.setShell(id);
215
+ flush();
216
+ },
217
+ advance(ms) {
218
+ app.animation.advance(ms);
219
+ flush();
220
+ },
221
+ flush,
222
+ async settle() {
223
+ // Two turns: one for the promise that is pending, one for whatever it
224
+ // schedules. Anything deeper is a test that should await explicitly.
225
+ await Promise.resolve();
226
+ await new Promise((resolve) => setImmediate(resolve));
227
+ // And one turn of the clock, which is the one that waits for the disk.
228
+ //
229
+ // `setImmediate` runs in the check phase, and the loop never blocks to
230
+ // get there - so a run of settles is a spin, costing microseconds and
231
+ // giving a `stat` or a `readdir` no opportunity to come back. Tests wait
232
+ // by counting settles, so the budget ends up denominated in turns of the
233
+ // loop while the thing being waited for is denominated in milliseconds:
234
+ // they pass on an idle machine and fail on a busy one, which is the
235
+ // shape of every flake this helper has produced. A pending timer makes
236
+ // the poll phase block, and pending I/O lands.
237
+ await new Promise((resolve) => setTimeout(resolve, 0));
238
+ flush();
239
+ },
240
+ focused() {
241
+ const id = app.focus.focused();
242
+ if (!id)
243
+ return null;
244
+ const found = collect((n) => n.focused === true);
245
+ return found[0] ?? null;
246
+ },
247
+ focus(id) {
248
+ app.focus.focus(id);
249
+ flush();
250
+ },
251
+ tab: () => sendKey('tab'),
252
+ shiftTab: () => sendKey('shift+tab'),
253
+ errors: () => [...errors],
254
+ stats: () => app.stats(),
255
+ async unmount() {
256
+ await app.stop();
257
+ app.dispose();
258
+ },
259
+ };
260
+ }
261
+ function toElement(node) {
262
+ return {
263
+ id: node.id,
264
+ component: node.component,
265
+ role: node.role,
266
+ label: node.label,
267
+ text: node.text,
268
+ rect: node.rect,
269
+ focused: node.focused === true,
270
+ props: node.props,
271
+ children: node.children.map(toElement),
272
+ };
273
+ }
274
+ /** `ctrl+shift+p`, `enter`, `a`. The inverse of `strokeOf`. */
275
+ export function chordToEvents(chord) {
276
+ return chord
277
+ .trim()
278
+ .split(/\s+/)
279
+ .map((stroke) => {
280
+ // The registry's parser, not a second one: a chord pressed here has to
281
+ // produce the stroke the registry stored it under, or a binding that
282
+ // works in a terminal fails in a test and nobody can tell which is right.
283
+ const { mods: names, key } = splitStroke(stroke);
284
+ const mods = new Set(names);
285
+ // `space` is named and printable at once - the decoder emits both, and a
286
+ // press that gave only the name could not be typed into a field.
287
+ const char = key === 'space' ? ' ' : ([...key].length === 1 ? key : undefined);
288
+ return {
289
+ type: 'key',
290
+ name: key,
291
+ char,
292
+ ctrl: mods.has('ctrl') || mods.has('control'),
293
+ alt: mods.has('alt') || mods.has('option'),
294
+ shift: mods.has('shift'),
295
+ meta: mods.has('meta') || mods.has('cmd') || mods.has('super'),
296
+ raw: stroke,
297
+ handled: false,
298
+ };
299
+ });
300
+ }
301
+ export { strokeOf };
302
+ /**
303
+ * A stable text snapshot: the frame, with a ruler, so a diff shows which
304
+ * column moved rather than only that something did.
305
+ */
306
+ export function snapshot(harness, options = {}) {
307
+ const lines = harness.lines();
308
+ if (!options.ruler)
309
+ return lines.join('\n');
310
+ const width = Math.max(0, ...lines.map((l) => l.length));
311
+ const tens = Array.from({ length: width }, (_, i) => (i % 10 === 0 ? String((i / 10) % 10) : ' ')).join('');
312
+ const ones = Array.from({ length: width }, (_, i) => String(i % 10)).join('');
313
+ return [tens, ones, ...lines].join('\n');
314
+ }
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "@textui/testing",
3
+ "version": "0.1.0",
4
+ "description": "Headless testing harness for TextUI - semantic queries, input, resizing, time",
5
+ "keywords": [
6
+ "terminal",
7
+ "tui",
8
+ "testing",
9
+ "test-harness",
10
+ "headless",
11
+ "vitest",
12
+ "snapshot"
13
+ ],
14
+ "homepage": "https://softov.github.io/textui/",
15
+ "bugs": {
16
+ "url": "https://github.com/softov/textui/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/softov/textui.git",
21
+ "directory": "packages/testing"
22
+ },
23
+ "license": "MIT",
24
+ "author": "Softov <softov@brbyte.com>",
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "engines": {
28
+ "node": ">=22"
29
+ },
30
+ "files": [
31
+ "dist",
32
+ "src",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "exports": {
37
+ ".": {
38
+ "types": "./dist/index.d.ts",
39
+ "import": "./dist/index.js"
40
+ }
41
+ },
42
+ "dependencies": {
43
+ "@textui/widgets": "^0.1.0",
44
+ "@textui/terminal": "^0.1.0",
45
+ "@textui/core": "^0.1.0"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "scripts": {
51
+ "build": "tsc -p tsconfig.json",
52
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.test.json",
53
+ "test": "vitest run"
54
+ }
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,504 @@
1
+ import type {
2
+ CapabilityOverrides,
3
+ ComponentDefinition,
4
+ ComponentNode,
5
+ Disposable,
6
+ InspectorNode,
7
+ KeyEvent,
8
+ TextUIApp,
9
+ ReactiveStore,
10
+ SemanticRole,
11
+ ThemeDefinition,
12
+ } from '@textui/core';
13
+ import { WRITER_KEY, createApp, strokeOf, splitStroke, type App, createBag } from '@textui/core';
14
+ import { registerBuiltins } from '@textui/widgets';
15
+ import { createVirtualTerminal, createWriter, type VirtualTerminalAdapter } from '@textui/terminal';
16
+
17
+ /**
18
+ * The testing harness.
19
+ *
20
+ * It drives a real application against a virtual terminal, so what a test
21
+ * asserts is what a terminal would receive. Queries are semantic first -
22
+ * by role, by label, by text - because a test pinned to exact ANSI output
23
+ * fails on every legitimate change and passes on none of the interesting bugs.
24
+ * Snapshots are still here for when the layout itself is the thing under test.
25
+ */
26
+
27
+ export interface RenderOptions {
28
+ width?: number;
29
+ height?: number;
30
+ theme?: string;
31
+ themes?: ThemeDefinition[];
32
+ shell?: string;
33
+ components?: ComponentDefinition[];
34
+ capabilities?: CapabilityOverrides;
35
+ locale?: string;
36
+ initialState?: Record<string, unknown>;
37
+ /** Register the shipped catalog. On by default. */
38
+ builtins?: boolean;
39
+ /**
40
+ * Run boot registration before the first frame.
41
+ *
42
+ * Mirrors the application's own `onBoot`, disposable and all - a harness
43
+ * whose boot contract is narrower than the real one is a harness that
44
+ * cannot test what the real one does.
45
+ */
46
+ onBoot?(app: TextUIApp): void | Disposable | Promise<void | Disposable>;
47
+ /** Encode frames to ANSI as a real terminal session would. Off by default. */
48
+ encode?: boolean;
49
+ /** Collect errors instead of printing them. On by default. */
50
+ captureErrors?: boolean;
51
+ /**
52
+ * Whether anything is allowed to move. On by default, and driven by hand -
53
+ * `advance` is the clock.
54
+ *
55
+ * Off is the reader who asked for stillness, and it is a behaviour worth
56
+ * testing rather than assuming: a component that animates has a second
57
+ * rendering nobody sees until somebody sets this.
58
+ */
59
+ animations?: boolean;
60
+ }
61
+
62
+ export interface QueryOptions {
63
+ /** Match the whole string rather than a substring. */
64
+ exact?: boolean;
65
+ }
66
+
67
+ export interface Element {
68
+ id: string;
69
+ component: string;
70
+ role?: string;
71
+ label?: string;
72
+ text?: string;
73
+ rect?: { x: number; y: number; width: number; height: number };
74
+ focused: boolean;
75
+ props: Record<string, unknown>;
76
+ children: Element[];
77
+ }
78
+
79
+ export interface Harness {
80
+ readonly app: TextUIApp;
81
+ readonly store: ReactiveStore;
82
+ readonly terminal: VirtualTerminalAdapter;
83
+
84
+ // --- output ---
85
+ /** The frame as plain text, trailing spaces trimmed. */
86
+ text(): string;
87
+ /** One row of the frame. */
88
+ line(y: number): string;
89
+ /** Every line, as an array. */
90
+ lines(): string[];
91
+ /** The encoded bytes written since the last `clearOutput`. */
92
+ output(): string;
93
+ clearOutput(): void;
94
+ /** The semantic tree, for structural assertions. */
95
+ tree(): InspectorNode | null;
96
+
97
+ // --- queries ---
98
+ getByRole(role: SemanticRole, options?: QueryOptions & { name?: string }): Element;
99
+ queryByRole(role: SemanticRole, options?: QueryOptions & { name?: string }): Element | null;
100
+ getAllByRole(role: SemanticRole): Element[];
101
+ getByLabel(label: string, options?: QueryOptions): Element;
102
+ queryByLabel(label: string, options?: QueryOptions): Element | null;
103
+ getByText(text: string, options?: QueryOptions): Element;
104
+ queryByText(text: string, options?: QueryOptions): Element | null;
105
+ getAllByText(text: string, options?: QueryOptions): Element[];
106
+ getByComponent(name: string): Element;
107
+ getAllByComponent(name: string): Element[];
108
+ /** True when the text appears anywhere in the frame. */
109
+ hasText(text: string): boolean;
110
+
111
+ // --- input ---
112
+ /** Send one chord: `a`, `enter`, `ctrl+p`, `shift+tab`. */
113
+ press(chord: string): void;
114
+ /** Send several chords in order. */
115
+ pressAll(...chords: string[]): void;
116
+ /** Type a string, one key at a time. */
117
+ type(text: string): void;
118
+ paste(text: string): void;
119
+ click(x: number, y: number, button?: 'left' | 'middle' | 'right'): void;
120
+ /** Click the centre of an element. */
121
+ clickOn(element: Element): void;
122
+ moveMouse(x: number, y: number): void;
123
+ wheel(x: number, y: number, delta: number): void;
124
+ focusTerminal(focused: boolean): void;
125
+ /** Feed raw terminal bytes, exercising the decoder too. */
126
+ feed(data: string): void;
127
+
128
+ // --- environment ---
129
+ resize(width: number, height: number): void;
130
+ setCapabilities(overrides: CapabilityOverrides): void;
131
+ setTheme(id: string): void;
132
+ setShell(id: string): void;
133
+
134
+ // --- time ---
135
+ /** Advance the animation clock and render. */
136
+ advance(ms: number): void;
137
+ /** Render now, outside the scheduler. */
138
+ flush(): void;
139
+ /** Let queued promises settle, then render. */
140
+ settle(): Promise<void>;
141
+
142
+ // --- focus ---
143
+ focused(): Element | null;
144
+ focus(id: string): void;
145
+ tab(): void;
146
+ shiftTab(): void;
147
+
148
+ // --- diagnostics ---
149
+ errors(): { context: string; message: string }[];
150
+ stats(): { renders: number; runs: number; instances: number };
151
+
152
+ unmount(): Promise<void>;
153
+ }
154
+
155
+ /** A harness that has already started. */
156
+ export async function render(
157
+ node: ComponentNode,
158
+ options: RenderOptions = {},
159
+ ): Promise<Harness> {
160
+ return mount({ ...options, root: node });
161
+ }
162
+
163
+ /** Mount an application rather than a bare node. */
164
+ export async function renderApp(
165
+ options: RenderOptions & { root?: ComponentNode } = {},
166
+ ): Promise<Harness> {
167
+ return mount(options);
168
+ }
169
+
170
+ async function mount(options: RenderOptions & { root?: ComponentNode }): Promise<Harness> {
171
+ const width = options.width ?? 80;
172
+ const height = options.height ?? 24;
173
+
174
+ const terminal = createVirtualTerminal({
175
+ width,
176
+ height,
177
+ capabilities: options.capabilities,
178
+ managed: false,
179
+ });
180
+
181
+ const errors: { context: string; message: string }[] = [];
182
+
183
+ const app = createApp({
184
+ terminal,
185
+ root: options.root,
186
+ // Only pass a theme when the caller named one, so a shell's own default
187
+ // still applies - passing 'dark' here would silently override it.
188
+ ...(options.theme ? { theme: options.theme } : {}),
189
+ themes: options.themes,
190
+ shell: options.shell ?? 'plain',
191
+ locale: options.locale,
192
+ animations: options.animations ?? true,
193
+ diagnostics: true,
194
+ session: { managed: false, altScreen: false, hideCursor: false },
195
+ onBoot: async (booted) => {
196
+ // Everything registered here comes back out when the app stops, which
197
+ // matters for a harness more than for an application: a test file
198
+ // mounts and unmounts dozens of times in one process.
199
+ const bag = createBag();
200
+ if (options.builtins !== false) bag.add(registerBuiltins(booted));
201
+ if (options.components) bag.add(booted.components.registerMany(options.components));
202
+ if (options.initialState) {
203
+ booted.store.batch(() => {
204
+ for (const [path, value] of Object.entries(options.initialState as object)) {
205
+ booted.store.set((path.startsWith('$/') ? path : `$/${path}`) as `$/${string}`, value);
206
+ }
207
+ });
208
+ }
209
+ const booted_ = await options.onBoot?.(booted);
210
+ if (booted_) bag.add(booted_);
211
+ return bag;
212
+ },
213
+ });
214
+
215
+ if (options.encode) {
216
+ app.services.provide(WRITER_KEY, createWriter(terminal.capabilities()));
217
+ }
218
+
219
+ app.store.subscribe(
220
+ '$/modus/diagnostics/errors',
221
+ (value) => {
222
+ const list = Array.isArray(value) ? value : [];
223
+ errors.length = 0;
224
+ for (const entry of list) {
225
+ errors.push(entry as { context: string; message: string });
226
+ }
227
+ },
228
+ { subtree: true },
229
+ );
230
+
231
+ await app.start();
232
+ app.flush();
233
+
234
+ return createHarness(app as App, terminal, errors);
235
+ }
236
+
237
+ function createHarness(
238
+ app: App,
239
+ terminal: VirtualTerminalAdapter,
240
+ errors: { context: string; message: string }[],
241
+ ): Harness {
242
+ const flush = (): void => app.flush();
243
+
244
+ const collect = (predicate: (node: InspectorNode) => boolean): Element[] => {
245
+ const tree = app.inspect();
246
+ if (!tree) return [];
247
+ const found: Element[] = [];
248
+ const walk = (node: InspectorNode): void => {
249
+ if (predicate(node)) found.push(toElement(node));
250
+ for (const child of node.children) walk(child);
251
+ };
252
+ walk(tree);
253
+ return found;
254
+ };
255
+
256
+ const textOf = (node: InspectorNode): string => {
257
+ const parts: string[] = [];
258
+ const walk = (n: InspectorNode): void => {
259
+ if (typeof n.text === 'string') parts.push(n.text);
260
+ for (const child of n.children) walk(child);
261
+ };
262
+ walk(node);
263
+ return parts.join(' ');
264
+ };
265
+
266
+ const matches = (haystack: string | undefined, needle: string, exact?: boolean): boolean => {
267
+ if (haystack === undefined) return false;
268
+ return exact ? haystack === needle : haystack.includes(needle);
269
+ };
270
+
271
+ const require1 = (found: Element[], what: string): Element => {
272
+ if (found.length === 0) {
273
+ throw new Error(
274
+ `[textui/testing] no element matching ${what}\n\n${app.buffer().toText()}`,
275
+ );
276
+ }
277
+ if (found.length > 1) {
278
+ const list = found.map((e) => ` <${e.component}> ${e.label ?? e.text ?? ''}`).join('\n');
279
+ throw new Error(
280
+ `[textui/testing] ${found.length} elements match ${what}; narrow the query\n${list}`,
281
+ );
282
+ }
283
+ return found[0] as Element;
284
+ };
285
+
286
+ const sendKey = (chord: string): void => {
287
+ for (const event of chordToEvents(chord)) app.handleInput(event);
288
+ flush();
289
+ };
290
+
291
+ return {
292
+ app,
293
+ store: app.store,
294
+ terminal,
295
+
296
+ text: () => app.buffer().toText(),
297
+ line: (y) => app.buffer().toText().split('\n')[y] ?? '',
298
+ lines: () => app.buffer().toText().split('\n'),
299
+ output: () => terminal.output(),
300
+ clearOutput: () => terminal.clearOutput(),
301
+ tree: () => app.inspect(),
302
+
303
+ getByRole(role, options = {}) {
304
+ const found = collect((n) =>
305
+ n.role === role && (options.name === undefined || matches(n.label ?? textOf(n), options.name, options.exact)));
306
+ return require1(found, `role "${role}"${options.name ? ` named "${options.name}"` : ''}`);
307
+ },
308
+ queryByRole(role, options = {}) {
309
+ const found = collect((n) =>
310
+ n.role === role && (options.name === undefined || matches(n.label ?? textOf(n), options.name, options.exact)));
311
+ return found[0] ?? null;
312
+ },
313
+ getAllByRole: (role) => collect((n) => n.role === role),
314
+
315
+ getByLabel(label, options = {}) {
316
+ return require1(collect((n) => matches(n.label, label, options.exact)), `label "${label}"`);
317
+ },
318
+ queryByLabel(label, options = {}) {
319
+ return collect((n) => matches(n.label, label, options.exact))[0] ?? null;
320
+ },
321
+
322
+ getByText(text, options = {}) {
323
+ return require1(collect((n) => matches(n.text, text, options.exact)), `text "${text}"`);
324
+ },
325
+ queryByText(text, options = {}) {
326
+ return collect((n) => matches(n.text, text, options.exact))[0] ?? null;
327
+ },
328
+ getAllByText: (text, options = {}) => collect((n) => matches(n.text, text, options.exact)),
329
+
330
+ getByComponent(name) {
331
+ return require1(collect((n) => n.component === name), `component <${name}>`);
332
+ },
333
+ getAllByComponent: (name) => collect((n) => n.component === name),
334
+
335
+ hasText: (text) => app.buffer().toText().includes(text),
336
+
337
+ press: sendKey,
338
+ pressAll(...chords) {
339
+ for (const chord of chords) sendKey(chord);
340
+ },
341
+ type(text) {
342
+ for (const char of [...text]) {
343
+ app.handleInput({
344
+ type: 'key', name: char, char, raw: char,
345
+ ctrl: false, alt: false, meta: false,
346
+ shift: char !== char.toLowerCase(),
347
+ handled: false,
348
+ });
349
+ }
350
+ flush();
351
+ },
352
+ paste(text) {
353
+ app.handleInput({ type: 'paste', text, handled: false });
354
+ flush();
355
+ },
356
+ click(x, y, button = 'left') {
357
+ app.handleInput({ type: 'mouse', action: 'down', button, x, y, ctrl: false, alt: false, shift: false, handled: false });
358
+ app.handleInput({ type: 'mouse', action: 'up', button, x, y, ctrl: false, alt: false, shift: false, handled: false });
359
+ flush();
360
+ },
361
+ clickOn(element) {
362
+ const rect = element.rect;
363
+ if (!rect) throw new Error(`[textui/testing] <${element.component}> has no bounds to click`);
364
+ this.click(rect.x + Math.floor(rect.width / 2), rect.y + Math.floor(rect.height / 2));
365
+ },
366
+ moveMouse(x, y) {
367
+ app.handleInput({ type: 'mouse', action: 'move', button: 'none', x, y, ctrl: false, alt: false, shift: false, handled: false });
368
+ flush();
369
+ },
370
+ wheel(x, y, delta) {
371
+ app.handleInput({ type: 'mouse', action: 'wheel', button: 'none', x, y, wheel: delta, ctrl: false, alt: false, shift: false, handled: false });
372
+ flush();
373
+ },
374
+ focusTerminal(focused) {
375
+ app.handleInput({ type: 'terminal-focus', focused });
376
+ flush();
377
+ },
378
+ feed(data) {
379
+ terminal.feed(data);
380
+ flush();
381
+ },
382
+
383
+ resize(width, height) {
384
+ terminal.resize(width, height);
385
+ flush();
386
+ },
387
+ setCapabilities(overrides) {
388
+ app.setCapabilityOverrides(overrides);
389
+ flush();
390
+ },
391
+ setTheme(id) {
392
+ app.setTheme(id);
393
+ flush();
394
+ },
395
+ setShell(id) {
396
+ app.setShell(id);
397
+ flush();
398
+ },
399
+
400
+ advance(ms) {
401
+ app.animation.advance(ms);
402
+ flush();
403
+ },
404
+ flush,
405
+ async settle() {
406
+ // Two turns: one for the promise that is pending, one for whatever it
407
+ // schedules. Anything deeper is a test that should await explicitly.
408
+ await Promise.resolve();
409
+ await new Promise((resolve) => setImmediate(resolve));
410
+ // And one turn of the clock, which is the one that waits for the disk.
411
+ //
412
+ // `setImmediate` runs in the check phase, and the loop never blocks to
413
+ // get there - so a run of settles is a spin, costing microseconds and
414
+ // giving a `stat` or a `readdir` no opportunity to come back. Tests wait
415
+ // by counting settles, so the budget ends up denominated in turns of the
416
+ // loop while the thing being waited for is denominated in milliseconds:
417
+ // they pass on an idle machine and fail on a busy one, which is the
418
+ // shape of every flake this helper has produced. A pending timer makes
419
+ // the poll phase block, and pending I/O lands.
420
+ await new Promise((resolve) => setTimeout(resolve, 0));
421
+ flush();
422
+ },
423
+
424
+ focused() {
425
+ const id = app.focus.focused();
426
+ if (!id) return null;
427
+ const found = collect((n) => n.focused === true);
428
+ return found[0] ?? null;
429
+ },
430
+ focus(id) {
431
+ app.focus.focus(id);
432
+ flush();
433
+ },
434
+ tab: () => sendKey('tab'),
435
+ shiftTab: () => sendKey('shift+tab'),
436
+
437
+ errors: () => [...errors],
438
+ stats: () => app.stats(),
439
+
440
+ async unmount() {
441
+ await app.stop();
442
+ app.dispose();
443
+ },
444
+ };
445
+ }
446
+
447
+ function toElement(node: InspectorNode): Element {
448
+ return {
449
+ id: node.id,
450
+ component: node.component,
451
+ role: node.role,
452
+ label: node.label,
453
+ text: node.text,
454
+ rect: node.rect,
455
+ focused: node.focused === true,
456
+ props: node.props,
457
+ children: node.children.map(toElement),
458
+ };
459
+ }
460
+
461
+ /** `ctrl+shift+p`, `enter`, `a`. The inverse of `strokeOf`. */
462
+ export function chordToEvents(chord: string): KeyEvent[] {
463
+ return chord
464
+ .trim()
465
+ .split(/\s+/)
466
+ .map((stroke) => {
467
+ // The registry's parser, not a second one: a chord pressed here has to
468
+ // produce the stroke the registry stored it under, or a binding that
469
+ // works in a terminal fails in a test and nobody can tell which is right.
470
+ const { mods: names, key } = splitStroke(stroke);
471
+ const mods = new Set(names);
472
+ // `space` is named and printable at once - the decoder emits both, and a
473
+ // press that gave only the name could not be typed into a field.
474
+ const char = key === 'space' ? ' ' : ([...key].length === 1 ? key : undefined);
475
+
476
+ return {
477
+ type: 'key' as const,
478
+ name: key,
479
+ char,
480
+ ctrl: mods.has('ctrl') || mods.has('control'),
481
+ alt: mods.has('alt') || mods.has('option'),
482
+ shift: mods.has('shift'),
483
+ meta: mods.has('meta') || mods.has('cmd') || mods.has('super'),
484
+ raw: stroke,
485
+ handled: false,
486
+ };
487
+ });
488
+ }
489
+
490
+ export { strokeOf };
491
+
492
+ /**
493
+ * A stable text snapshot: the frame, with a ruler, so a diff shows which
494
+ * column moved rather than only that something did.
495
+ */
496
+ export function snapshot(harness: Harness, options: { ruler?: boolean } = {}): string {
497
+ const lines = harness.lines();
498
+ if (!options.ruler) return lines.join('\n');
499
+
500
+ const width = Math.max(0, ...lines.map((l) => l.length));
501
+ const tens = Array.from({ length: width }, (_, i) => (i % 10 === 0 ? String((i / 10) % 10) : ' ')).join('');
502
+ const ones = Array.from({ length: width }, (_, i) => String(i % 10)).join('');
503
+ return [tens, ones, ...lines].join('\n');
504
+ }