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