@termwright/conformance 0.2.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.
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Generic (uninstrumented) conformance fixture — origin spec §20.1.
3
+ *
4
+ * It imports nothing from termwright and never reads `TERMWRIGHT_ENDPOINT`, so
5
+ * a driver attached to it must fall back to a generic session: no handshake, no
6
+ * tree, no invented roles. Everything it can be asked to do is driven by real
7
+ * PTY bytes, and every observation it makes is printed back onto the screen so
8
+ * a test can assert on it without a semantic channel.
9
+ *
10
+ * Keys:
11
+ * ArrowUp/ArrowDown move the menu selection
12
+ * Enter activate the selected item
13
+ * m / M mouse click reporting / drag reporting on, again to disable
14
+ * b bracketed paste on/off
15
+ * f focus reporting on/off
16
+ * a enter/leave the alternate screen
17
+ * u toggle the Unicode sample row (emoji, ZWJ, combining, CJK)
18
+ * w toggle a long line, so reflow is observable on resize
19
+ * s stream 120 plain lines into the scrollback and stop repainting
20
+ * r resume repainting after `s`
21
+ * q exit 0 x exit 7
22
+ *
23
+ * Every other keystroke is echoed as `KEY:<hex bytes>`, which is how the suites
24
+ * prove that a key encoded by the driver reached the child as the exact bytes a
25
+ * terminal would have sent.
26
+ */
27
+
28
+ const ITEMS = ['Alpha', 'Beta', 'Gamma'];
29
+
30
+ // `--pidfile=<path>` writes this process's pid at startup. It is how a suite
31
+ // proves *which* children an owner actually killed: a pid can be probed with
32
+ // signal 0 long after the process that launched it stopped watching.
33
+ const pidfileArg = process.argv.find((argument) => argument.startsWith('--pidfile='));
34
+ if (pidfileArg !== undefined) {
35
+ const { writeFileSync } = await import('node:fs');
36
+ writeFileSync(pidfileArg.slice('--pidfile='.length), String(process.pid), 'utf8');
37
+ }
38
+
39
+ let selected = 0;
40
+ let activated = 'none';
41
+ // Four slots, filled from the start: the row layout must not move when the
42
+ // first event arrives, or every coordinate assertion would depend on history.
43
+ const events = ['none', 'none', 'none', 'none'];
44
+ const note = (event) => {
45
+ events.push(event);
46
+ while (events.length > 4) events.shift();
47
+ };
48
+ let mouse = 'off';
49
+ let bracketed = false;
50
+ let focusReporting = false;
51
+ let alternate = false;
52
+ let unicode = false;
53
+ let wide = false;
54
+ let painting = true;
55
+
56
+ const out = (text) => process.stdout.write(text);
57
+
58
+ function draw() {
59
+ if (!painting) return;
60
+ out('\x1b[H\x1b[J');
61
+ out('GENERIC READY\r\n');
62
+ for (const [index, item] of ITEMS.entries()) {
63
+ // The selected row is styled, so style predicates (fg/bg/attributes) have
64
+ // something to discriminate on that plain text does not.
65
+ out(index === selected ? `\x1b[1;32m> ${item}\x1b[0m\r\n` : ` ${item}\r\n`);
66
+ }
67
+ out('\x1b[31mRED\x1b[0m \x1b[4mUNDER\x1b[0m \x1b[44mONBLUE\x1b[0m\r\n');
68
+ out(`modes: mouse=${mouse} paste=${bracketed ? 'on' : 'off'} focus=${focusReporting ? 'on' : 'off'}`);
69
+ out(` alt=${alternate ? 'on' : 'off'}\r\n`);
70
+ out(`size: ${process.stdout.columns}x${process.stdout.rows}\r\n`);
71
+ out(`activated: ${activated}\r\n`);
72
+ if (alternate) out('ALT SCREEN\r\n');
73
+ for (const event of events) out(`ev: ${event}\r\n`);
74
+ // Printed last on purpose: an extra row above the event log would move every
75
+ // coordinate the suites assert on. Reports whether a variable set in the test
76
+ // process reached the child, which is what `envMode` decides.
77
+ out(`env: ${process.env['CONFORMANCE_ECHO'] ?? 'unset'}\r\n`);
78
+ // Which of the documented allowlist actually arrived. A child that lost PATH
79
+ // or TERM is broken in ways that look like a driver bug much later. The home
80
+ // variable is named per platform because the allowlist is: Windows has no
81
+ // `HOME`, and a program there uses the profile variables instead.
82
+ // `TERM` and `COLORTERM` are not inherited but set by the driver, so their
83
+ // values are the claim rather than their presence. Printed before `allow:`,
84
+ // which several suites wait on as the last line of the frame.
85
+ out(`term: ${process.env['TERM'] ?? 'unset'}/${process.env['COLORTERM'] ?? 'unset'}\r\n`);
86
+ const home = process.platform === 'win32' ? 'USERPROFILE' : 'HOME';
87
+ const allow = ['PATH', home, 'LANG']
88
+ .map((name) => `${name}=${process.env[name] === undefined ? 'no' : 'yes'}`)
89
+ .join(' ');
90
+ out(`allow: ${allow}\r\n`);
91
+ if (unicode) out('U: \u{1F600} \u{1F469}\u200D\u{1F469}\u200D\u{1F467} e\u0301 \u65E5\u672C\u8A9E ok\r\n');
92
+ if (wide) out(`W: ${'0123456789'.repeat(12)} END\r\n`);
93
+ }
94
+
95
+ function setMouse(mode) {
96
+ if (mouse !== 'off') out('\x1b[?1000l\x1b[?1002l\x1b[?1006l');
97
+ mouse = mode;
98
+ if (mode === 'click') out('\x1b[?1000h\x1b[?1006h');
99
+ if (mode === 'drag') out('\x1b[?1002h\x1b[?1006h');
100
+ }
101
+
102
+ function hex(text) {
103
+ return [...Buffer.from(text, 'utf8')].map((byte) => byte.toString(16).padStart(2, '0')).join(' ');
104
+ }
105
+
106
+ function command(key) {
107
+ switch (key) {
108
+ case 'q':
109
+ out('BYE\r\n');
110
+ process.exit(0);
111
+ return true;
112
+ case 'x':
113
+ out('BYE\r\n');
114
+ process.exit(7);
115
+ return true;
116
+ case '\r':
117
+ activated = ITEMS[selected];
118
+ return true;
119
+ case 'm':
120
+ setMouse(mouse === 'click' ? 'off' : 'click');
121
+ return true;
122
+ case 'M':
123
+ setMouse(mouse === 'drag' ? 'off' : 'drag');
124
+ return true;
125
+ case 'b':
126
+ bracketed = !bracketed;
127
+ out(bracketed ? '\x1b[?2004h' : '\x1b[?2004l');
128
+ return true;
129
+ case 'f':
130
+ focusReporting = !focusReporting;
131
+ out(focusReporting ? '\x1b[?1004h' : '\x1b[?1004l');
132
+ return true;
133
+ case 'a':
134
+ alternate = !alternate;
135
+ out(alternate ? '\x1b[?1049h' : '\x1b[?1049l');
136
+ // Leaving the alternate screen must leave the restored normal buffer
137
+ // exactly as the terminal restored it — repainting over it would hide
138
+ // whether the restore happened at all.
139
+ painting = alternate;
140
+ return true;
141
+ case 'u':
142
+ unicode = !unicode;
143
+ return true;
144
+ case 'w':
145
+ wide = !wide;
146
+ return true;
147
+ case 's': {
148
+ painting = false;
149
+ out('\x1b[H\x1b[J');
150
+ for (let line = 1; line <= 120; line += 1) out(`line ${line}\r\n`);
151
+ out('SCROLL DONE\r\n');
152
+ return true;
153
+ }
154
+ case 'r':
155
+ painting = true;
156
+ return true;
157
+ default:
158
+ return false;
159
+ }
160
+ }
161
+
162
+ /** Consumes one event from the head of `rest`; returns the unconsumed tail. */
163
+ function step(rest) {
164
+ if (bracketed && rest.startsWith('\x1b[200~')) {
165
+ const end = rest.indexOf('\x1b[201~');
166
+ if (end < 0) return null; // incomplete paste: wait for the rest
167
+ note(`PASTE:${rest.slice(6, end)}`);
168
+ return rest.slice(end + 6);
169
+ }
170
+ if (rest.startsWith('\x1b[I')) {
171
+ note('FOCUS:in');
172
+ return rest.slice(3);
173
+ }
174
+ if (rest.startsWith('\x1b[O')) {
175
+ note('FOCUS:out');
176
+ return rest.slice(3);
177
+ }
178
+ const report = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/u.exec(rest);
179
+ if (report !== null) {
180
+ const [, button, column, row, final] = report;
181
+ const kind = final === 'm' ? 'release' : Number(button) >= 64 ? 'wheel' : 'press';
182
+ note(`MOUSE ${kind} b=${button} c=${column} r=${row}`);
183
+ if (kind === 'press' && Number(button) < 32) {
184
+ // The fixture decides what a double click is, so a test can wait for one
185
+ // event instead of counting two identical ones — a count on a repainted
186
+ // screen is satisfied by the first of the pair as soon as it lands.
187
+ const at = Date.now();
188
+ const cell = `c=${column} r=${row}`;
189
+ if (lastPress.cell === cell && at - lastPress.at < 500) note(`MOUSE dblclick ${cell}`);
190
+ lastPress = { cell, at };
191
+ }
192
+ return rest.slice(report[0].length);
193
+ }
194
+ if (rest.startsWith('\x1b[A')) {
195
+ selected = (selected + ITEMS.length - 1) % ITEMS.length;
196
+ return rest.slice(3);
197
+ }
198
+ if (rest.startsWith('\x1b[B')) {
199
+ selected = (selected + 1) % ITEMS.length;
200
+ return rest.slice(3);
201
+ }
202
+ // An unrecognised escape sequence is reported as one event, not as the four
203
+ // stray bytes it is made of, so a test can assert on the encoding of a key.
204
+ const escape = /^\x1b(?:\[[0-9;?]*[ -/]*[@-~]|O[@-~]|.)/u.exec(rest);
205
+ if (escape !== null) {
206
+ note(`KEY:${hex(escape[0])}`);
207
+ return rest.slice(escape[0].length);
208
+ }
209
+ const head = [...rest][0] ?? rest[0];
210
+ if (!command(head)) note(`KEY:${hex(head)}`);
211
+ return rest.slice(head.length);
212
+ }
213
+
214
+ let lastPress = { cell: '', at: 0 };
215
+ let pending = '';
216
+
217
+ process.stdout.write('\x1b]0;generic-app\x07');
218
+ process.stdin.setRawMode?.(true);
219
+ process.stdin.resume();
220
+ process.stdin.on('data', (chunk) => {
221
+ pending += chunk.toString('utf8');
222
+ for (;;) {
223
+ if (pending.length === 0) break;
224
+ const rest = step(pending);
225
+ if (rest === null) break; // incomplete sequence
226
+ pending = rest;
227
+ }
228
+ draw();
229
+ });
230
+
231
+ process.stdout.on('resize', () => {
232
+ note(`RESIZE:${process.stdout.columns}x${process.stdout.rows}`);
233
+ draw();
234
+ });
235
+
236
+ draw();
@@ -0,0 +1,23 @@
1
+ /** Small normal-render Ink process used only by cross-package readiness tests. */
2
+
3
+ import {createElement, useEffect, useRef} from 'react';
4
+ import {Box, Text, render, useApp} from 'ink';
5
+ import {useSemantic} from '@termwright/ink';
6
+
7
+ function App() {
8
+ const {exit} = useApp();
9
+ const status = useRef(null);
10
+ useSemantic(status, {role: 'status', name: 'Ready', testId: 'status'});
11
+ useEffect(() => {
12
+ const timer = setTimeout(exit, 2_000);
13
+ return () => clearTimeout(timer);
14
+ }, [exit]);
15
+ return createElement(
16
+ Box,
17
+ {ref: status, flexDirection: 'column'},
18
+ createElement(Text, null, 'Termwright Conformance'),
19
+ );
20
+ }
21
+
22
+ const app = render(createElement(App), {alternateScreen: true, interactive: true});
23
+ await app.waitUntilExit();
@@ -0,0 +1,97 @@
1
+ /**
2
+ * A shell-shaped fixture for `waitForReady`.
3
+ *
4
+ * It emits the OSC 133 shell-integration marks that VS Code, iTerm2, WezTerm
5
+ * and fish already agree on, so the driver's preferred readiness strategy has
6
+ * something real to read:
7
+ *
8
+ * `OSC 133 ; A` prompt starts `OSC 133 ; C` command starts
9
+ * `OSC 133 ; B` input starts `OSC 133 ; D ; <code>` command finished
10
+ *
11
+ * It is deliberately not a shell: a real one would drag its own startup files,
12
+ * prompt and locale into the assertion. Commands:
13
+ *
14
+ * `hang` emits C and never D — the "still running" timeout case
15
+ * `quit` exits 0
16
+ * anything else runs for `--work=<ms>` (default 200) and finishes with D;0
17
+ * `fail` finishes with D;3, so a non-zero status is observable
18
+ *
19
+ * Pass `--marks=off` to emit no marks at all: same screen, same timing, but the
20
+ * driver has to fall back to its settled-screen heuristic.
21
+ */
22
+
23
+ const workArg = process.argv.find((argument) => argument.startsWith('--work='));
24
+ const work = workArg === undefined ? 200 : Number(workArg.slice('--work='.length));
25
+ const marks = !process.argv.includes('--marks=off');
26
+
27
+ let line = '';
28
+ let running = false;
29
+
30
+ const out = (text) => process.stdout.write(text);
31
+ const mark = (payload) => {
32
+ if (marks) out(`\x1b]133;${payload}\x07`);
33
+ };
34
+
35
+ function prompt() {
36
+ mark('A');
37
+ out('$ ');
38
+ mark('B');
39
+ }
40
+
41
+ function finish(code) {
42
+ running = false;
43
+ mark(`D;${code}`);
44
+ out('\r\n');
45
+ prompt();
46
+ }
47
+
48
+ function run(command) {
49
+ running = true;
50
+ mark('C');
51
+ out('\r\n');
52
+ // Announced before the work starts, so a test can wait for "the command is
53
+ // running" without racing the mark it is trying to observe.
54
+ out(`RUNNING ${command}\r\n`);
55
+
56
+ if (command === 'hang') {
57
+ // Emits C and never D: a command that is still running is not readiness,
58
+ // and the driver must say so rather than time out silently.
59
+ out('HANGING\r\n');
60
+ return;
61
+ }
62
+ const timer = setTimeout(() => {
63
+ out(`ran ${command}\r\n`);
64
+ finish(command === 'fail' ? 3 : 0);
65
+ }, work);
66
+ timer.unref?.();
67
+ }
68
+
69
+ process.stdout.write('\x1b]0;prompt-app\x07');
70
+ process.stdout.write('PROMPT APP\r\n');
71
+ prompt();
72
+
73
+ process.stdin.setRawMode?.(true);
74
+ process.stdin.resume();
75
+ process.stdin.on('data', (chunk) => {
76
+ for (const character of chunk.toString('utf8')) {
77
+ if (character === '\r' || character === '\n') {
78
+ const command = line.trim();
79
+ line = '';
80
+ if (command === 'quit') {
81
+ out('\r\nBYE\r\n');
82
+ process.exit(0);
83
+ }
84
+ if (running) continue; // a busy shell does not take a new command
85
+ run(command.length === 0 ? 'nothing' : command);
86
+ continue;
87
+ }
88
+ if (character === '\x7f') {
89
+ line = line.slice(0, -1);
90
+ continue;
91
+ }
92
+ if (character >= ' ') {
93
+ line += character;
94
+ out(character);
95
+ }
96
+ }
97
+ });