@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 +21 -0
- package/README.md +64 -0
- package/dist/index.d.ts +154 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +314 -0
- package/package.json +55 -0
- package/src/index.ts +504 -0
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/>
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|