@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.
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/dist/index.d.ts +351 -0
- package/dist/index.js +1129 -0
- package/dist/index.js.map +1 -0
- package/package.json +56 -0
- package/src/fixtures/adversarial-peer.mjs +567 -0
- package/src/fixtures/generic-app.mjs +236 -0
- package/src/fixtures/ink-probe-app.mjs +23 -0
- package/src/fixtures/prompt-app.mjs +97 -0
|
@@ -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
|
+
});
|