@termwright/conformance 0.2.0 → 0.3.0

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