@rezi-ui/node 0.1.0-alpha.17 → 0.1.0-alpha.18
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/package.json +6 -4
- package/dist/__e2e__/fixtures/terminal-app.d.ts +0 -2
- package/dist/__e2e__/fixtures/terminal-app.d.ts.map +0 -1
- package/dist/__e2e__/fixtures/terminal-app.js +0 -42
- package/dist/__e2e__/fixtures/terminal-app.js.map +0 -1
- package/dist/__e2e__/fixtures/terminal-io-contract-target.d.ts +0 -2
- package/dist/__e2e__/fixtures/terminal-io-contract-target.d.ts.map +0 -1
- package/dist/__e2e__/fixtures/terminal-io-contract-target.js +0 -237
- package/dist/__e2e__/fixtures/terminal-io-contract-target.js.map +0 -1
- package/dist/__e2e__/runtime-reduced.e2e.test.d.ts +0 -2
- package/dist/__e2e__/runtime-reduced.e2e.test.d.ts.map +0 -1
- package/dist/__e2e__/runtime-reduced.e2e.test.js +0 -69
- package/dist/__e2e__/runtime-reduced.e2e.test.js.map +0 -1
- package/dist/__e2e__/terminal_io_contract.e2e.test.d.ts +0 -2
- package/dist/__e2e__/terminal_io_contract.e2e.test.d.ts.map +0 -1
- package/dist/__e2e__/terminal_io_contract.e2e.test.js +0 -654
- package/dist/__e2e__/terminal_io_contract.e2e.test.js.map +0 -1
- package/dist/__tests__/config_guards.test.d.ts +0 -2
- package/dist/__tests__/config_guards.test.d.ts.map +0 -1
- package/dist/__tests__/config_guards.test.js +0 -102
- package/dist/__tests__/config_guards.test.js.map +0 -1
- package/dist/__tests__/emoji_width_policy.test.d.ts +0 -2
- package/dist/__tests__/emoji_width_policy.test.d.ts.map +0 -1
- package/dist/__tests__/emoji_width_policy.test.js +0 -100
- package/dist/__tests__/emoji_width_policy.test.js.map +0 -1
- package/dist/__tests__/repro_recorder.test.d.ts +0 -2
- package/dist/__tests__/repro_recorder.test.d.ts.map +0 -1
- package/dist/__tests__/repro_recorder.test.js +0 -185
- package/dist/__tests__/repro_recorder.test.js.map +0 -1
- package/dist/__tests__/worker_integration.test.d.ts +0 -2
- package/dist/__tests__/worker_integration.test.d.ts.map +0 -1
- package/dist/__tests__/worker_integration.test.js +0 -673
- package/dist/__tests__/worker_integration.test.js.map +0 -1
|
@@ -1,654 +0,0 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
import net from "node:net";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { parseEventBatchV1 } from "@rezi-ui/core";
|
|
5
|
-
import { ZR_KEY_BACKSPACE, ZR_KEY_ENTER, ZR_KEY_ESCAPE, ZR_KEY_TAB, ZR_KEY_UP, ZR_MOD_ALT, ZR_MOD_CTRL, ZR_MOD_META, ZR_MOD_SHIFT, } from "@rezi-ui/core/keybindings";
|
|
6
|
-
import { assert, test } from "@rezi-ui/testkit";
|
|
7
|
-
const ZR_KEY_FOCUS_IN = 30;
|
|
8
|
-
const ZR_KEY_FOCUS_OUT = 31;
|
|
9
|
-
const CONTROL_COMMAND_TIMEOUT_MS = 5_000;
|
|
10
|
-
function closeServerQuiet(server) {
|
|
11
|
-
return new Promise((resolve) => {
|
|
12
|
-
server.close(() => resolve());
|
|
13
|
-
});
|
|
14
|
-
}
|
|
15
|
-
function delay(ms) {
|
|
16
|
-
return new Promise((resolve) => {
|
|
17
|
-
setTimeout(resolve, ms);
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
function loadPtySpawn() {
|
|
21
|
-
try {
|
|
22
|
-
const require = createRequire(import.meta.url);
|
|
23
|
-
const mod = require("node-pty");
|
|
24
|
-
const rec = mod;
|
|
25
|
-
return typeof rec.spawn === "function" ? rec.spawn : null;
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
return null;
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
function asErrorDetail(err) {
|
|
32
|
-
return err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
33
|
-
}
|
|
34
|
-
function terminatePtyBestEffort(pty) {
|
|
35
|
-
try {
|
|
36
|
-
pty.kill();
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
// fall through
|
|
41
|
-
}
|
|
42
|
-
try {
|
|
43
|
-
pty.kill("SIGTERM");
|
|
44
|
-
}
|
|
45
|
-
catch {
|
|
46
|
-
// best-effort cleanup
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
class ContractHarness {
|
|
50
|
-
caps;
|
|
51
|
-
#pty;
|
|
52
|
-
#socket;
|
|
53
|
-
#server;
|
|
54
|
-
#nextId = 1;
|
|
55
|
-
#lineBuf = "";
|
|
56
|
-
#closed = false;
|
|
57
|
-
#exitObserved = false;
|
|
58
|
-
#pending = new Map();
|
|
59
|
-
constructor(pty, socket, server, caps) {
|
|
60
|
-
this.#pty = pty;
|
|
61
|
-
this.#socket = socket;
|
|
62
|
-
this.#server = server;
|
|
63
|
-
this.caps = caps;
|
|
64
|
-
}
|
|
65
|
-
static async create(cfg = {}) {
|
|
66
|
-
const ptySpawn = loadPtySpawn();
|
|
67
|
-
if (ptySpawn === null) {
|
|
68
|
-
throw new Error('terminal-io-contract e2e requires "node-pty". Install: npm i -w @rezi-ui/node -D node-pty');
|
|
69
|
-
}
|
|
70
|
-
const server = net.createServer();
|
|
71
|
-
await new Promise((resolve, reject) => {
|
|
72
|
-
server.once("error", reject);
|
|
73
|
-
server.listen(0, "127.0.0.1", () => {
|
|
74
|
-
server.off("error", reject);
|
|
75
|
-
resolve();
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
const address = server.address();
|
|
79
|
-
if (address === null || typeof address === "string") {
|
|
80
|
-
server.close();
|
|
81
|
-
throw new Error("terminal-io-contract: failed to obtain control server address");
|
|
82
|
-
}
|
|
83
|
-
const targetPath = fileURLToPath(new URL("./fixtures/terminal-io-contract-target.js", import.meta.url));
|
|
84
|
-
const cols = cfg.cols ?? 120;
|
|
85
|
-
const rows = cfg.rows ?? 40;
|
|
86
|
-
let pty = null;
|
|
87
|
-
let socket = null;
|
|
88
|
-
let ptyOutput = "";
|
|
89
|
-
try {
|
|
90
|
-
const spawnedPty = ptySpawn(process.execPath, [targetPath], {
|
|
91
|
-
name: process.platform === "win32" ? "xterm" : "xterm-256color",
|
|
92
|
-
cols,
|
|
93
|
-
rows,
|
|
94
|
-
cwd: process.cwd(),
|
|
95
|
-
env: {
|
|
96
|
-
...process.env,
|
|
97
|
-
TERM: process.platform === "win32" ? "xterm" : "xterm-256color",
|
|
98
|
-
REZI_TERMINAL_IO_CTRL_PORT: String(address.port),
|
|
99
|
-
REZI_TERMINAL_IO_NATIVE_CONFIG: JSON.stringify(cfg.nativeConfig ?? {}),
|
|
100
|
-
...(cfg.env ?? {}),
|
|
101
|
-
},
|
|
102
|
-
});
|
|
103
|
-
pty = spawnedPty;
|
|
104
|
-
spawnedPty.onData((chunk) => {
|
|
105
|
-
ptyOutput = `${ptyOutput}${chunk}`.slice(-4_096);
|
|
106
|
-
});
|
|
107
|
-
const ctrlSocket = await new Promise((resolve, reject) => {
|
|
108
|
-
const handshakeTimer = setTimeout(() => {
|
|
109
|
-
cleanup();
|
|
110
|
-
reject(new Error(`terminal-io-contract target handshake timeout: no control socket connection; output=${JSON.stringify(ptyOutput)}`));
|
|
111
|
-
}, 2_000);
|
|
112
|
-
const onConn = (s) => {
|
|
113
|
-
cleanup();
|
|
114
|
-
resolve(s);
|
|
115
|
-
};
|
|
116
|
-
const onErr = (err) => {
|
|
117
|
-
cleanup();
|
|
118
|
-
reject(err);
|
|
119
|
-
};
|
|
120
|
-
const cleanup = () => {
|
|
121
|
-
clearTimeout(handshakeTimer);
|
|
122
|
-
server.off("connection", onConn);
|
|
123
|
-
server.off("error", onErr);
|
|
124
|
-
};
|
|
125
|
-
server.once("connection", onConn);
|
|
126
|
-
server.once("error", onErr);
|
|
127
|
-
});
|
|
128
|
-
socket = ctrlSocket;
|
|
129
|
-
ctrlSocket.setEncoding("utf8");
|
|
130
|
-
const ready = await new Promise((resolve, reject) => {
|
|
131
|
-
let buf = "";
|
|
132
|
-
const onData = (chunk) => {
|
|
133
|
-
buf += chunk;
|
|
134
|
-
for (;;) {
|
|
135
|
-
const idx = buf.indexOf("\n");
|
|
136
|
-
if (idx < 0)
|
|
137
|
-
break;
|
|
138
|
-
const line = buf.slice(0, idx);
|
|
139
|
-
buf = buf.slice(idx + 1);
|
|
140
|
-
const msg = parseControlMessage(line);
|
|
141
|
-
if (msg === null)
|
|
142
|
-
continue;
|
|
143
|
-
if (msg.type === "fatal") {
|
|
144
|
-
cleanup();
|
|
145
|
-
reject(new Error(`terminal-io-contract target fatal during handshake: ${msg.detail}`));
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
if (msg.type === "ready") {
|
|
149
|
-
cleanup();
|
|
150
|
-
resolve(msg);
|
|
151
|
-
return;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
};
|
|
155
|
-
const onErr = (err) => {
|
|
156
|
-
cleanup();
|
|
157
|
-
reject(err);
|
|
158
|
-
};
|
|
159
|
-
const cleanup = () => {
|
|
160
|
-
ctrlSocket.off("data", onData);
|
|
161
|
-
ctrlSocket.off("error", onErr);
|
|
162
|
-
};
|
|
163
|
-
ctrlSocket.on("data", onData);
|
|
164
|
-
ctrlSocket.on("error", onErr);
|
|
165
|
-
});
|
|
166
|
-
const harness = new ContractHarness(spawnedPty, ctrlSocket, server, ready.caps);
|
|
167
|
-
harness.#attachControlListeners();
|
|
168
|
-
return harness;
|
|
169
|
-
}
|
|
170
|
-
catch (err) {
|
|
171
|
-
if (socket !== null) {
|
|
172
|
-
socket.destroy();
|
|
173
|
-
}
|
|
174
|
-
if (pty !== null) {
|
|
175
|
-
terminatePtyBestEffort(pty);
|
|
176
|
-
}
|
|
177
|
-
await closeServerQuiet(server);
|
|
178
|
-
throw err;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
#attachControlListeners() {
|
|
182
|
-
this.#socket.on("data", (chunk) => {
|
|
183
|
-
this.#lineBuf += chunk;
|
|
184
|
-
for (;;) {
|
|
185
|
-
const idx = this.#lineBuf.indexOf("\n");
|
|
186
|
-
if (idx < 0)
|
|
187
|
-
break;
|
|
188
|
-
const line = this.#lineBuf.slice(0, idx);
|
|
189
|
-
this.#lineBuf = this.#lineBuf.slice(idx + 1);
|
|
190
|
-
const msg = parseControlMessage(line);
|
|
191
|
-
if (msg === null)
|
|
192
|
-
continue;
|
|
193
|
-
if (msg.type === "fatal") {
|
|
194
|
-
this.#rejectPending(new Error(`terminal-io-contract target fatal: ${msg.detail}`));
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
if (msg.type !== "response")
|
|
198
|
-
continue;
|
|
199
|
-
const waiter = this.#pending.get(msg.id);
|
|
200
|
-
if (waiter === undefined)
|
|
201
|
-
continue;
|
|
202
|
-
this.#pending.delete(msg.id);
|
|
203
|
-
if (msg.ok) {
|
|
204
|
-
waiter.resolve(msg.result);
|
|
205
|
-
}
|
|
206
|
-
else {
|
|
207
|
-
waiter.reject(new Error(msg.error));
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
});
|
|
211
|
-
this.#socket.on("error", (err) => {
|
|
212
|
-
this.#rejectPending(new Error(`terminal-io-contract control socket error: ${asErrorDetail(err)}`));
|
|
213
|
-
});
|
|
214
|
-
this.#pty.onExit(({ exitCode, signal }) => {
|
|
215
|
-
const msg = `terminal-io-contract target exited: exit=${String(exitCode)} signal=${String(signal ?? "")}`;
|
|
216
|
-
this.#exitObserved = true;
|
|
217
|
-
this.#rejectPending(new Error(msg));
|
|
218
|
-
this.#closed = true;
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
#rejectPending(err) {
|
|
222
|
-
for (const waiter of this.#pending.values()) {
|
|
223
|
-
waiter.reject(err);
|
|
224
|
-
}
|
|
225
|
-
this.#pending.clear();
|
|
226
|
-
}
|
|
227
|
-
async #sendCommand(cmd, timeoutMs = CONTROL_COMMAND_TIMEOUT_MS) {
|
|
228
|
-
if (this.#closed)
|
|
229
|
-
throw new Error("terminal-io-contract harness is closed");
|
|
230
|
-
const id = String(this.#nextId++);
|
|
231
|
-
const payload = { ...cmd, id };
|
|
232
|
-
const result = new Promise((resolve, reject) => {
|
|
233
|
-
this.#pending.set(id, { resolve, reject });
|
|
234
|
-
});
|
|
235
|
-
const timeout = setTimeout(() => {
|
|
236
|
-
const waiter = this.#pending.get(id);
|
|
237
|
-
if (waiter === undefined)
|
|
238
|
-
return;
|
|
239
|
-
this.#pending.delete(id);
|
|
240
|
-
waiter.reject(new Error(`terminal-io-contract command timeout: cmd=${cmd.cmd} timeoutMs=${String(timeoutMs)}`));
|
|
241
|
-
}, timeoutMs);
|
|
242
|
-
this.#socket.write(`${JSON.stringify(payload)}\n`);
|
|
243
|
-
try {
|
|
244
|
-
return await result;
|
|
245
|
-
}
|
|
246
|
-
finally {
|
|
247
|
-
clearTimeout(timeout);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
writeRaw(bytes) {
|
|
251
|
-
this.#pty.write(bytes);
|
|
252
|
-
}
|
|
253
|
-
resize(cols, rows) {
|
|
254
|
-
this.#pty.resize(cols, rows);
|
|
255
|
-
}
|
|
256
|
-
async pollOnce() {
|
|
257
|
-
const response = await this.#sendCommand({ cmd: "pollOnce" });
|
|
258
|
-
if (response.kind !== "pollOnce") {
|
|
259
|
-
throw new Error(`terminal-io-contract: expected pollOnce result, got ${response.kind}`);
|
|
260
|
-
}
|
|
261
|
-
const bytes = Uint8Array.from(Buffer.from(response.bytesBase64, "base64"));
|
|
262
|
-
const parsed = parseEventBatchV1(bytes);
|
|
263
|
-
if (!parsed.ok) {
|
|
264
|
-
assert.fail(`parseEventBatchV1 failed: code=${parsed.error.code} offset=${String(parsed.error.offset)} detail=${parsed.error.detail}`);
|
|
265
|
-
}
|
|
266
|
-
return {
|
|
267
|
-
events: parsed.value.events,
|
|
268
|
-
droppedBatches: response.droppedBatches,
|
|
269
|
-
};
|
|
270
|
-
}
|
|
271
|
-
async stop() {
|
|
272
|
-
if (this.#closed)
|
|
273
|
-
return;
|
|
274
|
-
try {
|
|
275
|
-
await this.#sendCommand({ cmd: "stop" });
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
// best-effort stop
|
|
279
|
-
}
|
|
280
|
-
this.#closed = true;
|
|
281
|
-
for (let i = 0; i < 200; i++) {
|
|
282
|
-
if (this.#exitObserved)
|
|
283
|
-
break;
|
|
284
|
-
await delay(10);
|
|
285
|
-
}
|
|
286
|
-
if (!this.#exitObserved) {
|
|
287
|
-
terminatePtyBestEffort(this.#pty);
|
|
288
|
-
}
|
|
289
|
-
try {
|
|
290
|
-
this.#socket.destroy();
|
|
291
|
-
}
|
|
292
|
-
catch {
|
|
293
|
-
// ignore
|
|
294
|
-
}
|
|
295
|
-
try {
|
|
296
|
-
await closeServerQuiet(this.#server);
|
|
297
|
-
}
|
|
298
|
-
catch {
|
|
299
|
-
// ignore
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
function parseControlMessage(line) {
|
|
304
|
-
const trimmed = line.trim();
|
|
305
|
-
if (trimmed.length === 0)
|
|
306
|
-
return null;
|
|
307
|
-
let parsed;
|
|
308
|
-
try {
|
|
309
|
-
parsed = JSON.parse(trimmed);
|
|
310
|
-
}
|
|
311
|
-
catch {
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
if (typeof parsed !== "object" || parsed === null)
|
|
315
|
-
return null;
|
|
316
|
-
const rec = parsed;
|
|
317
|
-
if (rec.type === "ready") {
|
|
318
|
-
if (typeof rec.caps !== "object" || rec.caps === null)
|
|
319
|
-
return null;
|
|
320
|
-
return rec;
|
|
321
|
-
}
|
|
322
|
-
if (rec.type === "fatal") {
|
|
323
|
-
if (typeof rec.detail !== "string")
|
|
324
|
-
return null;
|
|
325
|
-
return rec;
|
|
326
|
-
}
|
|
327
|
-
if (rec.type === "response") {
|
|
328
|
-
if (typeof rec.id !== "string")
|
|
329
|
-
return null;
|
|
330
|
-
if (typeof rec.ok !== "boolean")
|
|
331
|
-
return null;
|
|
332
|
-
return rec;
|
|
333
|
-
}
|
|
334
|
-
return null;
|
|
335
|
-
}
|
|
336
|
-
function isKey(ev, key, mods) {
|
|
337
|
-
return ev.kind === "key" && ev.key === key && ev.mods === mods && ev.action === "down";
|
|
338
|
-
}
|
|
339
|
-
function isText(ev, codepoint) {
|
|
340
|
-
return ev.kind === "text" && ev.codepoint === codepoint;
|
|
341
|
-
}
|
|
342
|
-
async function collectEvents(harness, maxPolls, stopWhen) {
|
|
343
|
-
const events = [];
|
|
344
|
-
for (let i = 0; i < maxPolls; i++) {
|
|
345
|
-
const batch = await harness.pollOnce();
|
|
346
|
-
events.push(...batch.events);
|
|
347
|
-
if (stopWhen(events))
|
|
348
|
-
return events;
|
|
349
|
-
}
|
|
350
|
-
return events;
|
|
351
|
-
}
|
|
352
|
-
async function writeAndCollectUntil(harness, bytes, maxPolls, stopWhen) {
|
|
353
|
-
harness.writeRaw(bytes);
|
|
354
|
-
return await collectEvents(harness, maxPolls, stopWhen);
|
|
355
|
-
}
|
|
356
|
-
async function writeAndCollectUntilWithRetries(harness, bytes, maxPolls, stopWhen, maxAttempts) {
|
|
357
|
-
const combined = [];
|
|
358
|
-
for (let i = 0; i < maxAttempts; i++) {
|
|
359
|
-
const events = await writeAndCollectUntil(harness, bytes, maxPolls, stopWhen);
|
|
360
|
-
combined.push(...events);
|
|
361
|
-
if (stopWhen(combined))
|
|
362
|
-
return combined;
|
|
363
|
-
}
|
|
364
|
-
return combined;
|
|
365
|
-
}
|
|
366
|
-
function findIndex(events, pred) {
|
|
367
|
-
for (let i = 0; i < events.length; i++) {
|
|
368
|
-
const ev = events[i];
|
|
369
|
-
if (ev !== undefined && pred(ev))
|
|
370
|
-
return i;
|
|
371
|
-
}
|
|
372
|
-
return -1;
|
|
373
|
-
}
|
|
374
|
-
async function createHarnessOrSkip(t, cfg = {}) {
|
|
375
|
-
let lastErr = null;
|
|
376
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
377
|
-
try {
|
|
378
|
-
return await ContractHarness.create(cfg);
|
|
379
|
-
}
|
|
380
|
-
catch (err) {
|
|
381
|
-
lastErr = err;
|
|
382
|
-
const detail = asErrorDetail(err);
|
|
383
|
-
if (attempt === 0 &&
|
|
384
|
-
(detail.includes("exited before handshake") || detail.includes("handshake timeout"))) {
|
|
385
|
-
await delay(50);
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
t.skip(`terminal-io-contract harness unavailable: ${detail}`);
|
|
389
|
-
return null;
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
t.skip(`terminal-io-contract harness unavailable: ${asErrorDetail(lastErr)}`);
|
|
393
|
-
return null;
|
|
394
|
-
}
|
|
395
|
-
test("terminal io contract: keyboard + paste + focus + mouse + resize + split reads", async (t) => {
|
|
396
|
-
if (process.platform === "win32") {
|
|
397
|
-
t.skip("full terminal contract assertions run on Unix PTY; Windows uses ConPTY-specific coverage");
|
|
398
|
-
return;
|
|
399
|
-
}
|
|
400
|
-
const harness = await createHarnessOrSkip(t, {
|
|
401
|
-
env: {
|
|
402
|
-
ZIREAEL_CAP_MOUSE: "1",
|
|
403
|
-
ZIREAEL_CAP_BRACKETED_PASTE: "1",
|
|
404
|
-
ZIREAEL_CAP_FOCUS_EVENTS: "1",
|
|
405
|
-
},
|
|
406
|
-
});
|
|
407
|
-
if (harness === null)
|
|
408
|
-
return;
|
|
409
|
-
try {
|
|
410
|
-
// Initial resize is part of the contract and must arrive before explicit resizes.
|
|
411
|
-
const startupEvents = await collectEvents(harness, 20, (xs) => {
|
|
412
|
-
return findIndex(xs, (ev) => ev.kind === "resize") >= 0;
|
|
413
|
-
});
|
|
414
|
-
assert.ok(findIndex(startupEvents, (ev) => ev.kind === "resize") >= 0, "missing initial resize event");
|
|
415
|
-
// Wait for at least one scheduler tick after initial resize before key assertions.
|
|
416
|
-
const readyTicks = await collectEvents(harness, 40, (xs) => {
|
|
417
|
-
return findIndex(xs, (ev) => ev.kind === "tick") >= 0;
|
|
418
|
-
});
|
|
419
|
-
assert.ok(findIndex(readyTicks, (ev) => ev.kind === "tick") >= 0, "no post-startup tick observed before key assertions");
|
|
420
|
-
const ctrlUp = await writeAndCollectUntilWithRetries(harness, "\x1b[1;5A", 40, (xs) => {
|
|
421
|
-
return findIndex(xs, (ev) => isKey(ev, ZR_KEY_UP, ZR_MOD_CTRL)) >= 0;
|
|
422
|
-
}, 3);
|
|
423
|
-
assert.ok(findIndex(ctrlUp, (ev) => isKey(ev, ZR_KEY_UP, ZR_MOD_CTRL)) >= 0, `missing Ctrl+Up; keys=${ctrlUp
|
|
424
|
-
.filter((ev) => ev.kind === "key")
|
|
425
|
-
.map((ev) => `${String(ev.key)}/${String(ev.mods)}`)
|
|
426
|
-
.join(",")}`);
|
|
427
|
-
const shiftTab = await writeAndCollectUntil(harness, "\x1b[Z", 40, (xs) => {
|
|
428
|
-
return findIndex(xs, (ev) => isKey(ev, ZR_KEY_TAB, ZR_MOD_SHIFT)) >= 0;
|
|
429
|
-
});
|
|
430
|
-
assert.ok(findIndex(shiftTab, (ev) => isKey(ev, ZR_KEY_TAB, ZR_MOD_SHIFT)) >= 0, "missing Shift+Tab");
|
|
431
|
-
const ctrlTab = await writeAndCollectUntil(harness, "\x1b[9;5u", 40, (xs) => {
|
|
432
|
-
return findIndex(xs, (ev) => isKey(ev, ZR_KEY_TAB, ZR_MOD_CTRL)) >= 0;
|
|
433
|
-
});
|
|
434
|
-
assert.ok(findIndex(ctrlTab, (ev) => isKey(ev, ZR_KEY_TAB, ZR_MOD_CTRL)) >= 0, "missing Ctrl+Tab CSI-u");
|
|
435
|
-
const ctrlEnter = await writeAndCollectUntil(harness, "\x1b[13;5u", 40, (xs) => {
|
|
436
|
-
return findIndex(xs, (ev) => isKey(ev, ZR_KEY_ENTER, ZR_MOD_CTRL)) >= 0;
|
|
437
|
-
});
|
|
438
|
-
assert.ok(findIndex(ctrlEnter, (ev) => isKey(ev, ZR_KEY_ENTER, ZR_MOD_CTRL)) >= 0, "missing Ctrl+Enter CSI-u");
|
|
439
|
-
const ctrlBackspace = await writeAndCollectUntil(harness, "\x1b[127;5u", 40, (xs) => {
|
|
440
|
-
return findIndex(xs, (ev) => isKey(ev, ZR_KEY_BACKSPACE, ZR_MOD_CTRL)) >= 0;
|
|
441
|
-
});
|
|
442
|
-
assert.ok(findIndex(ctrlBackspace, (ev) => isKey(ev, ZR_KEY_BACKSPACE, ZR_MOD_CTRL)) >= 0, "missing Ctrl+Backspace CSI-u");
|
|
443
|
-
const altPolicy = await writeAndCollectUntil(harness, "\x1b[97;3u", 40, (xs) => {
|
|
444
|
-
const esc = findIndex(xs, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0));
|
|
445
|
-
const payload = findIndex(xs, (ev) => isText(ev, 97) || (ev.kind === "key" && ev.key === 0 && ev.mods === ZR_MOD_ALT));
|
|
446
|
-
return esc >= 0 && payload >= 0;
|
|
447
|
-
});
|
|
448
|
-
const altEscIndex = findIndex(altPolicy, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0));
|
|
449
|
-
const altPayloadIndex = findIndex(altPolicy, (ev) => isText(ev, 97) || (ev.kind === "key" && ev.key === 0 && ev.mods === ZR_MOD_ALT));
|
|
450
|
-
assert.ok(altEscIndex >= 0, "missing Alt escape prefix");
|
|
451
|
-
assert.ok(altPayloadIndex >= 0, "missing Alt payload fallback event");
|
|
452
|
-
assert.ok(altEscIndex < altPayloadIndex, "escape prefix must precede Alt payload");
|
|
453
|
-
const metaPolicy = await writeAndCollectUntil(harness, "\x1b[98;9u", 40, (xs) => {
|
|
454
|
-
const esc = findIndex(xs, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0));
|
|
455
|
-
const payload = findIndex(xs, (ev) => isText(ev, 98) || (ev.kind === "key" && ev.key === 0 && ev.mods === ZR_MOD_META));
|
|
456
|
-
return esc >= 0 && payload >= 0;
|
|
457
|
-
});
|
|
458
|
-
const metaEscIndex = findIndex(metaPolicy, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0));
|
|
459
|
-
const metaPayloadIndex = findIndex(metaPolicy, (ev) => isText(ev, 98) || (ev.kind === "key" && ev.key === 0 && ev.mods === ZR_MOD_META));
|
|
460
|
-
assert.ok(metaEscIndex >= 0, "missing Meta escape prefix");
|
|
461
|
-
assert.ok(metaPayloadIndex >= 0, "missing Meta payload fallback event");
|
|
462
|
-
assert.ok(metaEscIndex < metaPayloadIndex, "escape prefix must precede Meta payload");
|
|
463
|
-
const focusIn = await writeAndCollectUntil(harness, "\x1b[I", 40, (xs) => {
|
|
464
|
-
return findIndex(xs, (ev) => isKey(ev, ZR_KEY_FOCUS_IN, 0)) >= 0;
|
|
465
|
-
});
|
|
466
|
-
assert.ok(findIndex(focusIn, (ev) => isKey(ev, ZR_KEY_FOCUS_IN, 0)) >= 0, "missing focus-in event");
|
|
467
|
-
const focusOut = await writeAndCollectUntil(harness, "\x1b[O", 40, (xs) => {
|
|
468
|
-
return findIndex(xs, (ev) => isKey(ev, ZR_KEY_FOCUS_OUT, 0)) >= 0;
|
|
469
|
-
});
|
|
470
|
-
assert.ok(findIndex(focusOut, (ev) => isKey(ev, ZR_KEY_FOCUS_OUT, 0)) >= 0, "missing focus-out event");
|
|
471
|
-
const mouseDown = await writeAndCollectUntil(harness, "\x1b[<0;300;400M", 40, (xs) => {
|
|
472
|
-
return (findIndex(xs, (ev) => ev.kind === "mouse" &&
|
|
473
|
-
ev.mouseKind === 3 &&
|
|
474
|
-
ev.x === 299 &&
|
|
475
|
-
ev.y === 399 &&
|
|
476
|
-
ev.buttons === 1) >= 0);
|
|
477
|
-
});
|
|
478
|
-
assert.ok(findIndex(mouseDown, (ev) => ev.kind === "mouse" &&
|
|
479
|
-
ev.mouseKind === 3 &&
|
|
480
|
-
ev.x === 299 &&
|
|
481
|
-
ev.y === 399 &&
|
|
482
|
-
ev.buttons === 1) >= 0, "missing mouse down with high coordinates");
|
|
483
|
-
const mouseUp = await writeAndCollectUntil(harness, "\x1b[<0;300;400m", 40, (xs) => {
|
|
484
|
-
return (findIndex(xs, (ev) => ev.kind === "mouse" &&
|
|
485
|
-
ev.mouseKind === 4 &&
|
|
486
|
-
ev.x === 299 &&
|
|
487
|
-
ev.y === 399 &&
|
|
488
|
-
ev.buttons === 1) >= 0);
|
|
489
|
-
});
|
|
490
|
-
assert.ok(findIndex(mouseUp, (ev) => ev.kind === "mouse" &&
|
|
491
|
-
ev.mouseKind === 4 &&
|
|
492
|
-
ev.x === 299 &&
|
|
493
|
-
ev.y === 399 &&
|
|
494
|
-
ev.buttons === 1) >= 0, "missing mouse up with high coordinates");
|
|
495
|
-
const mouseWheel = await writeAndCollectUntil(harness, "\x1b[<64;400;500M", 40, (xs) => {
|
|
496
|
-
return (findIndex(xs, (ev) => ev.kind === "mouse" &&
|
|
497
|
-
ev.mouseKind === 5 &&
|
|
498
|
-
ev.x === 399 &&
|
|
499
|
-
ev.y === 499 &&
|
|
500
|
-
ev.wheelY === 1) >= 0);
|
|
501
|
-
});
|
|
502
|
-
assert.ok(findIndex(mouseWheel, (ev) => ev.kind === "mouse" &&
|
|
503
|
-
ev.mouseKind === 5 &&
|
|
504
|
-
ev.x === 399 &&
|
|
505
|
-
ev.y === 499 &&
|
|
506
|
-
ev.wheelY === 1) >= 0, "missing mouse wheel with high coordinates");
|
|
507
|
-
// Split-read completion across multiple writes.
|
|
508
|
-
harness.writeRaw("\x1b[");
|
|
509
|
-
harness.writeRaw("A");
|
|
510
|
-
const splitEvents = await collectEvents(harness, 20, (xs) => {
|
|
511
|
-
const up = findIndex(xs, (ev) => isKey(ev, ZR_KEY_UP, 0));
|
|
512
|
-
const fallbackEsc = findIndex(xs, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0));
|
|
513
|
-
const fallbackBracket = findIndex(xs, (ev) => isText(ev, 91));
|
|
514
|
-
return up >= 0 || (fallbackEsc >= 0 && fallbackBracket >= 0);
|
|
515
|
-
});
|
|
516
|
-
const splitUp = findIndex(splitEvents, (ev) => isKey(ev, ZR_KEY_UP, 0));
|
|
517
|
-
assert.ok(splitUp >= 0, "split CSI arrow completion did not produce Up key");
|
|
518
|
-
assert.equal(splitEvents.some((ev) => isKey(ev, ZR_KEY_ESCAPE, 0) || isText(ev, 91)), false, "split CSI arrow should not fallback to ESC+'[' when completed");
|
|
519
|
-
// Incomplete sequence fallback policy: ESC+[ without completion flushes as ESC key + text '['.
|
|
520
|
-
harness.writeRaw("\x1b[");
|
|
521
|
-
const fallbackEvents = await collectEvents(harness, 20, (xs) => {
|
|
522
|
-
const esc = findIndex(xs, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0));
|
|
523
|
-
const bracket = findIndex(xs, (ev) => isText(ev, 91));
|
|
524
|
-
return esc >= 0 && bracket >= 0;
|
|
525
|
-
});
|
|
526
|
-
const escFallbackIndex = findIndex(fallbackEvents, (ev) => isKey(ev, ZR_KEY_ESCAPE, 0));
|
|
527
|
-
const textBracketIndex = findIndex(fallbackEvents, (ev) => isText(ev, 91));
|
|
528
|
-
assert.ok(escFallbackIndex >= 0, "incomplete escape fallback missing ESC event");
|
|
529
|
-
assert.ok(textBracketIndex >= 0, "incomplete escape fallback missing text '[' event");
|
|
530
|
-
assert.ok(escFallbackIndex < textBracketIndex, "fallback ESC must precede text '['");
|
|
531
|
-
// Bracketed paste framing.
|
|
532
|
-
harness.writeRaw("\x1b[200~hello\x1b[201~");
|
|
533
|
-
const pasteEvents = await collectEvents(harness, 80, (xs) => {
|
|
534
|
-
return findIndex(xs, (ev) => ev.kind === "paste") >= 0;
|
|
535
|
-
});
|
|
536
|
-
const framedPasteIndex = findIndex(pasteEvents, (ev) => ev.kind === "paste");
|
|
537
|
-
assert.ok(framedPasteIndex >= 0, "missing framed paste event");
|
|
538
|
-
const framedPaste = pasteEvents[framedPasteIndex];
|
|
539
|
-
assert.ok(framedPaste !== undefined);
|
|
540
|
-
if (framedPaste !== undefined && framedPaste.kind === "paste") {
|
|
541
|
-
assert.equal(new TextDecoder().decode(framedPaste.bytes), "hello");
|
|
542
|
-
}
|
|
543
|
-
// Missing paste end marker must flush and not wedge input.
|
|
544
|
-
harness.writeRaw("\x1b[200~xyz");
|
|
545
|
-
const missingEndEvents = await collectEvents(harness, 120, (xs) => {
|
|
546
|
-
return findIndex(xs, (ev) => ev.kind === "paste") >= 0;
|
|
547
|
-
});
|
|
548
|
-
const missingEndPasteIndex = findIndex(missingEndEvents, (ev) => ev.kind === "paste");
|
|
549
|
-
if (missingEndPasteIndex >= 0) {
|
|
550
|
-
const missingEndPaste = missingEndEvents[missingEndPasteIndex];
|
|
551
|
-
assert.ok(missingEndPaste !== undefined);
|
|
552
|
-
if (missingEndPaste !== undefined && missingEndPaste.kind === "paste") {
|
|
553
|
-
assert.equal(new TextDecoder().decode(missingEndPaste.bytes), "xyz");
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
harness.writeRaw("q");
|
|
557
|
-
const postMissingEnd = await collectEvents(harness, 40, (xs) => {
|
|
558
|
-
return findIndex(xs, (ev) => isText(ev, 113)) >= 0;
|
|
559
|
-
});
|
|
560
|
-
assert.ok(findIndex(postMissingEnd, (ev) => isText(ev, 113)) >= 0, "input wedged after paste without end marker");
|
|
561
|
-
// Oversized paste overrun drops paste event and must not wedge input.
|
|
562
|
-
const oversizedPayload = "a".repeat(70_000);
|
|
563
|
-
harness.writeRaw(`\x1b[200~${oversizedPayload}\x1b[201~`);
|
|
564
|
-
const oversizedEvents = await collectEvents(harness, 120, (xs) => {
|
|
565
|
-
return findIndex(xs, (ev) => ev.kind === "paste") >= 0;
|
|
566
|
-
});
|
|
567
|
-
assert.equal(oversizedEvents.some((ev) => ev.kind === "paste"), false, "oversized paste should not emit a paste event");
|
|
568
|
-
harness.writeRaw("z");
|
|
569
|
-
const postOversized = await collectEvents(harness, 20, (xs) => {
|
|
570
|
-
return findIndex(xs, (ev) => isText(ev, 122)) >= 0;
|
|
571
|
-
});
|
|
572
|
-
assert.ok(findIndex(postOversized, (ev) => isText(ev, 122)) >= 0, "input wedged after oversized paste");
|
|
573
|
-
// Resize semantics and ordering.
|
|
574
|
-
harness.resize(100, 30);
|
|
575
|
-
const resizedEvents = await collectEvents(harness, 40, (xs) => {
|
|
576
|
-
return findIndex(xs, (ev) => ev.kind === "resize" && ev.cols === 100 && ev.rows === 30) >= 0;
|
|
577
|
-
});
|
|
578
|
-
assert.ok(findIndex(resizedEvents, (ev) => ev.kind === "resize" && ev.cols === 100 && ev.rows === 30) >=
|
|
579
|
-
0, "missing resize event after terminal resize");
|
|
580
|
-
}
|
|
581
|
-
finally {
|
|
582
|
-
await harness.stop();
|
|
583
|
-
}
|
|
584
|
-
});
|
|
585
|
-
test("terminal io contract: focus gating when disabled", async (t) => {
|
|
586
|
-
if (process.platform === "win32") {
|
|
587
|
-
t.skip("focus-gating contract assertion is covered on Unix PTY lanes");
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
const harness = await createHarnessOrSkip(t, {
|
|
591
|
-
env: {
|
|
592
|
-
ZIREAEL_CAP_FOCUS_EVENTS: "0",
|
|
593
|
-
},
|
|
594
|
-
});
|
|
595
|
-
if (harness === null)
|
|
596
|
-
return;
|
|
597
|
-
try {
|
|
598
|
-
await harness.pollOnce();
|
|
599
|
-
harness.writeRaw("\x1b[I\x1b[O");
|
|
600
|
-
harness.writeRaw("k");
|
|
601
|
-
const gatedEvents = await collectEvents(harness, 20, (xs) => {
|
|
602
|
-
return findIndex(xs, (ev) => isText(ev, 107)) >= 0;
|
|
603
|
-
});
|
|
604
|
-
assert.equal(gatedEvents.some((ev) => isKey(ev, ZR_KEY_FOCUS_IN, 0) || isKey(ev, ZR_KEY_FOCUS_OUT, 0)), false, "focus events were emitted while focus mode was disabled");
|
|
605
|
-
}
|
|
606
|
-
finally {
|
|
607
|
-
await harness.stop();
|
|
608
|
-
}
|
|
609
|
-
});
|
|
610
|
-
test("terminal io contract: windows ConPTY guarded coverage", async (t) => {
|
|
611
|
-
if (process.platform !== "win32") {
|
|
612
|
-
t.skip("windows-only ConPTY coverage");
|
|
613
|
-
return;
|
|
614
|
-
}
|
|
615
|
-
const ci = process.env.CI;
|
|
616
|
-
if (ci === "true") {
|
|
617
|
-
t.skip("ConPTY guarded coverage is skipped on Windows CI; run locally on Windows for coverage");
|
|
618
|
-
return;
|
|
619
|
-
}
|
|
620
|
-
const harness = await createHarnessOrSkip(t, {
|
|
621
|
-
env: {
|
|
622
|
-
ZIREAEL_CAP_BRACKETED_PASTE: "1",
|
|
623
|
-
ZIREAEL_CAP_FOCUS_EVENTS: "1",
|
|
624
|
-
},
|
|
625
|
-
});
|
|
626
|
-
if (harness === null)
|
|
627
|
-
return;
|
|
628
|
-
try {
|
|
629
|
-
try {
|
|
630
|
-
await harness.pollOnce();
|
|
631
|
-
harness.writeRaw("\x1b[1;5A");
|
|
632
|
-
harness.writeRaw("\x1b[200~win\x1b[201~");
|
|
633
|
-
const events = await collectEvents(harness, 50, (xs) => {
|
|
634
|
-
const arrow = findIndex(xs, (ev) => isKey(ev, ZR_KEY_UP, ZR_MOD_CTRL)) >= 0;
|
|
635
|
-
const paste = findIndex(xs, (ev) => ev.kind === "paste" && new TextDecoder().decode(ev.bytes) === "win");
|
|
636
|
-
return arrow && paste >= 0;
|
|
637
|
-
});
|
|
638
|
-
assert.ok(findIndex(events, (ev) => isKey(ev, ZR_KEY_UP, ZR_MOD_CTRL)) >= 0, "missing Ctrl+Up");
|
|
639
|
-
assert.ok(findIndex(events, (ev) => ev.kind === "paste" && new TextDecoder().decode(ev.bytes) === "win") >= 0, "missing bracketed paste on ConPTY");
|
|
640
|
-
}
|
|
641
|
-
catch (err) {
|
|
642
|
-
const detail = asErrorDetail(err);
|
|
643
|
-
if (detail.includes("command timeout")) {
|
|
644
|
-
t.skip(`ConPTY coverage unavailable in this environment: ${detail}`);
|
|
645
|
-
return;
|
|
646
|
-
}
|
|
647
|
-
throw err;
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
finally {
|
|
651
|
-
await harness.stop();
|
|
652
|
-
}
|
|
653
|
-
});
|
|
654
|
-
//# sourceMappingURL=terminal_io_contract.e2e.test.js.map
|