@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@termwright/conformance",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "generic/semantic/adversarial fixtures + adapter contract tests",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -10,7 +10,7 @@
10
10
  },
11
11
  "type": "module",
12
12
  "engines": {
13
- "node": ">=22"
13
+ "node": "^22.0.0 || ^24.0.0"
14
14
  },
15
15
  "exports": {
16
16
  ".": {
@@ -24,22 +24,24 @@
24
24
  ],
25
25
  "dependencies": {
26
26
  "@xterm/headless": "^6.0.0",
27
- "@termwright/driver": "0.2.0",
28
- "@termwright/protocol": "0.2.0"
27
+ "@termwright/protocol": "0.3.0",
28
+ "@termwright/resource-broker": "0.3.0",
29
+ "@termwright/driver": "0.3.0"
29
30
  },
30
31
  "devDependencies": {
31
32
  "@modelcontextprotocol/sdk": "^1.30.0",
32
33
  "@types/react": "^19.2.18",
33
34
  "ink": "^7.1.1",
34
35
  "react": "^19.2.8",
35
- "@termwright/logs": "0.2.0",
36
- "@termwright/ink": "0.2.0",
37
- "@termwright/probe-tview": "0.2.0",
38
- "@termwright/mcp": "0.2.0",
39
- "@termwright/probe-ink": "0.2.0"
36
+ "@termwright/ink": "0.3.0",
37
+ "@termwright/logs": "0.3.0",
38
+ "@termwright/probe-ink": "0.3.0",
39
+ "@termwright/probe-tview": "0.3.0",
40
+ "@termwright/test": "0.3.0",
41
+ "@termwright/mcp": "0.3.0"
40
42
  },
41
43
  "peerDependencies": {
42
- "vitest": ">=3.2.0"
44
+ "vitest": "4.1.11"
43
45
  },
44
46
  "peerDependenciesMeta": {
45
47
  "vitest": {
@@ -47,10 +49,10 @@
47
49
  }
48
50
  },
49
51
  "scripts": {
50
- "build": "tsup src/index.ts --format esm --dts --sourcemap",
52
+ "build": "tsup src/index.ts --format esm --dts --sourcemap --clean",
51
53
  "typecheck": "tsc --noEmit",
52
- "test": "vitest run",
53
- "test:hostile": "vitest run --config vitest.hostile.config.ts src/suites/adversarial.test.ts src/suites/mcp-sessions.test.ts",
54
+ "test": "pnpm --dir ../.. test -- -- --run packages/conformance",
55
+ "test:hostile": "pnpm --dir ../.. test -- -- --run packages/conformance/src/suites/adversarial.test.ts packages/conformance/src/suites/mcp-sessions.test.ts",
54
56
  "conformance": "node scripts/conformance.mjs"
55
57
  }
56
58
  }
@@ -86,16 +86,6 @@ const MAX_LOG_RECORD_BYTES = 32 * 1024;
86
86
  /** Scenarios that need the log channel negotiated in the handshake. */
87
87
  const NEEDS_LOGS = new Set(['log-seq-duplicate', 'log-seq-gap', 'log-oversized', 'log-flood']);
88
88
 
89
- /** Scenarios that announce `tree-diffs` and push deltas instead of whole trees. */
90
- const NEEDS_DELTAS = new Set([
91
- 'delta-sequence',
92
- 'delta-bad-base',
93
- 'delta-cursor-clear',
94
- 'delta-flood',
95
- 'delta-removed-missing',
96
- 'delta-before-snapshot',
97
- ]);
98
-
99
89
  let logSeq = 0;
100
90
  let logBudget = null;
101
91
 
@@ -148,27 +138,62 @@ function marker(revision, forSession = sessionId, terminator = '\x07') {
148
138
 
149
139
  function tree(revision, nodes, overrides = {}) {
150
140
  return {
151
- v: 1,
141
+ v: 2,
152
142
  sessionId,
153
143
  revision,
154
144
  columns: 80,
155
145
  rows: 24,
156
146
  rootIds: ['n1'],
157
147
  nodes,
148
+ coordinateSpace: {
149
+ status: 'known',
150
+ value: 'viewport-cells',
151
+ evidence: evidence('adversarial-peer'),
152
+ },
153
+ hitGrid: {
154
+ status: 'unsupported',
155
+ capability: 'pointer-hit-grid',
156
+ reason: 'framework-unobservable',
157
+ },
158
158
  ...overrides,
159
159
  };
160
160
  }
161
161
 
162
+ function evidence(providerId) {
163
+ return { source: 'framework', method: 'native', strength: 'authoritative', providerId };
164
+ }
165
+
166
+ function geometry(rect) {
167
+ return {
168
+ displayed: { status: 'known', value: true, evidence: evidence('adversarial-peer') },
169
+ intendedRect: { status: 'known', value: { ...rect }, evidence: evidence('adversarial-peer') },
170
+ visibleRect: { status: 'known', value: { ...rect }, evidence: evidence('adversarial-peer') },
171
+ };
172
+ }
173
+
174
+ function unknownGeometry() {
175
+ return {
176
+ displayed: { status: 'unknown', reason: 'awaiting-revision-pair' },
177
+ intendedRect: { status: 'unknown', reason: 'awaiting-revision-pair' },
178
+ visibleRect: { status: 'unknown', reason: 'awaiting-revision-pair' },
179
+ };
180
+ }
181
+
162
182
  function validNodes(label) {
163
183
  return [
164
- { id: 'n1', role: 'region', name: 'Peer', bounds: { row: 0, column: 0, width: 20, height: 2 } },
184
+ {
185
+ id: 'n1',
186
+ role: 'region',
187
+ name: 'Peer',
188
+ geometry: geometry({ row: 0, column: 0, width: 20, height: 2 }),
189
+ },
165
190
  {
166
191
  id: 'n2',
167
192
  parentId: 'n1',
168
193
  role: 'button',
169
194
  name: label,
170
195
  testId: 'peer-button',
171
- bounds: { row: 1, column: 0, width: 10, height: 1 },
196
+ geometry: geometry({ row: 1, column: 0, width: 10, height: 1 }),
172
197
  actions: ['activate', 'focus'],
173
198
  },
174
199
  ];
@@ -217,28 +242,34 @@ const SCENARIOS = {
217
242
  cycle: () => {
218
243
  send(
219
244
  tree(2, [
220
- { id: 'n1', role: 'region', name: 'Peer' },
221
- { id: 'n2', parentId: 'n3', role: 'button', name: 'A' },
222
- { id: 'n3', parentId: 'n2', role: 'button', name: 'B' },
245
+ { id: 'n1', role: 'region', name: 'Peer', geometry: unknownGeometry() },
246
+ { id: 'n2', parentId: 'n3', role: 'button', name: 'A', geometry: unknownGeometry() },
247
+ { id: 'n3', parentId: 'n2', role: 'button', name: 'B', geometry: unknownGeometry() },
223
248
  ]),
224
249
  );
225
250
  },
226
251
  'missing-parent': () => {
227
252
  send(
228
253
  tree(2, [
229
- { id: 'n1', role: 'region', name: 'Peer' },
230
- { id: 'n2', parentId: 'ghost', role: 'button', name: 'Orphan' },
254
+ { id: 'n1', role: 'region', name: 'Peer', geometry: unknownGeometry() },
255
+ {
256
+ id: 'n2',
257
+ parentId: 'ghost',
258
+ role: 'button',
259
+ name: 'Orphan',
260
+ geometry: unknownGeometry(),
261
+ },
231
262
  ]),
232
263
  );
233
264
  },
234
- 'impossible-bounds': () => {
265
+ 'impossible-geometry': () => {
235
266
  send(
236
267
  tree(2, [
237
268
  {
238
269
  id: 'n1',
239
270
  role: 'region',
240
271
  name: 'Peer',
241
- bounds: { row: 9_000, column: 9_000, width: 5, height: 5 },
272
+ geometry: geometry({ row: 9_000, column: 9_000, width: 5, height: 5 }),
242
273
  },
243
274
  ]),
244
275
  );
@@ -260,18 +291,24 @@ const SCENARIOS = {
260
291
  flood: () => {
261
292
  const noise = `${'x'.repeat(4096)}\r\n`;
262
293
  for (let chunk = 0; chunk < 512; chunk += 1) process.stdout.write(noise);
294
+ say('PEER FLOOD OUTPUT COMPLETE');
263
295
  for (let revision = 2; revision <= 100; revision += 1) {
264
- socket.write(frame({ type: 'snapshot', snapshot: tree(revision, validNodes(`Flood${revision}`)) }));
296
+ socket.write(
297
+ frame({ type: 'snapshot', snapshot: tree(revision, validNodes(`Flood${revision}`)) }),
298
+ );
265
299
  }
266
300
  },
267
301
  'disconnect-mid-render': () => {
268
- socket.write(frame({ type: 'snapshot', snapshot: tree(2, validNodes('Torn')) }));
269
- socket.destroy();
302
+ socket.end(frame({ type: 'snapshot', snapshot: tree(2, validNodes('Torn')) }));
270
303
  },
271
304
  'hostile-unicode': () => {
272
305
  // A lone high surrogate survives JSON.stringify as an escape, so the bytes
273
306
  // on the wire are ASCII and the receiver is the one that has to fail closed.
274
- send(tree(2, [{ id: 'n1', role: 'region', name: 'lone \ud800 surrogate' }]));
307
+ send(
308
+ tree(2, [
309
+ { id: 'n1', role: 'region', name: 'lone \ud800 surrogate', geometry: unknownGeometry() },
310
+ ]),
311
+ );
275
312
  },
276
313
  'foreign-session': () => {
277
314
  send(tree(2, validNodes('Foreign'), { sessionId: 'someone-elses-session' }));
@@ -291,9 +328,15 @@ const SCENARIOS = {
291
328
  socket.write(frame({ type: 'snapshot', snapshot: value }));
292
329
  },
293
330
  'too-many-nodes': () => {
294
- const nodes = [{ id: 'n1', role: 'region', name: 'Peer' }];
331
+ const nodes = [{ id: 'n1', role: 'region', name: 'Peer', geometry: unknownGeometry() }];
295
332
  for (let index = 2; index <= MAX_NODES + 1_000; index += 1) {
296
- nodes.push({ id: `n${index}`, parentId: 'n1', role: 'text', name: `t${index}` });
333
+ nodes.push({
334
+ id: `n${index}`,
335
+ parentId: 'n1',
336
+ role: 'text',
337
+ name: `t${index}`,
338
+ geometry: unknownGeometry(),
339
+ });
297
340
  }
298
341
  send(tree(2, nodes));
299
342
  },
@@ -345,32 +388,6 @@ const SCENARIOS = {
345
388
  // Far past any sane per-second budget, sent in one turn.
346
389
  for (let seq = 1; seq <= 500; seq += 1) sendLog(seq, `flood ${seq}`);
347
390
  },
348
- 'delta-sequence': () => {
349
- sendDelta(renameDelta(1, 2, 'Second'));
350
- sendDelta(renameDelta(2, 3, 'Third'));
351
- sendDelta(renameDelta(3, 4, 'Fourth'));
352
- },
353
- 'delta-bad-base': () => {
354
- // Base 999 was never held, so this cannot be patched onto anything.
355
- sendDelta(renameDelta(999, 1000, 'Impossible'));
356
- },
357
- 'delta-cursor-clear': () => {
358
- // A delta can set a cursor but never clear it. `c` sends the full tree that
359
- // clears it, so the two halves are separate steps rather than a race.
360
- sendDelta(renameDelta(1, 2, 'Cursor', { cursor: { row: 3, column: 7, visible: true } }));
361
- },
362
- 'delta-flood': () => {
363
- for (let revision = 2; revision <= 200; revision += 1) {
364
- sendDelta(renameDelta(revision - 1, revision, `Rev${revision}`));
365
- }
366
- },
367
- 'delta-removed-missing': () => {
368
- sendDelta({ baseRevision: 1, revision: 2, changed: [], removed: ['ghost'] });
369
- },
370
- 'delta-before-snapshot': () => {
371
- // Handled specially at handshake time: nothing was published first.
372
- sendDelta(renameDelta(1, 2, 'Premature'));
373
- },
374
391
  'marker-st-terminator': () => {
375
392
  // ST rather than BEL. The tree must pair exactly as it does with BEL.
376
393
  socket.write(frame({ type: 'snapshot', snapshot: tree(2, validNodes('Terminated')) }));
@@ -390,14 +407,13 @@ const SCENARIOS = {
390
407
  };
391
408
 
392
409
  function hello(overrides = {}) {
393
- const capabilities = ['tree', 'bounds', 'states', 'actions', 'render-revisions'];
410
+ const capabilities = ['tree', 'states', 'actions', 'render-revisions'];
394
411
  // `log-no-negotiation` deliberately does NOT announce it: the point of that
395
412
  // scenario is sending records the driver never invited.
396
413
  if (NEEDS_LOGS.has(scenario)) capabilities.push('logs');
397
- if (NEEDS_DELTAS.has(scenario)) capabilities.push('tree-diffs');
398
414
  return {
399
415
  type: 'hello',
400
- protocol: 'termwright/1',
416
+ protocol: 'termwright/2',
401
417
  token,
402
418
  adapter: { name: 'adversarial-peer', version: '0.1.0' },
403
419
  capabilities,
@@ -415,41 +431,7 @@ function sendLog(seq, message) {
415
431
  logSeq = Math.max(logSeq, seq);
416
432
  }
417
433
 
418
- /**
419
- * Sends a delta and its marker. The peer keeps no model of its own beyond the
420
- * revision counter: composing is the receiver's job, and a producer that also
421
- * composed would only prove it agrees with itself.
422
- */
423
- function sendDelta(delta) {
424
- // The delta is the message: the body sits beside the discriminator rather
425
- // than nested under it.
426
- socket.write(frame({ type: 'tree-delta', ...delta }));
427
- process.stdout.write(REPAINT + marker(delta.revision));
428
- published = Math.max(published, delta.revision);
429
- }
430
-
431
- /** A delta that renames the button, so a composed tree is observable on screen. */
432
- function renameDelta(baseRevision, revision, label, overrides = {}) {
433
- return {
434
- baseRevision,
435
- revision,
436
- changed: [
437
- {
438
- id: 'n2',
439
- parentId: 'n1',
440
- role: 'button',
441
- name: label,
442
- testId: 'peer-button',
443
- bounds: { row: 1, column: 0, width: 10, height: 1 },
444
- actions: ['activate', 'focus'],
445
- },
446
- ],
447
- removed: [],
448
- ...overrides,
449
- };
450
- }
451
-
452
- /** Sends a snapshot together with its marker, so only the tree can be at fault. */
434
+ /** Sends a deliberately chosen snapshot together with its marker. */
453
435
  function send(snapshot) {
454
436
  socket.write(frame({ type: 'snapshot', snapshot }));
455
437
  process.stdout.write(marker(snapshot.revision));
@@ -467,8 +449,6 @@ function fire() {
467
449
  attack();
468
450
  say('PEER FIRED');
469
451
  } catch (error) {
470
- // A peer that cannot even build its attack must say so rather than die
471
- // silently and leave the suite waiting on a timeout.
472
452
  say(`PEER FAILED ${String(error && error.message ? error.message : error)}`);
473
453
  }
474
454
  }
@@ -483,9 +463,6 @@ process.stdin.on('data', (chunk) => {
483
463
  }
484
464
  if (text.includes('g')) fire();
485
465
  if (text.includes('p')) publish(published + 1, `Manual${published + 1}`);
486
- // The cursor-clearing half of `delta-cursor-clear`: only a full tree can do
487
- // it, and `tree()` builds one without a cursor.
488
- if (text.includes('c')) publish(published + 1, 'NoCursor');
489
466
  });
490
467
 
491
468
  say(`PEER START ${scenario}`);
@@ -540,26 +517,11 @@ if (endpoint === undefined || token === undefined) {
540
517
  if (message.type === 'hello-ack') {
541
518
  sessionId = message.sessionId;
542
519
  logBudget = message.logs ?? null;
543
- say(`PEER LOGS ${logBudget === null ? 'denied' : `enabled ${logBudget.maxRecordsPerSecond}/s`}`);
544
- // `delta-before-snapshot` deliberately skips the opening tree: its
545
- // whole point is a delta with nothing to compose onto.
546
- if (scenario !== 'delta-before-snapshot') publish(1);
547
- say(`PEER READY ${scenario}`);
548
- }
549
- if (message.type === 'get-tree') {
550
- // Answering is what makes a resync observable end to end: the driver
551
- // asks, the peer supplies, the session returns to a known tree.
552
- const revision = published + 1;
553
- socket.write(
554
- frame({
555
- type: 'get-tree-result',
556
- requestId: message.requestId,
557
- snapshot: tree(revision, validNodes('Resynced')),
558
- }),
520
+ say(
521
+ `PEER LOGS ${logBudget === null ? 'denied' : `enabled ${logBudget.maxRecordsPerSecond}/s`}`,
559
522
  );
560
- published = revision;
561
- process.stdout.write(marker(revision));
562
- say(`PEER SENT FULL TREE ${revision}`);
523
+ publish(1);
524
+ say(`PEER READY ${scenario}`);
563
525
  }
564
526
  if (message.type === 'error') say(`PEER GOT ERROR ${message.code}`);
565
527
  }
@@ -65,7 +65,9 @@ function draw() {
65
65
  out(index === selected ? `\x1b[1;32m> ${item}\x1b[0m\r\n` : ` ${item}\r\n`);
66
66
  }
67
67
  out('\x1b[31mRED\x1b[0m \x1b[4mUNDER\x1b[0m \x1b[44mONBLUE\x1b[0m\r\n');
68
- out(`modes: mouse=${mouse} paste=${bracketed ? 'on' : 'off'} focus=${focusReporting ? 'on' : 'off'}`);
68
+ out(
69
+ `modes: mouse=${mouse} paste=${bracketed ? 'on' : 'off'} focus=${focusReporting ? 'on' : 'off'}`,
70
+ );
69
71
  out(` alt=${alternate ? 'on' : 'off'}\r\n`);
70
72
  out(`size: ${process.stdout.columns}x${process.stdout.rows}\r\n`);
71
73
  out(`activated: ${activated}\r\n`);
@@ -88,7 +90,8 @@ function draw() {
88
90
  .map((name) => `${name}=${process.env[name] === undefined ? 'no' : 'yes'}`)
89
91
  .join(' ');
90
92
  out(`allow: ${allow}\r\n`);
91
- if (unicode) out('U: \u{1F600} \u{1F469}\u200D\u{1F469}\u200D\u{1F467} e\u0301 \u65E5\u672C\u8A9E ok\r\n');
93
+ if (unicode)
94
+ out('U: \u{1F600} \u{1F469}\u200D\u{1F469}\u200D\u{1F467} e\u0301 \u65E5\u672C\u8A9E ok\r\n');
92
95
  if (wide) out(`W: ${'0123456789'.repeat(12)} END\r\n`);
93
96
  }
94
97
 
@@ -1,23 +1,23 @@
1
1
  /** Small normal-render Ink process used only by cross-package readiness tests. */
2
2
 
3
- import {createElement, useEffect, useRef} from 'react';
4
- import {Box, Text, render, useApp} from 'ink';
5
- import {useSemantic} from '@termwright/ink';
3
+ import { createElement, useEffect, useRef } from 'react';
4
+ import { Box, Text, render, useApp } from 'ink';
5
+ import { useSemantic } from '@termwright/ink';
6
6
 
7
7
  function App() {
8
- const {exit} = useApp();
8
+ const { exit } = useApp();
9
9
  const status = useRef(null);
10
- useSemantic(status, {role: 'status', name: 'Ready', testId: 'status'});
10
+ useSemantic(status, { role: 'status', name: 'Ready', testId: 'status' });
11
11
  useEffect(() => {
12
12
  const timer = setTimeout(exit, 2_000);
13
13
  return () => clearTimeout(timer);
14
14
  }, [exit]);
15
15
  return createElement(
16
16
  Box,
17
- {ref: status, flexDirection: 'column'},
17
+ { ref: status, flexDirection: 'column' },
18
18
  createElement(Text, null, 'Termwright Conformance'),
19
19
  );
20
20
  }
21
21
 
22
- const app = render(createElement(App), {alternateScreen: true, interactive: true});
22
+ const app = render(createElement(App), { alternateScreen: true, interactive: true });
23
23
  await app.waitUntilExit();