@termwright/conformance 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1129 @@
1
+ // src/adapter-conformance.ts
2
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
3
+ import { tmpdir as tmpdir2 } from "os";
4
+ import { join as join3 } from "path";
5
+ import { ADAPTER_CAPABILITIES, validateSnapshot, DEFAULT_LIMITS as DEFAULT_LIMITS3 } from "@termwright/protocol";
6
+
7
+ // src/support/probe.ts
8
+ import { createServer } from "net";
9
+ import { readFileSync } from "fs";
10
+ import { mkdtemp, rm } from "fs/promises";
11
+ import { tmpdir } from "os";
12
+ import { join as join2 } from "path";
13
+ import { randomBytes, randomUUID } from "crypto";
14
+ import {
15
+ DEFAULT_LIMITS as DEFAULT_LIMITS2,
16
+ ENV_ENDPOINT as ENV_ENDPOINT2,
17
+ ENV_PROTOCOL,
18
+ ENV_TOKEN as ENV_TOKEN2,
19
+ MARKER_OSC_CODE,
20
+ MARKER_OSC_PREFIX,
21
+ parseAdapterMessage,
22
+ PROTOCOL_ID,
23
+ PROTOCOL_VERSION,
24
+ applyTreeDelta,
25
+ createFrameDecoder,
26
+ encodeFrame,
27
+ generateToken,
28
+ verifyMarkerPayload
29
+ } from "@termwright/protocol";
30
+ import xh from "@xterm/headless";
31
+ import { createNodePtyBackend as createNodePtyBackend2 } from "@termwright/driver";
32
+
33
+ // src/support/pty.ts
34
+ import { spawnSync } from "child_process";
35
+ import { existsSync } from "fs";
36
+ import { dirname, join } from "path";
37
+ import { fileURLToPath as fileURLToPath2 } from "url";
38
+ import { launchTerminal, createNodePtyBackend } from "@termwright/driver";
39
+
40
+ // ../probe-ink/dist/chunk-4KCXY7SI.js
41
+ import { validateProbeAnnotations } from "@termwright/protocol";
42
+
43
+ // ../recognizers/dist/index.js
44
+ import {
45
+ DEFAULT_LIMITS,
46
+ resolveNodeBounds,
47
+ SEMANTIC_ROLES
48
+ } from "@termwright/protocol";
49
+ var HOST_ROLES = Object.freeze({
50
+ "ink-root": "application",
51
+ "ink-text": "text",
52
+ "ink-virtual-text": "text",
53
+ "ink-box": "generic"
54
+ });
55
+ function roleForInkHost(host) {
56
+ return HOST_ROLES[host];
57
+ }
58
+ var ARIA_ROLES = Object.freeze({
59
+ button: "button",
60
+ checkbox: "checkbox",
61
+ combobox: "generic",
62
+ list: "list",
63
+ listbox: "list",
64
+ listitem: "listitem",
65
+ menu: "menu",
66
+ menuitem: "menuitem",
67
+ option: "listitem",
68
+ progressbar: "progressbar",
69
+ radio: "radio",
70
+ radiogroup: "generic",
71
+ tab: "tab",
72
+ tablist: "generic",
73
+ table: "table",
74
+ textbox: "textbox",
75
+ timer: "status",
76
+ toolbar: "generic"
77
+ });
78
+ var ROLE_BY_CLASS = Object.freeze({
79
+ RootRenderable: "application",
80
+ TextRenderable: "text",
81
+ TextNodeRenderable: "text",
82
+ RootTextNodeRenderable: "text",
83
+ CodeRenderable: "text",
84
+ MarkdownRenderable: "text",
85
+ ASCIIFontRenderable: "text",
86
+ InputRenderable: "textbox",
87
+ TextareaRenderable: "textbox",
88
+ EditBufferRenderable: "textbox",
89
+ SelectRenderable: "list",
90
+ TextTableRenderable: "table",
91
+ ScrollBarRenderable: "scrollbar"
92
+ });
93
+ function roleForOpenTuiClass(frameworkType) {
94
+ return Object.hasOwn(ROLE_BY_CLASS, frameworkType) ? ROLE_BY_CLASS[frameworkType] : void 0;
95
+ }
96
+ var ROLES = new Set(SEMANTIC_ROLES);
97
+ var UTF8_ENCODER = new TextEncoder();
98
+ var ROLE_MAPS = Object.freeze({ ink: roleForInkHost, opentui: roleForOpenTuiClass });
99
+
100
+ // ../probe-ink/dist/chunk-IUFXTMZ7.js
101
+ var INSTRUMENT_URL = new URL("./instrument.js", import.meta.url).href;
102
+
103
+ // ../probe-ink/dist/chunk-LO7YF74P.js
104
+ import { ENV_ENDPOINT, ENV_TOKEN } from "@termwright/protocol";
105
+
106
+ // ../probe-ink/dist/index.js
107
+ import { fileURLToPath, pathToFileURL } from "url";
108
+ var PROBE_ENTRIES = {
109
+ bun: fileURLToPath(new URL("./bun-preload.js", import.meta.url)),
110
+ node: fileURLToPath(new URL("./node-hook.js", import.meta.url))
111
+ };
112
+ function withProbe(runtime, argv) {
113
+ if (argv.length === 0) throw new Error("withProbe needs an interpreter in argv");
114
+ const [interpreter, ...rest] = argv;
115
+ const flag = runtime === "bun" ? "--preload" : "--import";
116
+ return {
117
+ command: [
118
+ interpreter,
119
+ flag,
120
+ pathToFileURL(PROBE_ENTRIES[runtime]).href,
121
+ ...rest
122
+ ],
123
+ runtime
124
+ };
125
+ }
126
+
127
+ // src/support/pty.ts
128
+ function fixturePath(name) {
129
+ return join(packageRoot(), "src", "fixtures", name);
130
+ }
131
+ var cachedRoot = null;
132
+ function packageRoot() {
133
+ if (cachedRoot !== null) return cachedRoot;
134
+ let directory = dirname(fileURLToPath2(import.meta.url));
135
+ for (; ; ) {
136
+ if (existsSync(join(directory, "package.json"))) {
137
+ cachedRoot = directory;
138
+ return directory;
139
+ }
140
+ const parent = dirname(directory);
141
+ if (parent === directory) {
142
+ throw new Error("@termwright/conformance: could not locate the package root from this module");
143
+ }
144
+ directory = parent;
145
+ }
146
+ }
147
+ var CONFORMANCE_FIXTURES = Object.freeze({
148
+ /** Uninstrumented app: proves the generic fallback (§20.1). */
149
+ generic: () => fixturePath("generic-app.mjs"),
150
+ /** Shell-shaped app emitting OSC 133 marks; `--marks=off` suppresses them. */
151
+ prompt: () => fixturePath("prompt-app.mjs"),
152
+ /** Normal-render Ink app used to exercise launch-time probe attachment. */
153
+ inkProbe: () => fixturePath("ink-probe-app.mjs"),
154
+ /** Hostile wire peer; takes a scenario name as its first argument (§20.3). */
155
+ adversarialPeer: () => fixturePath("adversarial-peer.mjs")
156
+ });
157
+ var cachedPty = null;
158
+ function ptyAvailable() {
159
+ if (cachedPty !== null) return cachedPty;
160
+ if (process.env["TERMWRIGHT_SKIP_PTY"] === "1") {
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
+ }
176
+ return cachedPty;
177
+ }
178
+ function environment(extra) {
179
+ const env = {};
180
+ for (const [key, value] of Object.entries(process.env)) {
181
+ if (value !== void 0) env[key] = value;
182
+ }
183
+ return { ...env, ...extra };
184
+ }
185
+ function createSessionPool() {
186
+ const open = [];
187
+ return {
188
+ async launch(fixture, options = {}) {
189
+ const { args = [], probe, ready: _ready, ...launchOptions } = options;
190
+ const base = [process.execPath, fixture, ...args];
191
+ const terminal = await launchTerminal({
192
+ command: probe === "ink" ? withProbe("node", base).command : base,
193
+ columns: 80,
194
+ rows: 24,
195
+ // No `env` and no `envMode`: the suites run against the secret-safe
196
+ // 'replace' default, which is what a user gets. Forwarding the runner's
197
+ // whole environment here would quietly make every suite an 'inherit'
198
+ // test and leave the default uncovered.
199
+ // Conformance runs start a fresh Node process, a pseudo-terminal and a
200
+ // socket per test, and several suites run beside other builds. The
201
+ // driver's defaults are tight enough that machine load, rather than the
202
+ // implementation, would decide the result — a genuine failure still
203
+ // fails here, just later.
204
+ timeouts: { text: 3e4, action: 3e4, exit: 3e4, idle: 1e4 },
205
+ ...launchOptions
206
+ });
207
+ open.push(terminal);
208
+ if (options.ready !== void 0) await waitForStart(terminal, options.ready, fixture);
209
+ return terminal;
210
+ },
211
+ async closeAll() {
212
+ while (open.length > 0) {
213
+ const terminal = open.pop();
214
+ await terminal?.close();
215
+ }
216
+ }
217
+ };
218
+ }
219
+ async function waitForStart(terminal, ready, fixture) {
220
+ let bytes = 0;
221
+ const off = terminal.events.on("output", ({ data }) => {
222
+ bytes += data.length;
223
+ });
224
+ try {
225
+ await terminal.waitForText(ready);
226
+ } catch (error) {
227
+ const exit = await Promise.race([terminal.exit, Promise.resolve(null)]);
228
+ const detail = bytes === 0 ? `it produced no output at all${exit === null ? " and is still running" : `; it exited ${JSON.stringify(exit)}`}` : `it produced ${bytes} bytes but never drew ${String(ready)}`;
229
+ throw new Error(`conformance: ${fixture.split("/").pop() ?? fixture} did not start \u2014 ${detail}`, {
230
+ cause: error
231
+ });
232
+ } finally {
233
+ off();
234
+ }
235
+ }
236
+ function commandAvailable(command, options = {}) {
237
+ const [binary, ...args] = command;
238
+ if (binary === void 0) return false;
239
+ const printable = command.join(" ");
240
+ try {
241
+ const result = spawnSync(binary, args, {
242
+ ...options.cwd === void 0 ? {} : { cwd: options.cwd },
243
+ timeout: options.timeoutMs ?? 12e4,
244
+ encoding: "utf8",
245
+ env: environment()
246
+ });
247
+ if (result.status === 0) return true;
248
+ if (options.quiet === true) return false;
249
+ const reason = result.error?.message ?? (result.signal === null ? `exit ${String(result.status)}` : `signal ${result.signal}`);
250
+ process.stderr.write(
251
+ `conformance: probe \`${printable}\` failed (${reason})
252
+ ${(result.stderr ?? "").trim().split("\n").slice(-3).join("\n")}
253
+ `
254
+ );
255
+ return false;
256
+ } catch (error) {
257
+ if (options.quiet !== true) {
258
+ process.stderr.write(
259
+ `conformance: probe \`${printable}\` could not run: ${error instanceof Error ? error.message : String(error)}
260
+ `
261
+ );
262
+ }
263
+ return false;
264
+ }
265
+ }
266
+
267
+ // src/support/probe.ts
268
+ var LOG_BUDGET = Object.freeze({ enabled: true, maxRecordsPerSecond: 200, burst: 400 });
269
+ var MARKER_PATTERN = new RegExp(
270
+ `\\x1b\\]${MARKER_OSC_CODE};(${MARKER_OSC_PREFIX}[0-9]+;[A-Za-z0-9_-]+)(?:\\x07|\\x1b\\\\)`,
271
+ "gu"
272
+ );
273
+ var AdapterProbe = class _AdapterProbe {
274
+ sessionId;
275
+ token;
276
+ #server;
277
+ #directory;
278
+ #pty;
279
+ #subscribe;
280
+ #terminal;
281
+ #startedAt = performance.now();
282
+ #messages = [];
283
+ #markers = [];
284
+ #faults = [];
285
+ #logs = [];
286
+ #deltas = [];
287
+ #composed = null;
288
+ #compositionError = null;
289
+ #requestId = 0;
290
+ #chunks = [];
291
+ #bytes = 0;
292
+ #text = "";
293
+ #markerScanFrom = 0;
294
+ #connections = 0;
295
+ #socket = null;
296
+ #exit = null;
297
+ /** Where the adapter writes its own account of attaching, if it writes one. */
298
+ #debugFile = null;
299
+ #stopped = false;
300
+ constructor(identity, server, directory, pty, size, subscribe) {
301
+ this.#subscribe = subscribe;
302
+ this.sessionId = identity.sessionId;
303
+ this.token = identity.token;
304
+ this.#server = server;
305
+ this.#directory = directory;
306
+ this.#pty = pty;
307
+ this.#terminal = new xh.Terminal({
308
+ cols: size.columns,
309
+ rows: size.rows,
310
+ allowProposedApi: true,
311
+ scrollback: 1e3
312
+ });
313
+ }
314
+ /** Creates the endpoint (unless dormant), then spawns the fixture. */
315
+ static async start(command, options = {}) {
316
+ const instrument = options.instrument ?? true;
317
+ const sessionId = randomUUID();
318
+ const token = generateToken();
319
+ let server = null;
320
+ let directory = null;
321
+ let endpoint = null;
322
+ if (instrument) {
323
+ server = createServer();
324
+ if (process.platform === "win32") {
325
+ endpoint = `\\\\.\\pipe\\termwright-probe-${randomBytes(16).toString("hex")}`;
326
+ } else {
327
+ directory = await mkdtemp(join2(tmpdir(), "termwright-probe-"));
328
+ endpoint = join2(directory, "semantic.sock");
329
+ }
330
+ const listening = server;
331
+ const address = endpoint;
332
+ await new Promise((resolve, reject) => {
333
+ listening.once("error", reject);
334
+ listening.listen(address, () => {
335
+ listening.removeListener("error", reject);
336
+ resolve();
337
+ });
338
+ });
339
+ }
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
+ }
375
+ /** Everything observed so far. Safe to call at any point. */
376
+ observe() {
377
+ return {
378
+ messages: [...this.#messages],
379
+ markers: [...this.#markers],
380
+ faults: [...this.#faults],
381
+ connections: this.#connections,
382
+ stdout: this.#stdout(),
383
+ text: this.#text,
384
+ 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
389
+ };
390
+ }
391
+ /** The visible grid as text, trailing whitespace trimmed per row. */
392
+ screenText() {
393
+ const buffer = this.#terminal.buffer.active;
394
+ const rows = [];
395
+ for (let row = 0; row < this.#terminal.rows; row += 1) {
396
+ rows.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "");
397
+ }
398
+ return rows.join("\n");
399
+ }
400
+ /** The child's exit status, or `null` while it is still running. */
401
+ get exitStatus() {
402
+ return this.#exit;
403
+ }
404
+ /** Writes raw bytes to the child, exactly as a terminal would. */
405
+ async write(input) {
406
+ this.#pty.write(new TextEncoder().encode(input));
407
+ await Promise.resolve();
408
+ }
409
+ /**
410
+ * Resolves once `needle` appears on the rendered grid.
411
+ *
412
+ * Matching the byte stream instead would only work for adapters that happen
413
+ * to write their text contiguously: a framework that positions each run of
414
+ * cells never emits `focus: reject` as those twelve bytes in a row.
415
+ */
416
+ async waitForText(needle, timeoutMs = 1e4) {
417
+ const deadline = Date.now() + timeoutMs;
418
+ for (; ; ) {
419
+ const screen = this.screenText();
420
+ if (needle instanceof RegExp ? needle.test(screen) : screen.includes(needle)) return;
421
+ if (Date.now() >= deadline) {
422
+ throw new Error(
423
+ `adapter conformance: ${String(needle)} never appeared on the fixture's screen
424
+ screen was:
425
+ ${screen}`
426
+ );
427
+ }
428
+ await delay(20);
429
+ }
430
+ }
431
+ /**
432
+ * Resolves once `predicate` holds over the current observation.
433
+ *
434
+ * `what` names the thing being waited for, and the failure carries what the
435
+ * probe could see when it gave up. "Condition never became true" is not a
436
+ * result anybody can act on: an adapter that never connected, one that
437
+ * connected and published nothing, and one whose binary died all produce it,
438
+ * and only the observation tells them apart.
439
+ */
440
+ async waitFor(predicate, timeoutMs = 1e4, what = "the condition") {
441
+ const deadline = Date.now() + timeoutMs;
442
+ for (; ; ) {
443
+ if (predicate(this.observe())) return;
444
+ if (Date.now() >= deadline) {
445
+ throw new Error(`adapter conformance: ${what} never happened \u2014 ${this.describe()}`);
446
+ }
447
+ await delay(20);
448
+ }
449
+ }
450
+ /** What the probe has seen so far, for a failure that has to explain itself. */
451
+ describe() {
452
+ const { messages, connections } = this.observe();
453
+ const kinds = /* @__PURE__ */ new Map();
454
+ for (const recorded of messages) {
455
+ const kind = recorded.message.type;
456
+ kinds.set(kind, (kinds.get(kind) ?? 0) + 1);
457
+ }
458
+ const traffic = kinds.size === 0 ? "no messages" : [...kinds].map(([kind, n]) => `${kind}\xD7${n}`).join(", ");
459
+ const screen = this.screenText().trimEnd().split("\n").filter((line) => line.trim() !== "");
460
+ const exit = this.#exit === null ? "still running" : `exited ${JSON.stringify(this.#exit)}`;
461
+ return `${connections} connection(s) to the endpoint, ${traffic}; the child is ${exit}; last screen line: ${JSON.stringify(screen.at(-1) ?? "")}
462
+ ${this.#adapterAccount()}`;
463
+ }
464
+ /**
465
+ * What the adapter says about its own attach, if the client writes it.
466
+ *
467
+ * The outside view cannot tell "dialled the wrong transport" from "the driver
468
+ * was not listening" from "the process never started" — all three look like
469
+ * no connection. The clients write that distinction to `TERMWRIGHT_DEBUG_FILE`,
470
+ * so it is quoted here rather than left in a file nobody opens. An adapter
471
+ * that writes nothing is itself an answer, and says so.
472
+ */
473
+ #adapterAccount() {
474
+ if (this.#debugFile === null) return "adapter debug log: not requested";
475
+ let contents;
476
+ try {
477
+ contents = readFileSync(this.#debugFile, "utf8");
478
+ } catch {
479
+ return `adapter debug log: nothing written to ${this.#debugFile} \u2014 the client either predates the log or never ran`;
480
+ }
481
+ const lines = contents.trimEnd().split("\n").slice(-12);
482
+ return `adapter debug log (last ${lines.length} line(s)):
483
+ ${lines.join("\n ")}`;
484
+ }
485
+ /** Waits for the child to exit and returns its status. */
486
+ async waitForExit(timeoutMs = 1e4) {
487
+ const deadline = Date.now() + timeoutMs;
488
+ while (this.#exit === null) {
489
+ if (Date.now() >= deadline) throw new Error("adapter conformance: the fixture never exited");
490
+ await delay(20);
491
+ }
492
+ return this.#exit;
493
+ }
494
+ /** Cuts the semantic channel without touching the child: the disconnect case. */
495
+ cutChannel() {
496
+ this.#socket?.destroy();
497
+ this.#socket = null;
498
+ }
499
+ /** Stops the child and releases the endpoint. Idempotent. */
500
+ async stop() {
501
+ if (this.#stopped) return;
502
+ this.#stopped = true;
503
+ this.#socket?.destroy();
504
+ this.#pty.dispose();
505
+ this.#terminal.dispose();
506
+ if (this.#server !== null) await new Promise((resolve) => this.#server?.close(() => resolve()));
507
+ if (this.#directory !== null) await rm(this.#directory, { recursive: true, force: true }).catch(() => {
508
+ });
509
+ if (this.#debugFile !== null) await rm(this.#debugFile, { force: true }).catch(() => {
510
+ });
511
+ }
512
+ // -------------------------------------------------------------------------
513
+ #stdout() {
514
+ const out = new Uint8Array(this.#bytes);
515
+ let offset = 0;
516
+ for (const chunk of this.#chunks) {
517
+ out.set(chunk, offset);
518
+ offset += chunk.length;
519
+ }
520
+ this.#chunks = [out];
521
+ return out;
522
+ }
523
+ #onData(data) {
524
+ this.#chunks.push(data);
525
+ this.#bytes += data.length;
526
+ this.#text += Buffer.from(data).toString("utf8");
527
+ this.#terminal.write(data);
528
+ this.#scanMarkers();
529
+ }
530
+ /** Finds render markers in the byte stream and verifies each against the token. */
531
+ #scanMarkers() {
532
+ MARKER_PATTERN.lastIndex = this.#markerScanFrom;
533
+ for (; ; ) {
534
+ const match = MARKER_PATTERN.exec(this.#text);
535
+ if (match === null) break;
536
+ const payload = match[1] ?? "";
537
+ const verified = verifyMarkerPayload(payload, this.token, this.sessionId);
538
+ if (verified === null) {
539
+ this.#faults.push({ code: "marker", detail: `marker did not verify: ${JSON.stringify(payload)}` });
540
+ } else {
541
+ this.#markers.push({ revision: verified.revision, offset: match.index, atMs: this.#now() });
542
+ }
543
+ this.#markerScanFrom = match.index + match[0].length;
544
+ }
545
+ MARKER_PATTERN.lastIndex = 0;
546
+ }
547
+ #onConnection(socket) {
548
+ this.#connections += 1;
549
+ if (this.#socket !== null) {
550
+ this.#faults.push({ code: "second-connection", detail: "the adapter opened a second channel" });
551
+ socket.destroy();
552
+ return;
553
+ }
554
+ this.#socket = socket;
555
+ const decoder = createFrameDecoder(DEFAULT_LIMITS2.maxFrameBytes);
556
+ socket.on("data", (chunk) => {
557
+ let frames;
558
+ try {
559
+ frames = decoder.push(chunk);
560
+ } catch (error) {
561
+ this.#faults.push({ code: "framing", detail: error instanceof Error ? error.message : String(error) });
562
+ socket.destroy();
563
+ return;
564
+ }
565
+ for (const frame of frames) this.#onFrame(socket, frame);
566
+ });
567
+ socket.on("error", () => socket.destroy());
568
+ socket.on("close", () => {
569
+ if (this.#socket === socket) this.#socket = null;
570
+ });
571
+ }
572
+ #onFrame(socket, frame) {
573
+ const parsed = parseAdapterMessage(frame, DEFAULT_LIMITS2);
574
+ if (!parsed.ok) {
575
+ this.#faults.push({ code: parsed.code, detail: parsed.detail });
576
+ return;
577
+ }
578
+ this.#messages.push({ message: parsed.message, stdoutBytes: this.#bytes, atMs: this.#now() });
579
+ if (parsed.message.type === "log") this.#logs.push(parsed.message.record);
580
+ if (parsed.message.type === "snapshot") this.#composed = parsed.message.snapshot;
581
+ if (parsed.message.type === "tree-delta") this.#compose(parsed.message);
582
+ if (parsed.message.type !== "hello") return;
583
+ const ack = {
584
+ type: "hello-ack",
585
+ protocol: PROTOCOL_ID,
586
+ sessionId: this.sessionId,
587
+ limits: DEFAULT_LIMITS2,
588
+ // Deltas are only ever sent to a driver that asked for them.
589
+ subscribe: this.#subscribe === "diffs" && parsed.message.capabilities.includes("tree-diffs") ? "diffs" : "snapshots",
590
+ marker: { enabled: parsed.message.capabilities.includes("render-revisions") },
591
+ // Granted only to an adapter that asked: an adapter that never announced
592
+ // `logs` must not be handed a budget it can then claim it was given.
593
+ ...parsed.message.capabilities.includes("logs") ? { logs: LOG_BUDGET } : {}
594
+ };
595
+ socket.write(encodeFrame(ack, DEFAULT_LIMITS2.maxFrameBytes));
596
+ }
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
+ #now() {
638
+ return performance.now() - this.#startedAt;
639
+ }
640
+ };
641
+ 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
+
649
+ // src/adapter-conformance.ts
650
+ async function assertDeltasCompose(probe, timeoutMs) {
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");
665
+ function writeConventionSummary(name, declared, outcomes) {
666
+ try {
667
+ mkdirSync(CONVENTION_SUMMARY_DIR, { recursive: true });
668
+ const file = join3(CONVENTION_SUMMARY_DIR, `${name.replace(/[^\w.-]+/gu, "_")}.json`);
669
+ writeFileSync(
670
+ file,
671
+ JSON.stringify(
672
+ {
673
+ adapter: name,
674
+ declared: Object.fromEntries(declared),
675
+ outcomes,
676
+ // A rule declared in the README that no check covers: the suite
677
+ // cannot confirm or refute it, and saying so is more honest than
678
+ // letting it read as verified.
679
+ unverified: [...declared.keys()].filter(
680
+ (rule) => !outcomes.some((outcome) => outcome.rule === rule)
681
+ )
682
+ },
683
+ null,
684
+ 2
685
+ ),
686
+ "utf8"
687
+ );
688
+ } catch {
689
+ }
690
+ }
691
+ function delay2(ms) {
692
+ return new Promise((resolve) => {
693
+ const timer = setTimeout(resolve, ms);
694
+ timer.unref?.();
695
+ });
696
+ }
697
+ async function settle(probe, quietMs = 250, budgetMs = 5e3) {
698
+ const deadline = Date.now() + budgetMs;
699
+ let seen = -1;
700
+ for (; ; ) {
701
+ const length = probe.observe().stdout.length;
702
+ if (length === seen) return;
703
+ seen = length;
704
+ if (Date.now() >= deadline) return;
705
+ await new Promise((resolve) => {
706
+ setTimeout(resolve, quietMs);
707
+ });
708
+ }
709
+ }
710
+ function parseDeclaredDeviations(readme) {
711
+ const declared = /* @__PURE__ */ new Map();
712
+ const start = readme.indexOf("## Deviations");
713
+ if (start < 0) return declared;
714
+ const rest = readme.slice(start + "## Deviations".length);
715
+ const end = rest.indexOf("\n## ");
716
+ const section = end < 0 ? rest : rest.slice(0, end);
717
+ for (const line of section.split("\n")) {
718
+ for (const match of line.matchAll(/\*\*Rule (\d+)\s*—\s*([^*]+?)\.?\*\*/gu)) {
719
+ add(declared, match[1], match[2].trim());
720
+ }
721
+ for (const match of line.matchAll(/\*\*(.+?)\*\*\s*\(rule (\d+)\)/gu)) {
722
+ add(declared, match[2], match[1].trim());
723
+ }
724
+ const trimmed = line.trim();
725
+ if (trimmed.startsWith("|") && !/^\|[\s:|-]*\|?$/u.test(trimmed) && !/^\|\s*rule\s*\|/iu.test(trimmed)) {
726
+ const cells = trimmed.split("|").slice(1, -1).map((cell) => cell.trim());
727
+ const numbered = /^(\d+)\s*[—-]\s*(.+)$/u.exec(cells[0] ?? "");
728
+ if (numbered !== null) {
729
+ add(declared, numbered[1], numbered[2].trim());
730
+ } else if ((cells[1] ?? "").length > 0) {
731
+ add(declared, "other", cells[1]);
732
+ }
733
+ }
734
+ }
735
+ return declared;
736
+ }
737
+ function add(map, key, value) {
738
+ map.set(key, [...map.get(key) ?? [], value]);
739
+ }
740
+ var CONTAINER_ROLES = /* @__PURE__ */ new Set([
741
+ "region",
742
+ "dialog",
743
+ "list",
744
+ "table",
745
+ "application",
746
+ "menu"
747
+ ]);
748
+ var snapshotsOf = (observation) => observation.messages.filter((entry) => entry.message.type === "snapshot").map((entry) => entry.message.snapshot);
749
+ async function runAdapterConformance(options) {
750
+ const { afterAll, beforeAll, describe, expect, it } = await import("vitest");
751
+ const timeout = options.timeoutMs ?? 1e4;
752
+ const toolchain = options.requires === void 0 || commandAvailable(options.requires.probe, {
753
+ ...options.requires.cwd === void 0 ? {} : { cwd: options.requires.cwd },
754
+ ...options.requires.timeoutMs === void 0 ? {} : { timeoutMs: options.requires.timeoutMs }
755
+ });
756
+ const probeOptions = {
757
+ ...options.columns === void 0 ? {} : { columns: options.columns },
758
+ ...options.rows === void 0 ? {} : { rows: options.rows }
759
+ };
760
+ const title = !ptyAvailable() ? `adapter conformance: ${options.name} (skipped: no pseudo-terminal here)` : toolchain ? `adapter conformance: ${options.name}` : `adapter conformance: ${options.name} (skipped: ${options.requires?.label ?? "toolchain"} unavailable)`;
761
+ describe.skipIf(!ptyAvailable() || !toolchain)(title, { timeout: timeout * 4 }, () => {
762
+ describe("the dormant rule", () => {
763
+ it("opens nothing and emits no marker without an endpoint", async () => {
764
+ const probe = await AdapterProbe.start(options.spawn(), { ...probeOptions, instrument: false });
765
+ try {
766
+ await probe.waitForText(options.ready, timeout);
767
+ await probe.write(options.interaction.input);
768
+ await probe.waitForText(options.interaction.expect, timeout);
769
+ const observation = probe.observe();
770
+ expect(observation.connections).toBe(0);
771
+ expect(observation.messages).toHaveLength(0);
772
+ expect(observation.text).not.toContain(MARKER_TEXT_PREFIX);
773
+ } finally {
774
+ await probe.stop();
775
+ }
776
+ });
777
+ it.skipIf(options.baseline === void 0)(
778
+ "produces the same bytes as a build without the adapter",
779
+ async () => {
780
+ const startup = async (command) => {
781
+ const probe = await AdapterProbe.start(command, { ...probeOptions, instrument: false });
782
+ try {
783
+ await probe.waitForText(options.ready, timeout);
784
+ await settle(probe);
785
+ const observation = probe.observe();
786
+ return { stdout: observation.stdout, screen: observation.screen };
787
+ } finally {
788
+ await probe.stop();
789
+ }
790
+ };
791
+ const instrumented = await startup(options.spawn());
792
+ const plain = await startup(options.baseline());
793
+ if (process.platform === "win32") {
794
+ expect(instrumented.screen).toBe(plain.screen);
795
+ } else {
796
+ expect(Buffer.from(instrumented.stdout).toString("binary")).toBe(
797
+ Buffer.from(plain.stdout).toString("binary")
798
+ );
799
+ }
800
+ }
801
+ );
802
+ });
803
+ describe("an instrumented session", () => {
804
+ let probe;
805
+ let beforeInput;
806
+ beforeAll(
807
+ async () => {
808
+ probe = await AdapterProbe.start(options.spawn(), probeOptions);
809
+ await probe.waitForText(options.ready, timeout);
810
+ await probe.waitFor(
811
+ (observation) => snapshotsOf(observation).length > 0,
812
+ timeout,
813
+ "a first snapshot from the adapter"
814
+ );
815
+ beforeInput = probe.observe();
816
+ },
817
+ // Starting the process, waiting for its first frame, and completing the
818
+ // adapter handshake are separate bounded operations. Vitest otherwise
819
+ // applies its 10-second hook default even though the suite has a larger
820
+ // timeout, which makes real adapters flaky under a parallel root run.
821
+ timeout * 4
822
+ );
823
+ afterAll(async () => {
824
+ await probe?.stop();
825
+ });
826
+ it("completes the handshake before anything else", async () => {
827
+ const { messages, connections } = probe.observe();
828
+ const first = messages[0];
829
+ expect(connections).toBe(1);
830
+ expect(first?.message.type).toBe("hello");
831
+ const hello = first?.message;
832
+ expect(hello.protocol).toBe("termwright/1");
833
+ expect(hello.adapter.name.length).toBeGreaterThan(0);
834
+ expect(hello.adapter.version.length).toBeGreaterThan(0);
835
+ expect(hello.capabilities.every((entry) => ADAPTER_CAPABILITIES.includes(entry))).toBe(true);
836
+ expect(hello.capabilities).toContain("tree");
837
+ expect(messages.filter((entry) => entry.message.type === "hello")).toHaveLength(1);
838
+ const firstLog = messages.findIndex((entry) => entry.message.type === "log");
839
+ expect(firstLog === -1 || firstLog > 0).toBe(true);
840
+ });
841
+ it(
842
+ options.treeBeforeInput === void 0 ? "publishes a usable tree before any input" : `publishes a usable tree before any input (exempt: ${options.treeBeforeInput.reason})`,
843
+ { skip: options.treeBeforeInput !== void 0 },
844
+ () => {
845
+ const latest = snapshotsOf(beforeInput).at(-1);
846
+ expect(latest, "no snapshot arrived before any input was sent").toBeDefined();
847
+ expect(latest?.nodes.length ?? 0).toBeGreaterThan(0);
848
+ expect(latest?.rootIds.length ?? 0).toBeGreaterThan(0);
849
+ const addressable = (latest?.nodes ?? []).filter(
850
+ (node) => node.name.length > 0 || node.testId !== void 0
851
+ );
852
+ expect(addressable.length, "the tree has no node that a locator could address").toBeGreaterThan(0);
853
+ }
854
+ );
855
+ it("publishes only valid snapshots, bound to this session", async () => {
856
+ const observation = probe.observe();
857
+ const snapshots = snapshotsOf(observation);
858
+ expect(observation.faults).toEqual([]);
859
+ expect(snapshots.length).toBeGreaterThan(0);
860
+ for (const snapshot of snapshots) {
861
+ expect(validateSnapshot(snapshot, DEFAULT_LIMITS3)).toMatchObject({ ok: true });
862
+ expect(snapshot.sessionId).toBe(probe.sessionId);
863
+ expect(snapshot.v).toBe(1);
864
+ const ids = new Set(snapshot.nodes.map((node) => node.id));
865
+ for (const node of snapshot.nodes) {
866
+ if (node.parentId === void 0) expect(snapshot.rootIds).toContain(node.id);
867
+ else expect(ids.has(node.parentId)).toBe(true);
868
+ }
869
+ }
870
+ const revisions = snapshots.map((snapshot) => snapshot.revision);
871
+ expect([...revisions]).toEqual([...new Set(revisions)].sort((left, right) => left - right));
872
+ });
873
+ it.skipIf(options.expectAbsoluteBounds !== true)("publishes viewport-absolute bounds", () => {
874
+ const snapshots = snapshotsOf(probe.observe());
875
+ const latest = snapshots[snapshots.length - 1];
876
+ expect(latest).toBeDefined();
877
+ const bounded = latest?.nodes.filter((node) => node.bounds !== void 0) ?? [];
878
+ expect(bounded.length).toBeGreaterThan(0);
879
+ for (const node of bounded) {
880
+ const bounds = node.bounds;
881
+ expect(bounds.row).toBeGreaterThanOrEqual(0);
882
+ expect(bounds.column).toBeGreaterThanOrEqual(0);
883
+ expect(bounds.row).toBeLessThan(latest?.rows ?? 0);
884
+ expect(bounds.column).toBeLessThan(latest?.columns ?? 0);
885
+ }
886
+ });
887
+ it("orders every revision as snapshot, then commit, then marker", async () => {
888
+ await probe.write(options.interaction.input);
889
+ await probe.waitForText(options.interaction.expect, timeout);
890
+ await probe.waitFor(
891
+ (observation2) => observation2.markers.length >= 2,
892
+ timeout,
893
+ "a second render marker"
894
+ );
895
+ const complete = (observation2) => observation2.markers.every(
896
+ (marker) => observation2.messages.some(
897
+ (entry) => entry.message.type === "snapshot" && entry.message.snapshot.revision === marker.revision
898
+ ) && observation2.messages.some(
899
+ (entry) => entry.message.type === "revision-commit" && entry.message.revision === marker.revision
900
+ )
901
+ );
902
+ await probe.waitFor(complete, timeout, "every marker paired with a snapshot and a commit");
903
+ const observation = probe.observe();
904
+ const markers = observation.markers;
905
+ expect(markers.length).toBeGreaterThan(0);
906
+ expect(markers.map((marker) => marker.revision)).toEqual(
907
+ [...markers.map((marker) => marker.revision)].sort((left, right) => left - right)
908
+ );
909
+ for (const marker of markers) {
910
+ const snapshot = observation.messages.find(
911
+ (entry) => entry.message.type === "snapshot" && entry.message.snapshot.revision === marker.revision
912
+ );
913
+ const commit = observation.messages.find(
914
+ (entry) => entry.message.type === "revision-commit" && entry.message.revision === marker.revision
915
+ );
916
+ expect(snapshot, `no snapshot for revision ${marker.revision}`).toBeDefined();
917
+ expect(commit, `no commit for revision ${marker.revision}`).toBeDefined();
918
+ const snapshotIndex = observation.messages.indexOf(snapshot);
919
+ expect(snapshotIndex).toBeLessThan(observation.messages.indexOf(commit));
920
+ }
921
+ let previousEnd = 0;
922
+ for (const marker of markers) {
923
+ expect(marker.offset).toBeGreaterThan(previousEnd);
924
+ previousEnd = marker.offset;
925
+ }
926
+ });
927
+ const conventions = options.conventions ?? {};
928
+ const readme = conventions.readmePath !== void 0 && existsSync2(conventions.readmePath) ? readFileSync2(conventions.readmePath, "utf8") : "";
929
+ const declared = parseDeclaredDeviations(readme);
930
+ const outcomes = [];
931
+ it("reads the deviations its README declares", () => {
932
+ if (conventions.readmePath === void 0) return;
933
+ if (!readme.includes("## Deviations")) return;
934
+ const start = readme.indexOf("## Deviations");
935
+ const rest = readme.slice(start + "## Deviations".length);
936
+ const end = rest.indexOf("\n## ");
937
+ const section = end < 0 ? rest : rest.slice(0, end);
938
+ const structured = /^\s*[-*|]/mu.test(section) || section.includes("**");
939
+ if (!structured) return;
940
+ expect(
941
+ declared.size,
942
+ `${options.name} has a "## Deviations" section with entries this suite could not read; its declarations would be invisible and its documented limitations would report as errors. Teach \`parseDeclaredDeviations\` the shape it uses.`
943
+ ).toBeGreaterThan(0);
944
+ });
945
+ const convention = (rule, what, check) => {
946
+ const failure = check();
947
+ const titles = declared.get(rule) ?? [];
948
+ if (failure === null) {
949
+ outcomes.push(
950
+ titles.length === 0 ? { rule, what, status: "compliant" } : { rule, what, status: "checked-despite-declaration", detail: titles.join("; ") }
951
+ );
952
+ return;
953
+ }
954
+ if (titles.length > 0) {
955
+ outcomes.push({ rule, what, status: "documented", detail: `${titles.join("; ")} \u2014 ${failure}` });
956
+ return;
957
+ }
958
+ outcomes.push({ rule, what, status: "violation", detail: failure });
959
+ expect.fail(`convention ${rule} (${what}): ${failure}`);
960
+ };
961
+ afterAll(() => {
962
+ writeConventionSummary(options.name, declared, outcomes);
963
+ });
964
+ it("convention 3: an author-annotated test id reaches the wire", () => {
965
+ if (conventions.annotatedTestId === void 0) return;
966
+ const wanted = conventions.annotatedTestId;
967
+ convention("3", "an annotated test id reaches the wire", () => {
968
+ const latest = snapshotsOf(beforeInput).at(-1);
969
+ const node = latest?.nodes.find((entry) => entry.testId === wanted);
970
+ return node === void 0 ? `no node carries the test id ${JSON.stringify(wanted)}` : null;
971
+ });
972
+ });
973
+ it("convention 5: an empty textbox publishes an empty value", () => {
974
+ if (conventions.emptyTextboxTestId === void 0) return;
975
+ const wanted = conventions.emptyTextboxTestId;
976
+ convention("5", "an empty textbox publishes an empty value", () => {
977
+ const latest = snapshotsOf(beforeInput).at(-1);
978
+ const node = latest?.nodes.find((entry) => entry.testId === wanted);
979
+ 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 string`;
981
+ });
982
+ });
983
+ it("convention 5: value is derived only for value-bearing roles", () => {
984
+ convention("5", "value is derived only for value-bearing roles", () => {
985
+ const annotated = new Set(conventions.annotatedValues ?? []);
986
+ const nodes = snapshotsOf(beforeInput).at(-1)?.nodes ?? [];
987
+ const offenders = nodes.filter(
988
+ (node) => node.value !== void 0 && node.role !== "textbox" && node.role !== "progressbar" && !(node.testId !== void 0 && annotated.has(node.testId))
989
+ );
990
+ if (offenders.length > 0) {
991
+ return `derived a value outside {textbox, progressbar}: ${offenders.map((node) => `${node.role} ${JSON.stringify(node.name)}`).join(", ")}`;
992
+ }
993
+ const booleans = nodes.filter((node) => node.value === "true" || node.value === "false");
994
+ return booleans.length === 0 ? null : `published a boolean as a value on ${booleans.map((node) => node.role).join(", ")}`;
995
+ });
996
+ });
997
+ it("convention 2: no container is named from the text it contains", () => {
998
+ convention("2", "no container is named from the text it contains", () => {
999
+ const nodes = snapshotsOf(beforeInput).at(-1)?.nodes ?? [];
1000
+ const children = /* @__PURE__ */ new Map();
1001
+ for (const node of nodes) {
1002
+ if (node.parentId === void 0) continue;
1003
+ children.set(node.parentId, [...children.get(node.parentId) ?? [], node.id]);
1004
+ }
1005
+ const descendantText = (id) => {
1006
+ const out = [];
1007
+ const pending = [...children.get(id) ?? []];
1008
+ while (pending.length > 0) {
1009
+ const next = nodes.find((node) => node.id === pending.pop());
1010
+ if (next === void 0) continue;
1011
+ if (next.name.length > 0) out.push(next.name);
1012
+ pending.push(...children.get(next.id) ?? []);
1013
+ }
1014
+ return out;
1015
+ };
1016
+ const offenders = nodes.filter((node) => CONTAINER_ROLES.has(node.role) && node.name.length > 0).filter((node) => {
1017
+ const texts = descendantText(node.id);
1018
+ const joined = texts.join(" ").replace(/\s+/gu, " ").trim();
1019
+ return texts.includes(node.name) || joined.length > 0 && joined === node.name;
1020
+ });
1021
+ return offenders.length === 0 ? null : `named from content: ${offenders.map((node) => `${node.role} ${JSON.stringify(node.name)}`).join(", ")}`;
1022
+ });
1023
+ });
1024
+ it("convention 2: a container with no label of its own has an empty name", () => {
1025
+ if (conventions.unnamedContainerTestId === void 0) return;
1026
+ const wanted = conventions.unnamedContainerTestId;
1027
+ convention("2", "an unlabelled container has an empty name", () => {
1028
+ const node = snapshotsOf(beforeInput).at(-1)?.nodes.find((entry) => entry.testId === wanted);
1029
+ if (node === void 0) return `no node carries the test id ${JSON.stringify(wanted)}`;
1030
+ return node.name === "" ? null : `the container is named ${JSON.stringify(node.name)}`;
1031
+ });
1032
+ });
1033
+ it.skipIf(conventions.readmePath === void 0)(
1034
+ "declares its deviations in its README (advisory)",
1035
+ () => {
1036
+ const path = conventions.readmePath;
1037
+ const text = existsSync2(path) ? readFileSync2(path, "utf8") : "";
1038
+ if (!text.includes("## Deviations")) {
1039
+ process.stderr.write(
1040
+ `conformance: ${options.name} has no "## Deviations" section in ${path}; rules 1, 2 and 4 are unverifiable from outside, so an undeclared difference is invisible
1041
+ `
1042
+ );
1043
+ }
1044
+ expect(true).toBe(true);
1045
+ }
1046
+ );
1047
+ it("sends log records only if it negotiated the channel", async () => {
1048
+ const hello = beforeInput.messages[0]?.message;
1049
+ if (hello.capabilities.includes("logs")) return;
1050
+ await probe.write(options.interaction.input);
1051
+ await probe.waitForText(options.interaction.expect, timeout);
1052
+ expect(
1053
+ probe.observe().logs,
1054
+ "the adapter sent log records without announcing the logs capability"
1055
+ ).toEqual([]);
1056
+ });
1057
+ it.skipIf(options.logs === void 0)("carries a log record without printing it", async () => {
1058
+ const logs = options.logs;
1059
+ const hello = probe.observe().messages[0]?.message;
1060
+ expect(
1061
+ hello.capabilities.includes("logs"),
1062
+ "the registration declares logs, but the adapter never announced the capability"
1063
+ ).toBe(true);
1064
+ const before = probe.observe().logs.length;
1065
+ if (logs.input !== void 0) await probe.write(logs.input);
1066
+ await probe.waitFor(
1067
+ (observation2) => observation2.logs.length > (logs.input === void 0 ? 0 : before),
1068
+ timeout,
1069
+ "a log record over the negotiated channel"
1070
+ );
1071
+ const observation = probe.observe();
1072
+ const record = observation.logs.find((entry) => entry.message.includes(logs.expect));
1073
+ expect(record, `no log record matched ${JSON.stringify(logs.expect)}`).toBeDefined();
1074
+ expect(record?.seq).toBeGreaterThanOrEqual(0);
1075
+ const seqs = observation.logs.map((entry) => entry.seq);
1076
+ expect(seqs).toEqual([...seqs].sort((left, right) => left - right));
1077
+ expect(new Set(seqs).size).toBe(seqs.length);
1078
+ expect(observation.screen).not.toContain(logs.expect);
1079
+ expect(observation.text).not.toContain(logs.expect);
1080
+ });
1081
+ it("produces deltas that compose to the tree it would have sent", async () => {
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();
1096
+ }
1097
+ });
1098
+ it("keeps the application alive when the channel is cut", async () => {
1099
+ const before = probe.observe();
1100
+ probe.cutChannel();
1101
+ await probe.write(options.interaction.input);
1102
+ await probe.waitFor(
1103
+ (observation) => observation.text.length > before.text.length,
1104
+ timeout,
1105
+ "any further output from the child"
1106
+ );
1107
+ const after = probe.observe();
1108
+ expect(after.text.length).toBeGreaterThan(before.text.length);
1109
+ expect(after.connections).toBe(1);
1110
+ expect(probe.exitStatus).toBeNull();
1111
+ await probe.write(options.quit.input);
1112
+ const status = await probe.waitForExit(timeout);
1113
+ if (options.quit.exitCode !== void 0) expect(status.code).toBe(options.quit.exitCode);
1114
+ });
1115
+ });
1116
+ });
1117
+ }
1118
+ export {
1119
+ AdapterProbe,
1120
+ CONFORMANCE_FIXTURES,
1121
+ MARKER_TEXT_PREFIX,
1122
+ createSessionPool,
1123
+ environment,
1124
+ fixturePath,
1125
+ parseDeclaredDeviations,
1126
+ ptyAvailable,
1127
+ runAdapterConformance
1128
+ };
1129
+ //# sourceMappingURL=index.js.map