@termwright/conformance 0.2.0 → 0.3.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/README.md +39 -30
- package/dist/index.d.ts +9 -31
- package/dist/index.js +648 -282
- package/dist/index.js.map +1 -1
- package/package.json +15 -13
- package/src/fixtures/adversarial-peer.mjs +76 -114
- package/src/fixtures/generic-app.mjs +5 -2
- package/src/fixtures/ink-probe-app.mjs +7 -7
package/dist/index.js
CHANGED
|
@@ -1,49 +1,111 @@
|
|
|
1
1
|
// src/adapter-conformance.ts
|
|
2
2
|
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
|
|
3
|
-
import { tmpdir as
|
|
4
|
-
import { join as
|
|
5
|
-
import {
|
|
3
|
+
import { tmpdir as tmpdir3 } from "os";
|
|
4
|
+
import { join as join4 } from "path";
|
|
5
|
+
import {
|
|
6
|
+
ADAPTER_CAPABILITIES,
|
|
7
|
+
validateSnapshot,
|
|
8
|
+
DEFAULT_LIMITS as DEFAULT_LIMITS3,
|
|
9
|
+
PROTOCOL_ID as PROTOCOL_ID2
|
|
10
|
+
} from "@termwright/protocol";
|
|
6
11
|
|
|
7
12
|
// src/support/probe.ts
|
|
8
|
-
import
|
|
13
|
+
import "net";
|
|
9
14
|
import { readFileSync } from "fs";
|
|
10
|
-
import {
|
|
11
|
-
import { tmpdir } from "os";
|
|
12
|
-
import { join as
|
|
13
|
-
import { randomBytes, randomUUID } from "crypto";
|
|
15
|
+
import { rm as rm2 } from "fs/promises";
|
|
16
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
17
|
+
import { join as join3 } from "path";
|
|
18
|
+
import { randomBytes as randomBytes2, randomUUID } from "crypto";
|
|
14
19
|
import {
|
|
15
20
|
DEFAULT_LIMITS as DEFAULT_LIMITS2,
|
|
16
21
|
ENV_ENDPOINT as ENV_ENDPOINT2,
|
|
17
|
-
ENV_PROTOCOL,
|
|
18
22
|
ENV_TOKEN as ENV_TOKEN2,
|
|
19
23
|
MARKER_OSC_CODE,
|
|
20
24
|
MARKER_OSC_PREFIX,
|
|
21
25
|
parseAdapterMessage,
|
|
22
26
|
PROTOCOL_ID,
|
|
23
|
-
PROTOCOL_VERSION,
|
|
24
|
-
applyTreeDelta,
|
|
25
27
|
createFrameDecoder,
|
|
26
28
|
encodeFrame,
|
|
27
29
|
generateToken,
|
|
28
30
|
verifyMarkerPayload
|
|
29
31
|
} from "@termwright/protocol";
|
|
30
|
-
import
|
|
31
|
-
import { createNodePtyBackend as createNodePtyBackend2 } from "@termwright/driver";
|
|
32
|
+
import { createNativePtyBackend, VtScreen } from "@termwright/driver/experimental";
|
|
32
33
|
|
|
33
34
|
// src/support/pty.ts
|
|
34
35
|
import { spawnSync } from "child_process";
|
|
35
36
|
import { existsSync } from "fs";
|
|
36
37
|
import { dirname, join } from "path";
|
|
37
38
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
38
|
-
import { launchTerminal
|
|
39
|
+
import { launchTerminal } from "@termwright/driver";
|
|
40
|
+
import { nativePtyAvailable } from "@termwright/driver/experimental";
|
|
39
41
|
|
|
40
|
-
// ../probe-ink/dist/chunk-
|
|
42
|
+
// ../probe-ink/dist/chunk-SLKX554P.js
|
|
43
|
+
var certified_instrumentation_default = {
|
|
44
|
+
framework: "ink",
|
|
45
|
+
profiles: [
|
|
46
|
+
{
|
|
47
|
+
coreSha256: "f632f6176e593183f0c0bb6e4a6e8a28d65f1c3899a33a84d4a95d26e1a82a58",
|
|
48
|
+
rendererSha256: "9e72b27731c38daac7e9f978e24f7bf1210c5cc26bf973e30f08c3ad4a9fe374",
|
|
49
|
+
version: "7.1.1"
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
schemaVersion: 1
|
|
53
|
+
};
|
|
54
|
+
var BUILTIN_PROFILES = certified_instrumentation_default.profiles;
|
|
55
|
+
var INK_VERSION = BUILTIN_PROFILES.at(-1)?.version ?? "unsupported";
|
|
56
|
+
|
|
57
|
+
// ../probe-ink/dist/chunk-5ZU7V4OQ.js
|
|
41
58
|
import { validateProbeAnnotations } from "@termwright/protocol";
|
|
42
59
|
|
|
60
|
+
// ../pty/dist/index.js
|
|
61
|
+
import { parseConPtyHostCursorResponse } from "@termwright/protocol";
|
|
62
|
+
var ESC = 27;
|
|
63
|
+
var bytes = (...values) => Buffer.from(values);
|
|
64
|
+
var DA1 = bytes(ESC, 91, 99);
|
|
65
|
+
var WINDOW_DEICONIFY = bytes(ESC, 91, 49, 116);
|
|
66
|
+
var WINDOW_ICONIFY = bytes(ESC, 91, 50, 116);
|
|
67
|
+
var FOCUS_ON = bytes(ESC, 91, 63, 49, 48, 48, 52, 104);
|
|
68
|
+
var FOCUS_OFF = bytes(ESC, 91, 63, 49, 48, 48, 52, 108);
|
|
69
|
+
var WIN32_ON = bytes(ESC, 91, 63, 57, 48, 48, 49, 104);
|
|
70
|
+
var WIN32_OFF = bytes(ESC, 91, 63, 57, 48, 48, 49, 108);
|
|
71
|
+
var RIS = bytes(ESC, 99);
|
|
72
|
+
var HOST_CURSOR_REQUEST_PREFIX = Buffer.from("\x1B]8488;twh-cpr-v1:q:", "ascii");
|
|
73
|
+
var HOST_CURSOR_REPLY_NAMESPACE = Buffer.from("\x1B]8488;twh-cpr-v1:r:", "ascii");
|
|
74
|
+
var STARTUP_REWRITES = [
|
|
75
|
+
{
|
|
76
|
+
// VtIo's ordinary startup handshake.
|
|
77
|
+
input: Buffer.concat([DA1, FOCUS_ON, WIN32_ON]),
|
|
78
|
+
output: DA1,
|
|
79
|
+
hostQueries: ["primary-device-attributes"]
|
|
80
|
+
}
|
|
81
|
+
];
|
|
82
|
+
var HOST_REWRITES = [
|
|
83
|
+
// AdaptDispatch reinjects these immediately after the child reset that
|
|
84
|
+
// caused them. Keeping the reset preserves the child's original bytes.
|
|
85
|
+
{ input: Buffer.concat([FOCUS_OFF, FOCUS_ON]), output: FOCUS_OFF },
|
|
86
|
+
{ input: Buffer.concat([WIN32_OFF, WIN32_ON]), output: WIN32_OFF },
|
|
87
|
+
{ input: Buffer.concat([RIS, FOCUS_ON, WIN32_ON]), output: RIS }
|
|
88
|
+
];
|
|
89
|
+
var signalNumbers = Object.freeze({
|
|
90
|
+
HUP: 1,
|
|
91
|
+
INT: 2,
|
|
92
|
+
KILL: 9,
|
|
93
|
+
TERM: 15
|
|
94
|
+
});
|
|
95
|
+
var signalNames = Object.freeze({
|
|
96
|
+
1: "SIGHUP",
|
|
97
|
+
2: "SIGINT",
|
|
98
|
+
3: "SIGQUIT",
|
|
99
|
+
6: "SIGABRT",
|
|
100
|
+
9: "SIGKILL",
|
|
101
|
+
13: "SIGPIPE",
|
|
102
|
+
15: "SIGTERM"
|
|
103
|
+
});
|
|
104
|
+
|
|
43
105
|
// ../recognizers/dist/index.js
|
|
44
106
|
import {
|
|
45
107
|
DEFAULT_LIMITS,
|
|
46
|
-
|
|
108
|
+
evidence,
|
|
47
109
|
SEMANTIC_ROLES
|
|
48
110
|
} from "@termwright/protocol";
|
|
49
111
|
var HOST_ROLES = Object.freeze({
|
|
@@ -97,10 +159,10 @@ var ROLES = new Set(SEMANTIC_ROLES);
|
|
|
97
159
|
var UTF8_ENCODER = new TextEncoder();
|
|
98
160
|
var ROLE_MAPS = Object.freeze({ ink: roleForInkHost, opentui: roleForOpenTuiClass });
|
|
99
161
|
|
|
100
|
-
// ../probe-ink/dist/chunk-
|
|
162
|
+
// ../probe-ink/dist/chunk-CLY2SLYH.js
|
|
101
163
|
var INSTRUMENT_URL = new URL("./instrument.js", import.meta.url).href;
|
|
102
164
|
|
|
103
|
-
// ../probe-ink/dist/chunk-
|
|
165
|
+
// ../probe-ink/dist/chunk-67M2GX5S.js
|
|
104
166
|
import { ENV_ENDPOINT, ENV_TOKEN } from "@termwright/protocol";
|
|
105
167
|
|
|
106
168
|
// ../probe-ink/dist/index.js
|
|
@@ -114,15 +176,13 @@ function withProbe(runtime, argv) {
|
|
|
114
176
|
const [interpreter, ...rest] = argv;
|
|
115
177
|
const flag = runtime === "bun" ? "--preload" : "--import";
|
|
116
178
|
return {
|
|
117
|
-
command: [
|
|
118
|
-
interpreter,
|
|
119
|
-
flag,
|
|
120
|
-
pathToFileURL(PROBE_ENTRIES[runtime]).href,
|
|
121
|
-
...rest
|
|
122
|
-
],
|
|
179
|
+
command: [interpreter, flag, runtimePreloadSpecifier(runtime, PROBE_ENTRIES[runtime]), ...rest],
|
|
123
180
|
runtime
|
|
124
181
|
};
|
|
125
182
|
}
|
|
183
|
+
function runtimePreloadSpecifier(runtime, entry) {
|
|
184
|
+
return runtime === "bun" ? entry : pathToFileURL(entry).href;
|
|
185
|
+
}
|
|
126
186
|
|
|
127
187
|
// src/support/pty.ts
|
|
128
188
|
function fixturePath(name) {
|
|
@@ -139,7 +199,9 @@ function packageRoot() {
|
|
|
139
199
|
}
|
|
140
200
|
const parent = dirname(directory);
|
|
141
201
|
if (parent === directory) {
|
|
142
|
-
throw new Error(
|
|
202
|
+
throw new Error(
|
|
203
|
+
"@termwright/conformance: could not locate the package root from this module"
|
|
204
|
+
);
|
|
143
205
|
}
|
|
144
206
|
directory = parent;
|
|
145
207
|
}
|
|
@@ -157,22 +219,7 @@ var CONFORMANCE_FIXTURES = Object.freeze({
|
|
|
157
219
|
var cachedPty = null;
|
|
158
220
|
function ptyAvailable() {
|
|
159
221
|
if (cachedPty !== null) return cachedPty;
|
|
160
|
-
|
|
161
|
-
cachedPty = false;
|
|
162
|
-
return cachedPty;
|
|
163
|
-
}
|
|
164
|
-
try {
|
|
165
|
-
const pty = createNodePtyBackend().spawn({
|
|
166
|
-
command: [process.execPath, "-e", "process.exit(0)"],
|
|
167
|
-
env: environment(),
|
|
168
|
-
columns: 20,
|
|
169
|
-
rows: 4
|
|
170
|
-
});
|
|
171
|
-
pty.dispose();
|
|
172
|
-
cachedPty = true;
|
|
173
|
-
} catch {
|
|
174
|
-
cachedPty = false;
|
|
175
|
-
}
|
|
222
|
+
cachedPty = nativePtyAvailable();
|
|
176
223
|
return cachedPty;
|
|
177
224
|
}
|
|
178
225
|
function environment(extra) {
|
|
@@ -217,18 +264,21 @@ function createSessionPool() {
|
|
|
217
264
|
};
|
|
218
265
|
}
|
|
219
266
|
async function waitForStart(terminal, ready, fixture) {
|
|
220
|
-
let
|
|
267
|
+
let bytes2 = 0;
|
|
221
268
|
const off = terminal.events.on("output", ({ data }) => {
|
|
222
|
-
|
|
269
|
+
bytes2 += data.length;
|
|
223
270
|
});
|
|
224
271
|
try {
|
|
225
272
|
await terminal.waitForText(ready);
|
|
226
273
|
} catch (error) {
|
|
227
274
|
const exit = await Promise.race([terminal.exit, Promise.resolve(null)]);
|
|
228
|
-
const detail =
|
|
229
|
-
throw new Error(
|
|
230
|
-
|
|
231
|
-
|
|
275
|
+
const detail = bytes2 === 0 ? `it produced no output at all${exit === null ? " and is still running" : `; it exited ${JSON.stringify(exit)}`}` : `it produced ${bytes2} bytes but never drew ${String(ready)}`;
|
|
276
|
+
throw new Error(
|
|
277
|
+
`conformance: ${fixture.split("/").pop() ?? fixture} did not start \u2014 ${detail}`,
|
|
278
|
+
{
|
|
279
|
+
cause: error
|
|
280
|
+
}
|
|
281
|
+
);
|
|
232
282
|
} finally {
|
|
233
283
|
off();
|
|
234
284
|
}
|
|
@@ -242,7 +292,7 @@ function commandAvailable(command, options = {}) {
|
|
|
242
292
|
...options.cwd === void 0 ? {} : { cwd: options.cwd },
|
|
243
293
|
timeout: options.timeoutMs ?? 12e4,
|
|
244
294
|
encoding: "utf8",
|
|
245
|
-
env: environment()
|
|
295
|
+
env: environment(options.env)
|
|
246
296
|
});
|
|
247
297
|
if (result.status === 0) return true;
|
|
248
298
|
if (options.quiet === true) return false;
|
|
@@ -264,6 +314,304 @@ ${(result.stderr ?? "").trim().split("\n").slice(-3).join("\n")}
|
|
|
264
314
|
}
|
|
265
315
|
}
|
|
266
316
|
|
|
317
|
+
// src/support/probe-peer-owner.ts
|
|
318
|
+
var ProbePeerOwner = class {
|
|
319
|
+
#sockets = /* @__PURE__ */ new Set();
|
|
320
|
+
#pending = [];
|
|
321
|
+
#handler = null;
|
|
322
|
+
#closing = false;
|
|
323
|
+
#closePromise = null;
|
|
324
|
+
admit(socket) {
|
|
325
|
+
socket.pause();
|
|
326
|
+
this.#sockets.add(socket);
|
|
327
|
+
socket.on("error", () => socket.destroy());
|
|
328
|
+
socket.once("close", () => {
|
|
329
|
+
this.#sockets.delete(socket);
|
|
330
|
+
const pending = this.#pending.indexOf(socket);
|
|
331
|
+
if (pending >= 0) this.#pending.splice(pending, 1);
|
|
332
|
+
});
|
|
333
|
+
if (this.#closing) {
|
|
334
|
+
socket.destroy();
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
if (this.#handler === null) this.#pending.push(socket);
|
|
338
|
+
else this.#deliver(socket);
|
|
339
|
+
return true;
|
|
340
|
+
}
|
|
341
|
+
activate(handler) {
|
|
342
|
+
if (this.#handler !== null) throw new Error("probe peer owner is already active");
|
|
343
|
+
this.#handler = handler;
|
|
344
|
+
for (const socket of this.#pending.splice(0)) this.#deliver(socket);
|
|
345
|
+
}
|
|
346
|
+
close(server) {
|
|
347
|
+
this.#closePromise ??= this.#close(server);
|
|
348
|
+
return this.#closePromise;
|
|
349
|
+
}
|
|
350
|
+
async #close(server) {
|
|
351
|
+
this.#closing = true;
|
|
352
|
+
const closed = new Promise((resolve, reject) => {
|
|
353
|
+
server.close((error) => error === void 0 ? resolve() : reject(error));
|
|
354
|
+
});
|
|
355
|
+
this.#pending.length = 0;
|
|
356
|
+
for (const socket of this.#sockets) socket.destroy();
|
|
357
|
+
await closed;
|
|
358
|
+
}
|
|
359
|
+
#deliver(socket) {
|
|
360
|
+
this.#handler?.(socket);
|
|
361
|
+
socket.resume();
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// src/support/probe-process-shutdown.ts
|
|
366
|
+
var DEFAULT_WATCHDOG_MS = 1e4;
|
|
367
|
+
var ProbeProcessShutdown = class {
|
|
368
|
+
#resources;
|
|
369
|
+
#exit;
|
|
370
|
+
#resolveExit;
|
|
371
|
+
#exitStatus = null;
|
|
372
|
+
#stopPromise = null;
|
|
373
|
+
constructor(resources) {
|
|
374
|
+
this.#resources = resources;
|
|
375
|
+
let resolveExit;
|
|
376
|
+
this.#exit = new Promise((resolve) => {
|
|
377
|
+
resolveExit = resolve;
|
|
378
|
+
});
|
|
379
|
+
this.#resolveExit = resolveExit;
|
|
380
|
+
}
|
|
381
|
+
observeExit(status) {
|
|
382
|
+
if (this.#exitStatus !== null) return;
|
|
383
|
+
this.#exitStatus = Object.freeze({ ...status });
|
|
384
|
+
this.#resolveExit(this.#exitStatus);
|
|
385
|
+
}
|
|
386
|
+
stop() {
|
|
387
|
+
this.#stopPromise ??= this.#stop();
|
|
388
|
+
return this.#stopPromise;
|
|
389
|
+
}
|
|
390
|
+
async #stop() {
|
|
391
|
+
const failures = [];
|
|
392
|
+
let admissionClosedSuccessfully = false;
|
|
393
|
+
let admissionClosed;
|
|
394
|
+
try {
|
|
395
|
+
admissionClosed = this.#resources.closeAdmission().then(
|
|
396
|
+
() => {
|
|
397
|
+
admissionClosedSuccessfully = true;
|
|
398
|
+
},
|
|
399
|
+
(error) => {
|
|
400
|
+
failures.push(error);
|
|
401
|
+
}
|
|
402
|
+
);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
failures.push(error);
|
|
405
|
+
admissionClosed = Promise.resolve();
|
|
406
|
+
}
|
|
407
|
+
const controller = new AbortController();
|
|
408
|
+
let causalBoundaryReached = false;
|
|
409
|
+
try {
|
|
410
|
+
await this.#withWatchdog(this.#reachCausalBoundary(controller.signal), controller);
|
|
411
|
+
causalBoundaryReached = true;
|
|
412
|
+
} catch (error) {
|
|
413
|
+
failures.push(error);
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
this.#resources.closeTerminalResponseAdmission();
|
|
417
|
+
} catch (error) {
|
|
418
|
+
failures.push(error);
|
|
419
|
+
}
|
|
420
|
+
for (const dispose of [
|
|
421
|
+
() => this.#resources.pty.dispose(),
|
|
422
|
+
() => this.#resources.disposeParser()
|
|
423
|
+
]) {
|
|
424
|
+
try {
|
|
425
|
+
dispose();
|
|
426
|
+
} catch (error) {
|
|
427
|
+
failures.push(error);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
await admissionClosed;
|
|
432
|
+
} catch (error) {
|
|
433
|
+
failures.push(error);
|
|
434
|
+
}
|
|
435
|
+
if (admissionClosedSuccessfully && causalBoundaryReached) {
|
|
436
|
+
try {
|
|
437
|
+
await this.#resources.removeArtifacts();
|
|
438
|
+
} catch (error) {
|
|
439
|
+
failures.push(error);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (failures.length === 1) throw failures[0];
|
|
443
|
+
if (failures.length > 1) throw new AggregateError(failures, "adapter probe cleanup failed");
|
|
444
|
+
}
|
|
445
|
+
async #reachCausalBoundary(signal) {
|
|
446
|
+
const { pty } = this.#resources;
|
|
447
|
+
const initialTree = pty.treeState?.() ?? "unsupported";
|
|
448
|
+
if (initialTree === "alive") {
|
|
449
|
+
if (pty.hardKillTree === void 0) {
|
|
450
|
+
throw new Error(
|
|
451
|
+
`adapter probe cannot prove teardown of process ${String(pty.pid)}: the PTY exposes no owned-tree kill operation`
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
this.#resources.closeTerminalResponseAdmission();
|
|
455
|
+
await pty.hardKillTree(signal);
|
|
456
|
+
} else if (initialTree === "unsupported") {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`adapter probe cannot prove teardown of process ${String(pty.pid)}: the PTY exposes no authoritative owned-tree state`
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
const outputEnded = pty.outputEnded;
|
|
462
|
+
if (outputEnded === void 0) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`adapter probe cannot prove teardown of process ${String(pty.pid)}: the PTY exposes no output EOF barrier`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
await this.#awaitOrAbort(
|
|
468
|
+
Promise.all([this.#exit, outputEnded]).then(() => void 0),
|
|
469
|
+
signal
|
|
470
|
+
);
|
|
471
|
+
signal.throwIfAborted();
|
|
472
|
+
if (pty.sawOutputEnd?.() !== true) {
|
|
473
|
+
throw new Error(
|
|
474
|
+
`adapter probe cannot prove teardown of process ${String(pty.pid)}: the PTY output ended without authoritative EOF`
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
if (pty.treeState?.() !== "gone") {
|
|
478
|
+
throw new Error(
|
|
479
|
+
`adapter probe cannot prove teardown of process ${String(pty.pid)}: the owned process tree was not confirmed gone at EOF`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
await this.#awaitOrAbort(this.#resources.drainParser(), signal);
|
|
483
|
+
signal.throwIfAborted();
|
|
484
|
+
}
|
|
485
|
+
async #awaitOrAbort(operation, signal) {
|
|
486
|
+
signal.throwIfAborted();
|
|
487
|
+
let removeAbortListener = () => void 0;
|
|
488
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
489
|
+
const onAbort = () => reject(signal.reason);
|
|
490
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
491
|
+
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
492
|
+
});
|
|
493
|
+
try {
|
|
494
|
+
await Promise.race([operation, aborted]);
|
|
495
|
+
} finally {
|
|
496
|
+
removeAbortListener();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async #withWatchdog(operation, controller) {
|
|
500
|
+
const watchdogMs = this.#resources.watchdogMs ?? DEFAULT_WATCHDOG_MS;
|
|
501
|
+
let timer;
|
|
502
|
+
const expired = new Promise((_resolve, reject) => {
|
|
503
|
+
timer = setTimeout(() => {
|
|
504
|
+
reject(
|
|
505
|
+
new Error(
|
|
506
|
+
`adapter probe teardown did not reach exit, authoritative EOF, and parser drain within its ${String(watchdogMs)} ms watchdog`
|
|
507
|
+
)
|
|
508
|
+
);
|
|
509
|
+
controller.abort();
|
|
510
|
+
}, watchdogMs);
|
|
511
|
+
});
|
|
512
|
+
try {
|
|
513
|
+
await Promise.race([operation, expired]);
|
|
514
|
+
} finally {
|
|
515
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
516
|
+
controller.abort();
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
// src/support/probe-startup.ts
|
|
522
|
+
import { randomBytes } from "crypto";
|
|
523
|
+
import { mkdtemp } from "fs/promises";
|
|
524
|
+
import { createServer } from "net";
|
|
525
|
+
import { tmpdir } from "os";
|
|
526
|
+
import { join as join2 } from "path";
|
|
527
|
+
|
|
528
|
+
// src/support/probe-start-cleanup.ts
|
|
529
|
+
import { rm } from "fs/promises";
|
|
530
|
+
async function rollbackProbeStart(primary, cleanup) {
|
|
531
|
+
const failures = [primary];
|
|
532
|
+
let serverClosed = Promise.resolve();
|
|
533
|
+
if (cleanup.closeAdmission !== void 0) {
|
|
534
|
+
try {
|
|
535
|
+
serverClosed = cleanup.closeAdmission();
|
|
536
|
+
} catch (error) {
|
|
537
|
+
failures.push(error);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (cleanup.disposePty !== void 0) {
|
|
541
|
+
try {
|
|
542
|
+
cleanup.disposePty();
|
|
543
|
+
} catch (error) {
|
|
544
|
+
failures.push(error);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
await serverClosed;
|
|
549
|
+
} catch (error) {
|
|
550
|
+
failures.push(error);
|
|
551
|
+
}
|
|
552
|
+
for (const path of [cleanup.directory, cleanup.debugFile]) {
|
|
553
|
+
if (path === void 0 || path === null) continue;
|
|
554
|
+
try {
|
|
555
|
+
await rm(path, { recursive: path === cleanup.directory, force: true });
|
|
556
|
+
} catch (error) {
|
|
557
|
+
failures.push(error);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
if (failures.length === 1) throw primary;
|
|
561
|
+
throw new AggregateError(failures, "adapter probe startup and rollback failed", {
|
|
562
|
+
cause: primary
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// src/support/probe-startup.ts
|
|
567
|
+
var ProbeStartupTransaction = class {
|
|
568
|
+
peers = new ProbePeerOwner();
|
|
569
|
+
server = null;
|
|
570
|
+
directory = null;
|
|
571
|
+
endpoint = null;
|
|
572
|
+
debugFile = null;
|
|
573
|
+
pty = null;
|
|
574
|
+
async acquireEndpoint(instrument, options = {}) {
|
|
575
|
+
if (!instrument) return;
|
|
576
|
+
this.server = createServer();
|
|
577
|
+
this.server.on("connection", (socket) => this.peers.admit(socket));
|
|
578
|
+
if (process.platform !== "win32" || options.allocateDirectory === true) {
|
|
579
|
+
this.directory = await mkdtemp(join2(tmpdir(), "termwright-probe-"));
|
|
580
|
+
}
|
|
581
|
+
if (process.platform === "win32") {
|
|
582
|
+
this.endpoint = `\\\\.\\pipe\\termwright-probe-${randomBytes(16).toString("hex")}`;
|
|
583
|
+
} else {
|
|
584
|
+
this.endpoint = join2(this.directory, "semantic.sock");
|
|
585
|
+
}
|
|
586
|
+
await (options.listen ?? listenServer)(this.server, this.endpoint);
|
|
587
|
+
}
|
|
588
|
+
rollback(primary) {
|
|
589
|
+
const server = this.server;
|
|
590
|
+
const pty = this.pty;
|
|
591
|
+
return rollbackProbeStart(primary, {
|
|
592
|
+
...server === null || !server.listening ? {} : { closeAdmission: () => this.peers.close(server) },
|
|
593
|
+
...pty === null ? {} : { disposePty: () => pty.dispose() },
|
|
594
|
+
directory: this.directory,
|
|
595
|
+
debugFile: this.debugFile
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
function listenServer(server, endpoint) {
|
|
600
|
+
return new Promise((resolve, reject) => {
|
|
601
|
+
const onError = (error) => {
|
|
602
|
+
server.removeListener("listening", onListening);
|
|
603
|
+
reject(error);
|
|
604
|
+
};
|
|
605
|
+
const onListening = () => {
|
|
606
|
+
server.removeListener("error", onError);
|
|
607
|
+
resolve();
|
|
608
|
+
};
|
|
609
|
+
server.once("error", onError);
|
|
610
|
+
server.once("listening", onListening);
|
|
611
|
+
server.listen(endpoint);
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
|
|
267
615
|
// src/support/probe.ts
|
|
268
616
|
var LOG_BUDGET = Object.freeze({ enabled: true, maxRecordsPerSecond: 200, burst: 400 });
|
|
269
617
|
var MARKER_PATTERN = new RegExp(
|
|
@@ -276,39 +624,52 @@ var AdapterProbe = class _AdapterProbe {
|
|
|
276
624
|
#server;
|
|
277
625
|
#directory;
|
|
278
626
|
#pty;
|
|
279
|
-
#
|
|
627
|
+
#vt;
|
|
280
628
|
#terminal;
|
|
629
|
+
#detachTerminalResponse;
|
|
281
630
|
#startedAt = performance.now();
|
|
282
631
|
#messages = [];
|
|
283
632
|
#markers = [];
|
|
284
633
|
#faults = [];
|
|
285
634
|
#logs = [];
|
|
286
|
-
#deltas = [];
|
|
287
|
-
#composed = null;
|
|
288
|
-
#compositionError = null;
|
|
289
|
-
#requestId = 0;
|
|
290
635
|
#chunks = [];
|
|
291
636
|
#bytes = 0;
|
|
292
637
|
#text = "";
|
|
293
638
|
#markerScanFrom = 0;
|
|
294
639
|
#connections = 0;
|
|
295
640
|
#socket = null;
|
|
641
|
+
#peers;
|
|
296
642
|
#exit = null;
|
|
297
643
|
/** Where the adapter writes its own account of attaching, if it writes one. */
|
|
298
644
|
#debugFile = null;
|
|
299
|
-
#
|
|
300
|
-
|
|
301
|
-
|
|
645
|
+
#shutdown;
|
|
646
|
+
#changeWaiters = /* @__PURE__ */ new Set();
|
|
647
|
+
constructor(identity, server, directory, pty, size, peers) {
|
|
302
648
|
this.sessionId = identity.sessionId;
|
|
303
649
|
this.token = identity.token;
|
|
304
650
|
this.#server = server;
|
|
305
651
|
this.#directory = directory;
|
|
306
652
|
this.#pty = pty;
|
|
307
|
-
this.#
|
|
308
|
-
|
|
653
|
+
this.#peers = peers;
|
|
654
|
+
this.#vt = new VtScreen({
|
|
655
|
+
columns: size.columns,
|
|
309
656
|
rows: size.rows,
|
|
310
|
-
|
|
311
|
-
|
|
657
|
+
scrollbackLines: 1e3
|
|
658
|
+
});
|
|
659
|
+
this.#terminal = this.#vt.terminal;
|
|
660
|
+
this.#detachTerminalResponse = this.#vt.onResponse(
|
|
661
|
+
(response) => this.#writeTerminalResponse(response.data)
|
|
662
|
+
);
|
|
663
|
+
this.#shutdown = new ProbeProcessShutdown({
|
|
664
|
+
pty,
|
|
665
|
+
closeAdmission: () => this.#server === null ? Promise.resolve() : this.#peers.close(this.#server),
|
|
666
|
+
closeTerminalResponseAdmission: () => this.#closeTerminalResponseAdmission(),
|
|
667
|
+
drainParser: () => this.#vt.drain(),
|
|
668
|
+
disposeParser: () => {
|
|
669
|
+
this.#notifyChange();
|
|
670
|
+
this.#vt.dispose();
|
|
671
|
+
},
|
|
672
|
+
removeArtifacts: () => this.#removeArtifacts()
|
|
312
673
|
});
|
|
313
674
|
}
|
|
314
675
|
/** Creates the endpoint (unless dormant), then spawns the fixture. */
|
|
@@ -316,61 +677,48 @@ var AdapterProbe = class _AdapterProbe {
|
|
|
316
677
|
const instrument = options.instrument ?? true;
|
|
317
678
|
const sessionId = randomUUID();
|
|
318
679
|
const token = generateToken();
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
680
|
+
const startup = new ProbeStartupTransaction();
|
|
681
|
+
try {
|
|
682
|
+
await startup.acquireEndpoint(instrument);
|
|
683
|
+
const { server, directory, endpoint, peers } = startup;
|
|
684
|
+
const env = environment(command.env);
|
|
685
|
+
delete env[ENV_ENDPOINT2];
|
|
686
|
+
delete env[ENV_TOKEN2];
|
|
687
|
+
if (endpoint !== null) {
|
|
688
|
+
env[ENV_ENDPOINT2] = endpoint;
|
|
689
|
+
env[ENV_TOKEN2] = token;
|
|
329
690
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
691
|
+
startup.debugFile = join3(
|
|
692
|
+
tmpdir2(),
|
|
693
|
+
`termwright-adapter-debug-${randomBytes2(8).toString("hex")}.log`
|
|
694
|
+
);
|
|
695
|
+
env["TERMWRIGHT_DEBUG_FILE"] = startup.debugFile;
|
|
696
|
+
const size = { columns: options.columns ?? 80, rows: options.rows ?? 24 };
|
|
697
|
+
startup.pty = createNativePtyBackend().spawn({
|
|
698
|
+
command: command.command,
|
|
699
|
+
...command.cwd === void 0 ? {} : { cwd: command.cwd },
|
|
700
|
+
env,
|
|
701
|
+
columns: size.columns,
|
|
702
|
+
rows: size.rows,
|
|
703
|
+
// This probe is itself the terminal emulator. Do not inherit a harness
|
|
704
|
+
// shell's `TERM=dumb`: the child is connected to our xterm-compatible
|
|
705
|
+
// parser regardless of which terminal launched Vitest.
|
|
706
|
+
term: "xterm-256color"
|
|
338
707
|
});
|
|
708
|
+
const pty = startup.pty;
|
|
709
|
+
const probe = new _AdapterProbe({ sessionId, token }, server, directory, pty, size, peers);
|
|
710
|
+
probe.#debugFile = startup.debugFile;
|
|
711
|
+
pty.onData((data) => probe.#onData(data));
|
|
712
|
+
pty.onExit((status) => {
|
|
713
|
+
probe.#exit = status;
|
|
714
|
+
probe.#shutdown.observeExit(status);
|
|
715
|
+
probe.#notifyChange();
|
|
716
|
+
});
|
|
717
|
+
peers.activate((socket) => probe.#onConnection(socket));
|
|
718
|
+
return probe;
|
|
719
|
+
} catch (error) {
|
|
720
|
+
return startup.rollback(error);
|
|
339
721
|
}
|
|
340
|
-
const env = environment(command.env);
|
|
341
|
-
delete env[ENV_ENDPOINT2];
|
|
342
|
-
delete env[ENV_TOKEN2];
|
|
343
|
-
delete env[ENV_PROTOCOL];
|
|
344
|
-
if (endpoint !== null) {
|
|
345
|
-
env[ENV_ENDPOINT2] = endpoint;
|
|
346
|
-
env[ENV_TOKEN2] = token;
|
|
347
|
-
env[ENV_PROTOCOL] = String(PROTOCOL_VERSION);
|
|
348
|
-
}
|
|
349
|
-
const debugFile = join2(tmpdir(), `termwright-adapter-debug-${randomBytes(8).toString("hex")}.log`);
|
|
350
|
-
env["TERMWRIGHT_DEBUG_FILE"] = debugFile;
|
|
351
|
-
const size = { columns: options.columns ?? 80, rows: options.rows ?? 24 };
|
|
352
|
-
const pty = createNodePtyBackend2().spawn({
|
|
353
|
-
command: command.command,
|
|
354
|
-
...command.cwd === void 0 ? {} : { cwd: command.cwd },
|
|
355
|
-
env,
|
|
356
|
-
columns: size.columns,
|
|
357
|
-
rows: size.rows
|
|
358
|
-
});
|
|
359
|
-
const probe = new _AdapterProbe(
|
|
360
|
-
{ sessionId, token },
|
|
361
|
-
server,
|
|
362
|
-
directory,
|
|
363
|
-
pty,
|
|
364
|
-
size,
|
|
365
|
-
options.subscribe ?? "snapshots"
|
|
366
|
-
);
|
|
367
|
-
probe.#debugFile = debugFile;
|
|
368
|
-
pty.onData((data) => probe.#onData(data));
|
|
369
|
-
pty.onExit((status) => {
|
|
370
|
-
probe.#exit = status;
|
|
371
|
-
});
|
|
372
|
-
server?.on("connection", (socket) => probe.#onConnection(socket));
|
|
373
|
-
return probe;
|
|
374
722
|
}
|
|
375
723
|
/** Everything observed so far. Safe to call at any point. */
|
|
376
724
|
observe() {
|
|
@@ -382,10 +730,7 @@ var AdapterProbe = class _AdapterProbe {
|
|
|
382
730
|
stdout: this.#stdout(),
|
|
383
731
|
text: this.#text,
|
|
384
732
|
screen: this.screenText(),
|
|
385
|
-
logs: this.#logs.map((entry) => entry)
|
|
386
|
-
deltas: this.#deltas.map((entry) => entry),
|
|
387
|
-
composed: this.#composed,
|
|
388
|
-
compositionError: this.#compositionError
|
|
733
|
+
logs: this.#logs.map((entry) => entry)
|
|
389
734
|
};
|
|
390
735
|
}
|
|
391
736
|
/** The visible grid as text, trailing whitespace trimmed per row. */
|
|
@@ -414,18 +759,23 @@ var AdapterProbe = class _AdapterProbe {
|
|
|
414
759
|
* cells never emits `focus: reject` as those twelve bytes in a row.
|
|
415
760
|
*/
|
|
416
761
|
async waitForText(needle, timeoutMs = 1e4) {
|
|
417
|
-
const deadline =
|
|
762
|
+
const deadline = performance.now() + timeoutMs;
|
|
418
763
|
for (; ; ) {
|
|
764
|
+
const change = this.#armChange(deadline);
|
|
419
765
|
const screen = this.screenText();
|
|
420
|
-
if (needle instanceof RegExp ? needle.test(screen) : screen.includes(needle))
|
|
421
|
-
|
|
766
|
+
if (needle instanceof RegExp ? needle.test(screen) : screen.includes(needle)) {
|
|
767
|
+
change.cancel();
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (performance.now() >= deadline) {
|
|
771
|
+
change.cancel();
|
|
422
772
|
throw new Error(
|
|
423
773
|
`adapter conformance: ${String(needle)} never appeared on the fixture's screen
|
|
424
774
|
screen was:
|
|
425
775
|
${screen}`
|
|
426
776
|
);
|
|
427
777
|
}
|
|
428
|
-
await
|
|
778
|
+
await change.wait();
|
|
429
779
|
}
|
|
430
780
|
}
|
|
431
781
|
/**
|
|
@@ -438,13 +788,18 @@ ${screen}`
|
|
|
438
788
|
* and only the observation tells them apart.
|
|
439
789
|
*/
|
|
440
790
|
async waitFor(predicate, timeoutMs = 1e4, what = "the condition") {
|
|
441
|
-
const deadline =
|
|
791
|
+
const deadline = performance.now() + timeoutMs;
|
|
442
792
|
for (; ; ) {
|
|
443
|
-
|
|
444
|
-
if (
|
|
793
|
+
const change = this.#armChange(deadline);
|
|
794
|
+
if (predicate(this.observe())) {
|
|
795
|
+
change.cancel();
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (performance.now() >= deadline) {
|
|
799
|
+
change.cancel();
|
|
445
800
|
throw new Error(`adapter conformance: ${what} never happened \u2014 ${this.describe()}`);
|
|
446
801
|
}
|
|
447
|
-
await
|
|
802
|
+
await change.wait();
|
|
448
803
|
}
|
|
449
804
|
}
|
|
450
805
|
/** What the probe has seen so far, for a failure that has to explain itself. */
|
|
@@ -484,10 +839,18 @@ ${this.#adapterAccount()}`;
|
|
|
484
839
|
}
|
|
485
840
|
/** Waits for the child to exit and returns its status. */
|
|
486
841
|
async waitForExit(timeoutMs = 1e4) {
|
|
487
|
-
const deadline =
|
|
842
|
+
const deadline = performance.now() + timeoutMs;
|
|
488
843
|
while (this.#exit === null) {
|
|
489
|
-
|
|
490
|
-
|
|
844
|
+
const change = this.#armChange(deadline);
|
|
845
|
+
if (this.#exit !== null) {
|
|
846
|
+
change.cancel();
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
849
|
+
if (performance.now() >= deadline) {
|
|
850
|
+
change.cancel();
|
|
851
|
+
throw new Error("adapter conformance: the fixture never exited");
|
|
852
|
+
}
|
|
853
|
+
await change.wait();
|
|
491
854
|
}
|
|
492
855
|
return this.#exit;
|
|
493
856
|
}
|
|
@@ -497,17 +860,29 @@ ${this.#adapterAccount()}`;
|
|
|
497
860
|
this.#socket = null;
|
|
498
861
|
}
|
|
499
862
|
/** Stops the child and releases the endpoint. Idempotent. */
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
this.#
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
863
|
+
stop() {
|
|
864
|
+
return this.#shutdown.stop();
|
|
865
|
+
}
|
|
866
|
+
async #removeArtifacts() {
|
|
867
|
+
const failures = [];
|
|
868
|
+
if (this.#directory !== null) {
|
|
869
|
+
try {
|
|
870
|
+
await rm2(this.#directory, { recursive: true, force: true });
|
|
871
|
+
} catch (error) {
|
|
872
|
+
failures.push(error);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
if (this.#debugFile !== null) {
|
|
876
|
+
try {
|
|
877
|
+
await rm2(this.#debugFile, { force: true });
|
|
878
|
+
} catch (error) {
|
|
879
|
+
failures.push(error);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
this.#notifyChange();
|
|
883
|
+
if (failures.length === 1) throw failures[0];
|
|
884
|
+
if (failures.length > 1)
|
|
885
|
+
throw new AggregateError(failures, "adapter probe artifact cleanup failed");
|
|
511
886
|
}
|
|
512
887
|
// -------------------------------------------------------------------------
|
|
513
888
|
#stdout() {
|
|
@@ -524,8 +899,23 @@ ${this.#adapterAccount()}`;
|
|
|
524
899
|
this.#chunks.push(data);
|
|
525
900
|
this.#bytes += data.length;
|
|
526
901
|
this.#text += Buffer.from(data).toString("utf8");
|
|
527
|
-
this.#
|
|
902
|
+
void this.#vt.write(data).finally(() => this.#notifyChange());
|
|
528
903
|
this.#scanMarkers();
|
|
904
|
+
this.#notifyChange();
|
|
905
|
+
}
|
|
906
|
+
/** Returns emulator-owned replies without presenting them as user input. */
|
|
907
|
+
#writeTerminalResponse(response) {
|
|
908
|
+
const data = Buffer.from(response, "utf8");
|
|
909
|
+
const write = this.#pty.writeTerminalResponse;
|
|
910
|
+
if (write === void 0) {
|
|
911
|
+
this.#pty.write(data, "raw");
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
write.call(this.#pty, data);
|
|
915
|
+
}
|
|
916
|
+
#closeTerminalResponseAdmission() {
|
|
917
|
+
this.#detachTerminalResponse?.();
|
|
918
|
+
this.#detachTerminalResponse = null;
|
|
529
919
|
}
|
|
530
920
|
/** Finds render markers in the byte stream and verifies each against the token. */
|
|
531
921
|
#scanMarkers() {
|
|
@@ -536,7 +926,10 @@ ${this.#adapterAccount()}`;
|
|
|
536
926
|
const payload = match[1] ?? "";
|
|
537
927
|
const verified = verifyMarkerPayload(payload, this.token, this.sessionId);
|
|
538
928
|
if (verified === null) {
|
|
539
|
-
this.#faults.push({
|
|
929
|
+
this.#faults.push({
|
|
930
|
+
code: "marker",
|
|
931
|
+
detail: `marker did not verify: ${JSON.stringify(payload)}`
|
|
932
|
+
});
|
|
540
933
|
} else {
|
|
541
934
|
this.#markers.push({ revision: verified.revision, offset: match.index, atMs: this.#now() });
|
|
542
935
|
}
|
|
@@ -546,8 +939,12 @@ ${this.#adapterAccount()}`;
|
|
|
546
939
|
}
|
|
547
940
|
#onConnection(socket) {
|
|
548
941
|
this.#connections += 1;
|
|
942
|
+
this.#notifyChange();
|
|
549
943
|
if (this.#socket !== null) {
|
|
550
|
-
this.#faults.push({
|
|
944
|
+
this.#faults.push({
|
|
945
|
+
code: "second-connection",
|
|
946
|
+
detail: "the adapter opened a second channel"
|
|
947
|
+
});
|
|
551
948
|
socket.destroy();
|
|
552
949
|
return;
|
|
553
950
|
}
|
|
@@ -558,35 +955,38 @@ ${this.#adapterAccount()}`;
|
|
|
558
955
|
try {
|
|
559
956
|
frames = decoder.push(chunk);
|
|
560
957
|
} catch (error) {
|
|
561
|
-
this.#faults.push({
|
|
958
|
+
this.#faults.push({
|
|
959
|
+
code: "framing",
|
|
960
|
+
detail: error instanceof Error ? error.message : String(error)
|
|
961
|
+
});
|
|
962
|
+
this.#notifyChange();
|
|
562
963
|
socket.destroy();
|
|
563
964
|
return;
|
|
564
965
|
}
|
|
565
966
|
for (const frame of frames) this.#onFrame(socket, frame);
|
|
566
967
|
});
|
|
567
|
-
socket.on("error", () => socket.destroy());
|
|
568
968
|
socket.on("close", () => {
|
|
569
969
|
if (this.#socket === socket) this.#socket = null;
|
|
970
|
+
this.#notifyChange();
|
|
570
971
|
});
|
|
571
972
|
}
|
|
572
973
|
#onFrame(socket, frame) {
|
|
573
974
|
const parsed = parseAdapterMessage(frame, DEFAULT_LIMITS2);
|
|
574
975
|
if (!parsed.ok) {
|
|
575
976
|
this.#faults.push({ code: parsed.code, detail: parsed.detail });
|
|
977
|
+
this.#notifyChange();
|
|
576
978
|
return;
|
|
577
979
|
}
|
|
578
980
|
this.#messages.push({ message: parsed.message, stdoutBytes: this.#bytes, atMs: this.#now() });
|
|
579
981
|
if (parsed.message.type === "log") this.#logs.push(parsed.message.record);
|
|
580
|
-
|
|
581
|
-
if (parsed.message.type === "tree-delta") this.#compose(parsed.message);
|
|
982
|
+
this.#notifyChange();
|
|
582
983
|
if (parsed.message.type !== "hello") return;
|
|
583
984
|
const ack = {
|
|
584
985
|
type: "hello-ack",
|
|
585
986
|
protocol: PROTOCOL_ID,
|
|
586
987
|
sessionId: this.sessionId,
|
|
587
988
|
limits: DEFAULT_LIMITS2,
|
|
588
|
-
|
|
589
|
-
subscribe: this.#subscribe === "diffs" && parsed.message.capabilities.includes("tree-diffs") ? "diffs" : "snapshots",
|
|
989
|
+
subscribe: "snapshots",
|
|
590
990
|
marker: { enabled: parsed.message.capabilities.includes("render-revisions") },
|
|
591
991
|
// Granted only to an adapter that asked: an adapter that never announced
|
|
592
992
|
// `logs` must not be handed a budget it can then claim it was given.
|
|
@@ -594,78 +994,39 @@ ${this.#adapterAccount()}`;
|
|
|
594
994
|
};
|
|
595
995
|
socket.write(encodeFrame(ack, DEFAULT_LIMITS2.maxFrameBytes));
|
|
596
996
|
}
|
|
597
|
-
/** Applies one delta to the held tree, recording the first failure. */
|
|
598
|
-
#compose(delta) {
|
|
599
|
-
const { type: _type, ...body } = delta;
|
|
600
|
-
this.#deltas.push(body);
|
|
601
|
-
const base = this.#composed;
|
|
602
|
-
if (base === null) {
|
|
603
|
-
this.#compositionError ??= `delta ${body.baseRevision}\u2192${body.revision} arrived before any full tree`;
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
const result = applyTreeDelta(base, body, DEFAULT_LIMITS2);
|
|
607
|
-
if (!result.ok) {
|
|
608
|
-
this.#compositionError ??= `delta ${body.baseRevision}\u2192${body.revision} did not compose (${result.code}): ${result.detail}`;
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
611
|
-
this.#composed = result.snapshot;
|
|
612
|
-
}
|
|
613
|
-
/**
|
|
614
|
-
* Asks the adapter for a full tree and resolves with it.
|
|
615
|
-
*
|
|
616
|
-
* This is what turns composition into a check rather than a belief: the
|
|
617
|
-
* locally composed tree is compared against one the adapter built itself.
|
|
618
|
-
*/
|
|
619
|
-
async requestTree(timeoutMs = 1e4) {
|
|
620
|
-
const socket = this.#socket;
|
|
621
|
-
if (socket === null) return null;
|
|
622
|
-
this.#requestId += 1;
|
|
623
|
-
const requestId = this.#requestId;
|
|
624
|
-
socket.write(encodeFrame({ type: "get-tree", requestId }, DEFAULT_LIMITS2.maxFrameBytes));
|
|
625
|
-
const deadline = Date.now() + timeoutMs;
|
|
626
|
-
for (; ; ) {
|
|
627
|
-
const reply = this.#messages.find(
|
|
628
|
-
(entry) => entry.message.type === "get-tree-result" && entry.message.requestId === requestId
|
|
629
|
-
);
|
|
630
|
-
if (reply !== void 0) {
|
|
631
|
-
return reply.message.snapshot ?? null;
|
|
632
|
-
}
|
|
633
|
-
if (Date.now() >= deadline) return null;
|
|
634
|
-
await delay(20);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
997
|
#now() {
|
|
638
998
|
return performance.now() - this.#startedAt;
|
|
639
999
|
}
|
|
1000
|
+
#notifyChange() {
|
|
1001
|
+
for (const resolve of [...this.#changeWaiters]) resolve();
|
|
1002
|
+
}
|
|
1003
|
+
#armChange(deadline) {
|
|
1004
|
+
let settled = false;
|
|
1005
|
+
let resolvePromise;
|
|
1006
|
+
const finish = () => {
|
|
1007
|
+
if (settled) return;
|
|
1008
|
+
settled = true;
|
|
1009
|
+
clearTimeout(timer);
|
|
1010
|
+
this.#changeWaiters.delete(finish);
|
|
1011
|
+
resolvePromise();
|
|
1012
|
+
};
|
|
1013
|
+
const promise = new Promise((resolve) => {
|
|
1014
|
+
resolvePromise = resolve;
|
|
1015
|
+
});
|
|
1016
|
+
const timer = setTimeout(finish, Math.max(0, deadline - performance.now()));
|
|
1017
|
+
timer.unref?.();
|
|
1018
|
+
this.#changeWaiters.add(finish);
|
|
1019
|
+
return { wait: () => promise, cancel: finish };
|
|
1020
|
+
}
|
|
640
1021
|
};
|
|
641
1022
|
var MARKER_TEXT_PREFIX = `\x1B]${MARKER_OSC_CODE};${MARKER_OSC_PREFIX}`;
|
|
642
|
-
function delay(ms) {
|
|
643
|
-
return new Promise((resolve) => {
|
|
644
|
-
const timer = setTimeout(resolve, ms);
|
|
645
|
-
timer.unref?.();
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
1023
|
|
|
649
1024
|
// src/adapter-conformance.ts
|
|
650
|
-
|
|
651
|
-
const { expect } = await import("vitest");
|
|
652
|
-
const observation = probe.observe();
|
|
653
|
-
expect(observation.compositionError, "the adapter produced a delta nobody could apply").toBeNull();
|
|
654
|
-
expect(observation.composed).not.toBeNull();
|
|
655
|
-
expect(observation.deltas.length).toBeGreaterThan(0);
|
|
656
|
-
const authoritative = await probe.requestTree(timeoutMs);
|
|
657
|
-
expect(authoritative, "the adapter answered no get-tree").not.toBeNull();
|
|
658
|
-
const composed = observation.composed;
|
|
659
|
-
const truth = authoritative;
|
|
660
|
-
const byId = (nodes) => [...nodes].sort((left, right) => left.id.localeCompare(right.id));
|
|
661
|
-
expect(byId(truth.nodes)).toEqual(byId(composed.nodes));
|
|
662
|
-
expect([...truth.rootIds].sort()).toEqual([...composed.rootIds].sort());
|
|
663
|
-
}
|
|
664
|
-
var CONVENTION_SUMMARY_DIR = join3(tmpdir2(), "termwright-conformance-conventions");
|
|
1025
|
+
var CONVENTION_SUMMARY_DIR = join4(tmpdir3(), "termwright-conformance-conventions");
|
|
665
1026
|
function writeConventionSummary(name, declared, outcomes) {
|
|
666
1027
|
try {
|
|
667
1028
|
mkdirSync(CONVENTION_SUMMARY_DIR, { recursive: true });
|
|
668
|
-
const file =
|
|
1029
|
+
const file = join4(CONVENTION_SUMMARY_DIR, `${name.replace(/[^\w.-]+/gu, "_")}.json`);
|
|
669
1030
|
writeFileSync(
|
|
670
1031
|
file,
|
|
671
1032
|
JSON.stringify(
|
|
@@ -688,20 +1049,14 @@ function writeConventionSummary(name, declared, outcomes) {
|
|
|
688
1049
|
} catch {
|
|
689
1050
|
}
|
|
690
1051
|
}
|
|
691
|
-
function delay2(ms) {
|
|
692
|
-
return new Promise((resolve) => {
|
|
693
|
-
const timer = setTimeout(resolve, ms);
|
|
694
|
-
timer.unref?.();
|
|
695
|
-
});
|
|
696
|
-
}
|
|
697
1052
|
async function settle(probe, quietMs = 250, budgetMs = 5e3) {
|
|
698
|
-
const deadline =
|
|
1053
|
+
const deadline = performance.now() + budgetMs;
|
|
699
1054
|
let seen = -1;
|
|
700
1055
|
for (; ; ) {
|
|
701
1056
|
const length = probe.observe().stdout.length;
|
|
702
1057
|
if (length === seen) return;
|
|
703
1058
|
seen = length;
|
|
704
|
-
if (
|
|
1059
|
+
if (performance.now() >= deadline) return;
|
|
705
1060
|
await new Promise((resolve) => {
|
|
706
1061
|
setTimeout(resolve, quietMs);
|
|
707
1062
|
});
|
|
@@ -747,11 +1102,14 @@ var CONTAINER_ROLES = /* @__PURE__ */ new Set([
|
|
|
747
1102
|
]);
|
|
748
1103
|
var snapshotsOf = (observation) => observation.messages.filter((entry) => entry.message.type === "snapshot").map((entry) => entry.message.snapshot);
|
|
749
1104
|
async function runAdapterConformance(options) {
|
|
750
|
-
const { afterAll,
|
|
1105
|
+
const { afterAll, afterEach, beforeEach, describe, expect } = await import("vitest");
|
|
1106
|
+
const { it: resourceAwareIt } = await import("@termwright/resource-broker/vitest");
|
|
1107
|
+
const it = resourceAwareIt.resources({ terminals: 1, traceWriters: 0 });
|
|
751
1108
|
const timeout = options.timeoutMs ?? 1e4;
|
|
752
1109
|
const toolchain = options.requires === void 0 || commandAvailable(options.requires.probe, {
|
|
753
1110
|
...options.requires.cwd === void 0 ? {} : { cwd: options.requires.cwd },
|
|
754
|
-
...options.requires.timeoutMs === void 0 ? {} : { timeoutMs: options.requires.timeoutMs }
|
|
1111
|
+
...options.requires.timeoutMs === void 0 ? {} : { timeoutMs: options.requires.timeoutMs },
|
|
1112
|
+
...options.requires.env === void 0 ? {} : { env: options.requires.env }
|
|
755
1113
|
});
|
|
756
1114
|
const probeOptions = {
|
|
757
1115
|
...options.columns === void 0 ? {} : { columns: options.columns },
|
|
@@ -761,7 +1119,10 @@ async function runAdapterConformance(options) {
|
|
|
761
1119
|
describe.skipIf(!ptyAvailable() || !toolchain)(title, { timeout: timeout * 4 }, () => {
|
|
762
1120
|
describe("the dormant rule", () => {
|
|
763
1121
|
it("opens nothing and emits no marker without an endpoint", async () => {
|
|
764
|
-
const probe = await AdapterProbe.start(options.spawn(), {
|
|
1122
|
+
const probe = await AdapterProbe.start(options.spawn(), {
|
|
1123
|
+
...probeOptions,
|
|
1124
|
+
instrument: false
|
|
1125
|
+
});
|
|
765
1126
|
try {
|
|
766
1127
|
await probe.waitForText(options.ready, timeout);
|
|
767
1128
|
await probe.write(options.interaction.input);
|
|
@@ -803,7 +1164,7 @@ async function runAdapterConformance(options) {
|
|
|
803
1164
|
describe("an instrumented session", () => {
|
|
804
1165
|
let probe;
|
|
805
1166
|
let beforeInput;
|
|
806
|
-
|
|
1167
|
+
beforeEach(
|
|
807
1168
|
async () => {
|
|
808
1169
|
probe = await AdapterProbe.start(options.spawn(), probeOptions);
|
|
809
1170
|
await probe.waitForText(options.ready, timeout);
|
|
@@ -820,7 +1181,7 @@ async function runAdapterConformance(options) {
|
|
|
820
1181
|
// timeout, which makes real adapters flaky under a parallel root run.
|
|
821
1182
|
timeout * 4
|
|
822
1183
|
);
|
|
823
|
-
|
|
1184
|
+
afterEach(async () => {
|
|
824
1185
|
await probe?.stop();
|
|
825
1186
|
});
|
|
826
1187
|
it("completes the handshake before anything else", async () => {
|
|
@@ -829,10 +1190,14 @@ async function runAdapterConformance(options) {
|
|
|
829
1190
|
expect(connections).toBe(1);
|
|
830
1191
|
expect(first?.message.type).toBe("hello");
|
|
831
1192
|
const hello = first?.message;
|
|
832
|
-
expect(hello.protocol).toBe(
|
|
1193
|
+
expect(hello.protocol).toBe(PROTOCOL_ID2);
|
|
833
1194
|
expect(hello.adapter.name.length).toBeGreaterThan(0);
|
|
834
1195
|
expect(hello.adapter.version.length).toBeGreaterThan(0);
|
|
835
|
-
expect(
|
|
1196
|
+
expect(
|
|
1197
|
+
hello.capabilities.every(
|
|
1198
|
+
(entry) => ADAPTER_CAPABILITIES.includes(entry)
|
|
1199
|
+
)
|
|
1200
|
+
).toBe(true);
|
|
836
1201
|
expect(hello.capabilities).toContain("tree");
|
|
837
1202
|
expect(messages.filter((entry) => entry.message.type === "hello")).toHaveLength(1);
|
|
838
1203
|
const firstLog = messages.findIndex((entry) => entry.message.type === "log");
|
|
@@ -849,7 +1214,10 @@ async function runAdapterConformance(options) {
|
|
|
849
1214
|
const addressable = (latest?.nodes ?? []).filter(
|
|
850
1215
|
(node) => node.name.length > 0 || node.testId !== void 0
|
|
851
1216
|
);
|
|
852
|
-
expect(
|
|
1217
|
+
expect(
|
|
1218
|
+
addressable.length,
|
|
1219
|
+
"the tree has no node that a locator could address"
|
|
1220
|
+
).toBeGreaterThan(0);
|
|
853
1221
|
}
|
|
854
1222
|
);
|
|
855
1223
|
it("publishes only valid snapshots, bound to this session", async () => {
|
|
@@ -860,7 +1228,7 @@ async function runAdapterConformance(options) {
|
|
|
860
1228
|
for (const snapshot of snapshots) {
|
|
861
1229
|
expect(validateSnapshot(snapshot, DEFAULT_LIMITS3)).toMatchObject({ ok: true });
|
|
862
1230
|
expect(snapshot.sessionId).toBe(probe.sessionId);
|
|
863
|
-
expect(snapshot.v).toBe(
|
|
1231
|
+
expect(snapshot.v).toBe(2);
|
|
864
1232
|
const ids = new Set(snapshot.nodes.map((node) => node.id));
|
|
865
1233
|
for (const node of snapshot.nodes) {
|
|
866
1234
|
if (node.parentId === void 0) expect(snapshot.rootIds).toContain(node.id);
|
|
@@ -870,20 +1238,25 @@ async function runAdapterConformance(options) {
|
|
|
870
1238
|
const revisions = snapshots.map((snapshot) => snapshot.revision);
|
|
871
1239
|
expect([...revisions]).toEqual([...new Set(revisions)].sort((left, right) => left - right));
|
|
872
1240
|
});
|
|
873
|
-
it.skipIf(options.
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1241
|
+
it.skipIf(options.expectIntendedGeometry !== true)(
|
|
1242
|
+
"publishes intended geometry in viewport cells",
|
|
1243
|
+
() => {
|
|
1244
|
+
const snapshots = snapshotsOf(probe.observe());
|
|
1245
|
+
const latest = snapshots[snapshots.length - 1];
|
|
1246
|
+
expect(latest).toBeDefined();
|
|
1247
|
+
const bounded = latest?.nodes.filter((node) => node.geometry.intendedRect.status === "known") ?? [];
|
|
1248
|
+
expect(bounded.length).toBeGreaterThan(0);
|
|
1249
|
+
for (const node of bounded) {
|
|
1250
|
+
const bounds = node.geometry.intendedRect.status === "known" ? node.geometry.intendedRect.value : void 0;
|
|
1251
|
+
expect(bounds).toBeDefined();
|
|
1252
|
+
if (bounds === void 0) continue;
|
|
1253
|
+
expect(bounds.row).toBeGreaterThanOrEqual(0);
|
|
1254
|
+
expect(bounds.column).toBeGreaterThanOrEqual(0);
|
|
1255
|
+
expect(bounds.row).toBeLessThan(latest?.rows ?? 0);
|
|
1256
|
+
expect(bounds.column).toBeLessThan(latest?.columns ?? 0);
|
|
1257
|
+
}
|
|
885
1258
|
}
|
|
886
|
-
|
|
1259
|
+
);
|
|
887
1260
|
it("orders every revision as snapshot, then commit, then marker", async () => {
|
|
888
1261
|
await probe.write(options.interaction.input);
|
|
889
1262
|
await probe.waitForText(options.interaction.expect, timeout);
|
|
@@ -952,7 +1325,12 @@ async function runAdapterConformance(options) {
|
|
|
952
1325
|
return;
|
|
953
1326
|
}
|
|
954
1327
|
if (titles.length > 0) {
|
|
955
|
-
outcomes.push({
|
|
1328
|
+
outcomes.push({
|
|
1329
|
+
rule,
|
|
1330
|
+
what,
|
|
1331
|
+
status: "documented",
|
|
1332
|
+
detail: `${titles.join("; ")} \u2014 ${failure}`
|
|
1333
|
+
});
|
|
956
1334
|
return;
|
|
957
1335
|
}
|
|
958
1336
|
outcomes.push({ rule, what, status: "violation", detail: failure });
|
|
@@ -977,7 +1355,7 @@ async function runAdapterConformance(options) {
|
|
|
977
1355
|
const latest = snapshotsOf(beforeInput).at(-1);
|
|
978
1356
|
const node = latest?.nodes.find((entry) => entry.testId === wanted);
|
|
979
1357
|
if (node === void 0) return `no node carries the test id ${JSON.stringify(wanted)}`;
|
|
980
|
-
return node.value === "" ? null : `the value is ${JSON.stringify(node.value)}, not an empty
|
|
1358
|
+
return node.value?.status === "known" && node.value.value === "" ? null : `the value is ${JSON.stringify(node.value)}, not an empty known value`;
|
|
981
1359
|
});
|
|
982
1360
|
});
|
|
983
1361
|
it("convention 5: value is derived only for value-bearing roles", () => {
|
|
@@ -990,7 +1368,9 @@ async function runAdapterConformance(options) {
|
|
|
990
1368
|
if (offenders.length > 0) {
|
|
991
1369
|
return `derived a value outside {textbox, progressbar}: ${offenders.map((node) => `${node.role} ${JSON.stringify(node.name)}`).join(", ")}`;
|
|
992
1370
|
}
|
|
993
|
-
const booleans = nodes.filter(
|
|
1371
|
+
const booleans = nodes.filter(
|
|
1372
|
+
(node) => node.value?.status === "known" && (node.value.value === "true" || node.value.value === "false")
|
|
1373
|
+
);
|
|
994
1374
|
return booleans.length === 0 ? null : `published a boolean as a value on ${booleans.map((node) => node.role).join(", ")}`;
|
|
995
1375
|
});
|
|
996
1376
|
});
|
|
@@ -1054,47 +1434,33 @@ async function runAdapterConformance(options) {
|
|
|
1054
1434
|
"the adapter sent log records without announcing the logs capability"
|
|
1055
1435
|
).toEqual([]);
|
|
1056
1436
|
});
|
|
1057
|
-
it.skipIf(options.logs === void 0)(
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
hello.
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
const announced = (beforeInput.messages[0]?.message).capabilities;
|
|
1083
|
-
if (!announced.includes("tree-diffs")) return;
|
|
1084
|
-
const diffs = await AdapterProbe.start(options.spawn(), { ...probeOptions, subscribe: "diffs" });
|
|
1085
|
-
try {
|
|
1086
|
-
await diffs.waitForText(options.ready, timeout);
|
|
1087
|
-
await diffs.waitFor((observation) => observation.composed !== null, timeout);
|
|
1088
|
-
for (let press = 0; press < 3; press += 1) {
|
|
1089
|
-
await diffs.write(options.interaction.input);
|
|
1090
|
-
await delay2(150);
|
|
1091
|
-
}
|
|
1092
|
-
await diffs.waitFor((observation) => observation.deltas.length > 0, timeout);
|
|
1093
|
-
await assertDeltasCompose(diffs, timeout);
|
|
1094
|
-
} finally {
|
|
1095
|
-
await diffs.stop();
|
|
1437
|
+
it.skipIf(options.logs === void 0)(
|
|
1438
|
+
"carries a log record without printing it",
|
|
1439
|
+
async () => {
|
|
1440
|
+
const logs = options.logs;
|
|
1441
|
+
const hello = probe.observe().messages[0]?.message;
|
|
1442
|
+
expect(
|
|
1443
|
+
hello.capabilities.includes("logs"),
|
|
1444
|
+
"the registration declares logs, but the adapter never announced the capability"
|
|
1445
|
+
).toBe(true);
|
|
1446
|
+
const before = probe.observe().logs.length;
|
|
1447
|
+
if (logs.input !== void 0) await probe.write(logs.input);
|
|
1448
|
+
await probe.waitFor(
|
|
1449
|
+
(observation2) => observation2.logs.length > (logs.input === void 0 ? 0 : before),
|
|
1450
|
+
timeout,
|
|
1451
|
+
"a log record over the negotiated channel"
|
|
1452
|
+
);
|
|
1453
|
+
const observation = probe.observe();
|
|
1454
|
+
const record = observation.logs.find((entry) => entry.message.includes(logs.expect));
|
|
1455
|
+
expect(record, `no log record matched ${JSON.stringify(logs.expect)}`).toBeDefined();
|
|
1456
|
+
expect(record?.seq).toBeGreaterThanOrEqual(0);
|
|
1457
|
+
const seqs = observation.logs.map((entry) => entry.seq);
|
|
1458
|
+
expect(seqs).toEqual([...seqs].sort((left, right) => left - right));
|
|
1459
|
+
expect(new Set(seqs).size).toBe(seqs.length);
|
|
1460
|
+
expect(observation.screen).not.toContain(logs.expect);
|
|
1461
|
+
expect(observation.text).not.toContain(logs.expect);
|
|
1096
1462
|
}
|
|
1097
|
-
|
|
1463
|
+
);
|
|
1098
1464
|
it("keeps the application alive when the channel is cut", async () => {
|
|
1099
1465
|
const before = probe.observe();
|
|
1100
1466
|
probe.cutChannel();
|