@termwright/conformance 0.2.0 → 0.3.1

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