@lumencast/protocol 0.1.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.
Files changed (84) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/cli.d.ts +3 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +162 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/codec.d.ts +15 -0
  9. package/dist/codec.d.ts.map +1 -0
  10. package/dist/codec.js +328 -0
  11. package/dist/codec.js.map +1 -0
  12. package/dist/conformance/bundle-hash.d.ts +4 -0
  13. package/dist/conformance/bundle-hash.d.ts.map +1 -0
  14. package/dist/conformance/bundle-hash.js +49 -0
  15. package/dist/conformance/bundle-hash.js.map +1 -0
  16. package/dist/conformance/control-client.d.ts +42 -0
  17. package/dist/conformance/control-client.d.ts.map +1 -0
  18. package/dist/conformance/control-client.js +63 -0
  19. package/dist/conformance/control-client.js.map +1 -0
  20. package/dist/conformance/harness.d.ts +41 -0
  21. package/dist/conformance/harness.d.ts.map +1 -0
  22. package/dist/conformance/harness.js +441 -0
  23. package/dist/conformance/harness.js.map +1 -0
  24. package/dist/conformance/index.d.ts +8 -0
  25. package/dist/conformance/index.d.ts.map +1 -0
  26. package/dist/conformance/index.js +12 -0
  27. package/dist/conformance/index.js.map +1 -0
  28. package/dist/conformance/loader.d.ts +9 -0
  29. package/dist/conformance/loader.d.ts.map +1 -0
  30. package/dist/conformance/loader.js +27 -0
  31. package/dist/conformance/loader.js.map +1 -0
  32. package/dist/conformance/match.d.ts +7 -0
  33. package/dist/conformance/match.d.ts.map +1 -0
  34. package/dist/conformance/match.js +82 -0
  35. package/dist/conformance/match.js.map +1 -0
  36. package/dist/conformance/placeholders.d.ts +2 -0
  37. package/dist/conformance/placeholders.d.ts.map +1 -0
  38. package/dist/conformance/placeholders.js +40 -0
  39. package/dist/conformance/placeholders.js.map +1 -0
  40. package/dist/conformance/scenario.d.ts +33 -0
  41. package/dist/conformance/scenario.d.ts.map +1 -0
  42. package/dist/conformance/scenario.js +26 -0
  43. package/dist/conformance/scenario.js.map +1 -0
  44. package/dist/envelope.d.ts +66 -0
  45. package/dist/envelope.d.ts.map +1 -0
  46. package/dist/envelope.js +111 -0
  47. package/dist/envelope.js.map +1 -0
  48. package/dist/errors.d.ts +25 -0
  49. package/dist/errors.d.ts.map +1 -0
  50. package/dist/errors.js +38 -0
  51. package/dist/errors.js.map +1 -0
  52. package/dist/index.d.ts +7 -0
  53. package/dist/index.d.ts.map +1 -0
  54. package/dist/index.js +8 -0
  55. package/dist/index.js.map +1 -0
  56. package/dist/leaf-path.d.ts +21 -0
  57. package/dist/leaf-path.d.ts.map +1 -0
  58. package/dist/leaf-path.js +51 -0
  59. package/dist/leaf-path.js.map +1 -0
  60. package/dist/sequence.d.ts +25 -0
  61. package/dist/sequence.d.ts.map +1 -0
  62. package/dist/sequence.js +51 -0
  63. package/dist/sequence.js.map +1 -0
  64. package/dist/types.d.ts +159 -0
  65. package/dist/types.d.ts.map +1 -0
  66. package/dist/types.js +13 -0
  67. package/dist/types.js.map +1 -0
  68. package/package.json +56 -0
  69. package/src/cli.ts +176 -0
  70. package/src/codec.ts +374 -0
  71. package/src/conformance/bundle-hash.ts +54 -0
  72. package/src/conformance/control-client.ts +93 -0
  73. package/src/conformance/harness.ts +492 -0
  74. package/src/conformance/index.ts +34 -0
  75. package/src/conformance/loader.ts +39 -0
  76. package/src/conformance/match.ts +92 -0
  77. package/src/conformance/placeholders.ts +45 -0
  78. package/src/conformance/scenario.ts +71 -0
  79. package/src/envelope.ts +180 -0
  80. package/src/errors.ts +55 -0
  81. package/src/index.ts +63 -0
  82. package/src/leaf-path.ts +58 -0
  83. package/src/sequence.ts +60 -0
  84. package/src/types.ts +201 -0
@@ -0,0 +1,492 @@
1
+ // Conformance harness — drives a server through a YAML scenario via the
2
+ // LSDP/1 wire protocol + the HTTP test control plane.
3
+ //
4
+ // One harness per run; each scenario is fully isolated:
5
+ // 1. Hash inline bundles
6
+ // 2. POST /test/setup with tokens + bundles + initial_state
7
+ // 3. Open WebSocket to the URL returned by setup (subprotocol lsdp.v1)
8
+ // 4. Walk steps, sending/expecting frames + control-plane introspection
9
+ // 5. POST /test/reset before the next scenario
10
+
11
+ import { WebSocket } from "ws";
12
+ import { WS_SUBPROTOCOL } from "../types.js";
13
+ import { hashInlineBundle } from "./bundle-hash.js";
14
+ import { ControlClient } from "./control-client.js";
15
+ import { matchFrame } from "./match.js";
16
+ import { substitute } from "./placeholders.js";
17
+ import type { BundleDecl, ClientAction, Scenario, Step, Tag, Target } from "./scenario.js";
18
+
19
+ export interface HarnessOptions {
20
+ /** ws://host:port/lsdp/v1 — the WS endpoint the server returns from /test/setup,
21
+ * OR (for servers that don't speak the control plane) the static endpoint. */
22
+ serverUrl?: string;
23
+ /** http://host:port — the test control plane root. Required for cross-language. */
24
+ controlUrl: string;
25
+ /** Token map used by /test/setup. Must include the canonical placeholders.
26
+ * Default: the canonical interop tokens (matches lumencast-protocol/interop/fixtures/). */
27
+ tokens?: Record<string, string>;
28
+ /** Per-step read timeout. Default 2_000 ms. */
29
+ stepTimeoutMs?: number;
30
+ }
31
+
32
+ const DEFAULT_TOKENS: Record<string, string> = {
33
+ $TOKEN_OPERATOR: "interop-tok-operator-7f3a",
34
+ $TOKEN_VIEWER: "interop-tok-viewer-7f3a",
35
+ $TOKEN_SERVICE: "interop-tok-service-7f3a",
36
+ $TOKEN_TEST: "interop-tok-test-7f3a",
37
+ $TOKEN_INVALID: "interop-tok-invalid-7f3a",
38
+ };
39
+
40
+ export type Outcome = "PASS" | "FAIL" | "SKIP";
41
+
42
+ export interface ScenarioResult {
43
+ name: string;
44
+ tag: Tag;
45
+ target: Target;
46
+ outcome: Outcome;
47
+ reason?: string;
48
+ }
49
+
50
+ export interface Report {
51
+ total: number;
52
+ passed: number;
53
+ failed: number;
54
+ skipped: number;
55
+ results: ScenarioResult[];
56
+ }
57
+
58
+ export class Harness {
59
+ private readonly control: ControlClient;
60
+ private readonly tokens: Record<string, string>;
61
+ private readonly stepTimeoutMs: number;
62
+
63
+ constructor(private readonly opts: HarnessOptions) {
64
+ this.control = new ControlClient(opts.controlUrl);
65
+ this.tokens = opts.tokens ?? DEFAULT_TOKENS;
66
+ this.stepTimeoutMs = opts.stepTimeoutMs ?? 2000;
67
+ }
68
+
69
+ /** Run a single scenario. */
70
+ async runScenario(scenario: Scenario): Promise<ScenarioResult> {
71
+ const base = { name: scenario.name, tag: scenario.tag, target: scenario.target };
72
+
73
+ // Skip runtime-targeted scenarios — this harness drives a server.
74
+ if (scenario.target === "runtime") {
75
+ return {
76
+ ...base,
77
+ outcome: "SKIP",
78
+ reason: "runtime-targeted scenario, harness drives a server",
79
+ };
80
+ }
81
+
82
+ let bundleHashes: Record<string, string> = {};
83
+ if (scenario.bundles && scenario.bundles.length > 0) {
84
+ bundleHashes = await this.computeBundleHashes(scenario.bundles);
85
+ }
86
+
87
+ // Build the /test/setup payload. The control plane requires at least one
88
+ // bundle; if the scenario doesn't declare any, infer scene_id +
89
+ // scene_version + initial state from the first server-sends snapshot step.
90
+ const setupBundles = buildSetupBundles(scenario, bundleHashes);
91
+ const initialState = setupBundles.initialState;
92
+
93
+ let setupResponse;
94
+ try {
95
+ setupResponse = await this.control.setup({
96
+ scenario: scenario.name,
97
+ tokens: this.tokens,
98
+ bundles: setupBundles.bundles,
99
+ initial_state: initialState,
100
+ });
101
+ } catch (err) {
102
+ return { ...base, outcome: "FAIL", reason: `setup: ${(err as Error).message}` };
103
+ }
104
+ void setupResponse;
105
+
106
+ const wsUrl = this.opts.serverUrl ?? setupResponse.ws_url;
107
+ let ws: WebSocket;
108
+ try {
109
+ ws = await openSocket(wsUrl);
110
+ } catch (err) {
111
+ await this.control.reset().catch(() => undefined);
112
+ return { ...base, outcome: "FAIL", reason: `dial: ${(err as Error).message}` };
113
+ }
114
+
115
+ const exec = new Exec(ws, this.tokens, bundleHashes, this.control, this.stepTimeoutMs);
116
+ let result: ScenarioResult = { ...base, outcome: "PASS" };
117
+ for (let i = 0; i < scenario.steps.length; i++) {
118
+ const step = scenario.steps[i]!;
119
+ try {
120
+ await exec.runStep(step);
121
+ } catch (err) {
122
+ result = {
123
+ ...base,
124
+ outcome: "FAIL",
125
+ reason: `step ${i + 1} (${step.kind}): ${(err as Error).message}`,
126
+ };
127
+ break;
128
+ }
129
+ }
130
+
131
+ try {
132
+ ws.close(1000, "scenario done");
133
+ } catch {
134
+ // ignore
135
+ }
136
+ await this.control.reset().catch(() => undefined);
137
+
138
+ return result;
139
+ }
140
+
141
+ /** Run every scenario in `scenarios` matching the tag filter (default required). */
142
+ async runAll(scenarios: Scenario[], tagFilter: Tag = "required"): Promise<Report> {
143
+ const rep: Report = { total: 0, passed: 0, failed: 0, skipped: 0, results: [] };
144
+ for (const sc of scenarios) {
145
+ rep.total++;
146
+ if (sc.tag !== tagFilter) {
147
+ rep.skipped++;
148
+ rep.results.push({
149
+ name: sc.name,
150
+ tag: sc.tag,
151
+ target: sc.target,
152
+ outcome: "SKIP",
153
+ reason: `tag ${sc.tag} != filter ${tagFilter}`,
154
+ });
155
+ continue;
156
+ }
157
+ const r = await this.runScenario(sc);
158
+ rep.results.push(r);
159
+ if (r.outcome === "PASS") rep.passed++;
160
+ else if (r.outcome === "FAIL") rep.failed++;
161
+ else rep.skipped++;
162
+ }
163
+ return rep;
164
+ }
165
+
166
+ private async computeBundleHashes(bundles: BundleDecl[]): Promise<Record<string, string>> {
167
+ const out: Record<string, string> = {};
168
+ for (const b of bundles) {
169
+ const h = await hashInlineBundle(b.inline);
170
+ b.hash = h;
171
+ out[b.id] = h;
172
+ }
173
+ return out;
174
+ }
175
+ }
176
+
177
+ class Exec {
178
+ private shadowState: Record<string, unknown> = {};
179
+ private readonly inbox: string[] = [];
180
+ private readonly waiters: Array<(v: string) => void> = [];
181
+ private closed = false;
182
+ /** WebSocket close code (RFC 6455). Set on the `close` event ; used
183
+ * by `expect-no-frame-for` to distinguish clean shutdown (1000/1001/
184
+ * 1005) from abnormal closures. */
185
+ private closeCode: number | null = null;
186
+
187
+ constructor(
188
+ private readonly ws: WebSocket,
189
+ private readonly tokens: Record<string, string>,
190
+ private readonly bundleHashes: Record<string, string>,
191
+ private readonly control: ControlClient,
192
+ private readonly stepTimeoutMs: number,
193
+ ) {
194
+ // Queue messages so frames that arrive between recv() calls aren't lost.
195
+ this.ws.on("message", (data) => {
196
+ const s = String(data);
197
+ const w = this.waiters.shift();
198
+ if (w) w(s);
199
+ else this.inbox.push(s);
200
+ });
201
+ this.ws.on("close", (code: number) => {
202
+ this.closed = true;
203
+ this.closeCode = code;
204
+ // Wake every waiter so they reject promptly.
205
+ while (this.waiters.length > 0) this.waiters.shift()!(""); // signal close
206
+ });
207
+ }
208
+
209
+ async runStep(step: Step): Promise<void> {
210
+ switch (step.kind) {
211
+ case "client-sends":
212
+ return this.send(step.frame ?? {});
213
+ case "server-sends":
214
+ return this.expectServerFrame(step.frame ?? {});
215
+ case "server-emits":
216
+ return this.serverEmits(step.frame ?? {});
217
+ case "expect-runtime-state":
218
+ return this.expectRuntimeState(step.state ?? {});
219
+ case "expect-server-state":
220
+ return this.expectServerState(step.state ?? {});
221
+ case "expect-no-frame-for":
222
+ return this.expectQuiet(step.duration_ms ?? 0);
223
+ case "expect-client-action":
224
+ return this.expectClientAction(step.action, step.reason);
225
+ default:
226
+ throw new Error(`unsupported step kind ${(step as { kind: string }).kind}`);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * `server-emits` (SCENARIO-FORMAT.md) — harness orchestrates a
232
+ * server-driven frame via the test control plane, then validates
233
+ * the wire form. Only `frame.type === "delta"` is currently
234
+ * supported (POST /test/emit).
235
+ */
236
+ private async serverEmits(expected: Record<string, unknown>): Promise<void> {
237
+ if (expected["type"] !== "delta" || !Array.isArray(expected["patches"])) {
238
+ throw new Error(
239
+ `server-emits only supports type=delta today, got ${String(expected["type"])}`,
240
+ );
241
+ }
242
+ const patches = expected["patches"] as Array<{ path: string; value: unknown }>;
243
+ const resolved = patches.map((p) => ({
244
+ path: p.path,
245
+ value: substitute(p.value, this.tokens, this.bundleHashes),
246
+ }));
247
+ await this.control.emit(resolved);
248
+ return this.expectServerFrame(expected);
249
+ }
250
+
251
+ private send(frame: Record<string, unknown>): Promise<void> {
252
+ const resolved = substitute(frame, this.tokens, this.bundleHashes);
253
+ return new Promise<void>((resolve, reject) => {
254
+ this.ws.send(JSON.stringify(resolved), (err) => {
255
+ if (err) reject(err);
256
+ else resolve();
257
+ });
258
+ });
259
+ }
260
+
261
+ private async expectServerFrame(expected: Record<string, unknown>): Promise<void> {
262
+ // Try to read what's already in flight first (snapshots, error frames, and
263
+ // operator-input echoes arrive without prompting). Only fall back to
264
+ // /test/emit when nothing arrives within a short window AND the expected
265
+ // frame is a delta — that's the case where the server needs nudging.
266
+ const want = substitute(expected, this.tokens, this.bundleHashes) as Record<string, unknown>;
267
+
268
+ let actual: Record<string, unknown> | null = null;
269
+ try {
270
+ const raw = await this.recv(150);
271
+ actual = JSON.parse(raw) as Record<string, unknown>;
272
+ } catch {
273
+ // No frame yet — if the expected is a delta, drive it via /test/emit.
274
+ if (expected["type"] === "delta" && Array.isArray(expected["patches"])) {
275
+ const patches = expected["patches"] as Array<{ path: string; value: unknown }>;
276
+ const resolved = patches.map((p) => ({
277
+ path: p.path,
278
+ value: substitute(p.value, this.tokens, this.bundleHashes),
279
+ }));
280
+ try {
281
+ await this.control.emit(resolved);
282
+ } catch {
283
+ // Swallow — the matcher will report any actual mismatch.
284
+ }
285
+ }
286
+ const raw = await this.recv(this.stepTimeoutMs);
287
+ actual = JSON.parse(raw) as Record<string, unknown>;
288
+ }
289
+
290
+ const err = matchFrame(want, actual);
291
+ if (err) {
292
+ throw new Error(
293
+ `frame mismatch at ${err.path}: ${err.reason} (got ${JSON.stringify(actual)})`,
294
+ );
295
+ }
296
+ this.absorb(actual);
297
+ }
298
+
299
+ private absorb(frame: Record<string, unknown>): void {
300
+ if (frame["type"] === "snapshot") {
301
+ const state = (frame["state"] as Record<string, unknown>) ?? {};
302
+ this.shadowState = { ...state };
303
+ } else if (frame["type"] === "delta") {
304
+ const patches = (frame["patches"] as Array<{ path: string; value: unknown }>) ?? [];
305
+ for (const p of patches) this.shadowState[p.path] = p.value;
306
+ }
307
+ }
308
+
309
+ private expectRuntimeState(want: Record<string, unknown>): void {
310
+ for (const [k, v] of Object.entries(want)) {
311
+ if (!(k in this.shadowState)) {
312
+ throw new Error(`runtime state missing ${k}`);
313
+ }
314
+ const got = this.shadowState[k];
315
+ if (JSON.stringify(got) !== JSON.stringify(v)) {
316
+ throw new Error(
317
+ `runtime state ${k}: want ${JSON.stringify(v)}, got ${JSON.stringify(got)}`,
318
+ );
319
+ }
320
+ }
321
+ }
322
+
323
+ private async expectServerState(want: Record<string, unknown>): Promise<void> {
324
+ const snap = await this.control.state();
325
+ for (const [k, v] of Object.entries(want)) {
326
+ if (!(k in snap.state)) {
327
+ throw new Error(`server state missing ${k}`);
328
+ }
329
+ const got = snap.state[k];
330
+ if (JSON.stringify(got) !== JSON.stringify(v)) {
331
+ throw new Error(`server state ${k}: want ${JSON.stringify(v)}, got ${JSON.stringify(got)}`);
332
+ }
333
+ }
334
+ }
335
+
336
+ private async expectQuiet(durationMs: number): Promise<void> {
337
+ try {
338
+ const raw = await this.recv(durationMs);
339
+ throw new Error(`expected silence for ${durationMs}ms, got frame: ${raw}`);
340
+ } catch (err) {
341
+ const msg = (err as Error).message;
342
+ if (msg === "recv timeout") return;
343
+ // SCENARIO-FORMAT.md `expect-no-frame-for` § Connection-close
344
+ // semantics : clean WS close (1000/1001/1005) within the duration
345
+ // counts as success — no data flowed. Abnormal closures still
346
+ // surface as errors.
347
+ if (msg === "connection closed") {
348
+ const code = this.closeCode;
349
+ if (code === 1000 || code === 1001 || code === 1005) return;
350
+ }
351
+ throw err;
352
+ }
353
+ }
354
+
355
+ private async expectClientAction(
356
+ action: ClientAction | undefined,
357
+ _reason: string | undefined,
358
+ ): Promise<void> {
359
+ if (!action) throw new Error("expect-client-action: action required");
360
+ if (action === "close-with-reason" || action === "reconnect") {
361
+ // Read should reject (connection closed or no further frames).
362
+ try {
363
+ await this.recv(this.stepTimeoutMs);
364
+ } catch (err) {
365
+ if ((err as Error).message === "recv timeout") return;
366
+ // close → recv rejects, that's the success path.
367
+ return;
368
+ }
369
+ throw new Error(`expected connection close or no frame, got data`);
370
+ }
371
+ throw new Error(`unknown client action ${action}`);
372
+ }
373
+
374
+ private recv(timeoutMs: number): Promise<string> {
375
+ return new Promise((resolve, reject) => {
376
+ const queued = this.inbox.shift();
377
+ if (queued !== undefined) {
378
+ resolve(queued);
379
+ return;
380
+ }
381
+ if (this.closed) {
382
+ reject(new Error("connection closed"));
383
+ return;
384
+ }
385
+ const t = setTimeout(() => {
386
+ const i = this.waiters.indexOf(resolveAndDispatch);
387
+ if (i >= 0) this.waiters.splice(i, 1);
388
+ reject(new Error("recv timeout"));
389
+ }, timeoutMs);
390
+ const resolveAndDispatch = (v: string): void => {
391
+ clearTimeout(t);
392
+ if (v === "" && this.closed) reject(new Error("connection closed"));
393
+ else resolve(v);
394
+ };
395
+ this.waiters.push(resolveAndDispatch);
396
+ });
397
+ }
398
+ }
399
+
400
+ function openSocket(url: string): Promise<WebSocket> {
401
+ return new Promise((resolve, reject) => {
402
+ const ws = new WebSocket(url, [WS_SUBPROTOCOL]);
403
+ ws.once("open", () => resolve(ws));
404
+ ws.once("error", reject);
405
+ });
406
+ }
407
+
408
+ function extractInitialState(bundles: BundleDecl[]): Record<string, unknown> {
409
+ // The Go reference reads `initial_state` from the scenario top-level when
410
+ // present. Our scenarios encode it inside each bundle's `defaults` field
411
+ // (LSML 1.0 §10). Pull from the first bundle's defaults if any.
412
+ const primary = bundles[0];
413
+ if (primary && typeof primary.inline === "object" && primary.inline !== null) {
414
+ const defaults = (primary.inline as { defaults?: Record<string, unknown> }).defaults;
415
+ if (defaults && typeof defaults === "object") return defaults;
416
+ }
417
+ return {};
418
+ }
419
+
420
+ interface ResolvedSetupBundles {
421
+ bundles: Array<{ id: string; hash: string; inline: unknown }>;
422
+ initialState: Record<string, unknown>;
423
+ }
424
+
425
+ /** Build the bundles + initial_state payload for /test/setup.
426
+ * When the scenario declares bundles, use them verbatim. Otherwise, infer
427
+ * scene_id + scene_version + initial state from the first server-sends
428
+ * snapshot step in the scenario — the same shape the harness will later
429
+ * expect on the wire. */
430
+ function buildSetupBundles(
431
+ scenario: Scenario,
432
+ bundleHashes: Record<string, string>,
433
+ ): ResolvedSetupBundles {
434
+ if (scenario.bundles && scenario.bundles.length > 0) {
435
+ return {
436
+ bundles: scenario.bundles.map((b) => ({
437
+ id: b.id,
438
+ hash: bundleHashes[b.id] ?? "",
439
+ inline: b.inline,
440
+ })),
441
+ initialState: extractInitialState(scenario.bundles),
442
+ };
443
+ }
444
+
445
+ // Look for the first `server-sends snapshot` step.
446
+ let sceneId = "t";
447
+ let sceneVersion = "sha256:" + "f".repeat(64);
448
+ let initialState: Record<string, unknown> = {};
449
+ for (const step of scenario.steps) {
450
+ if (step.kind !== "server-sends" || !step.frame || step.frame["type"] !== "snapshot") {
451
+ continue;
452
+ }
453
+ const snap = step.frame;
454
+ if (typeof snap["scene_id"] === "string" && snap["scene_id"] !== "$ANY") {
455
+ sceneId = snap["scene_id"];
456
+ }
457
+ if (typeof snap["scene_version"] === "string" && snap["scene_version"] !== "$ANY_HASH") {
458
+ sceneVersion = snap["scene_version"];
459
+ }
460
+ if (snap["state"] && typeof snap["state"] === "object") {
461
+ initialState = snap["state"] as Record<string, unknown>;
462
+ }
463
+ break;
464
+ }
465
+
466
+ // Synthesize an `operator_inputs` schema declaring every __inputs.* path
467
+ // present in the initial state. This makes the server reject UNKNOWN_PATH
468
+ // for inputs on undeclared paths, mirroring real-server semantics.
469
+ const operatorInputs = Object.keys(initialState)
470
+ .filter((p) => p.startsWith("__inputs."))
471
+ .map((p) => ({
472
+ path: p,
473
+ label: p,
474
+ type: typeof initialState[p] === "number" ? "number" : "string",
475
+ writable_by: ["operator"],
476
+ }));
477
+
478
+ return {
479
+ bundles: [
480
+ {
481
+ id: sceneId,
482
+ hash: sceneVersion,
483
+ inline: {
484
+ scene_id: sceneId,
485
+ synthetic: true,
486
+ ...(operatorInputs.length > 0 ? { operator_inputs: operatorInputs } : {}),
487
+ },
488
+ },
489
+ ],
490
+ initialState,
491
+ };
492
+ }
@@ -0,0 +1,34 @@
1
+ // Public surface of @lumencast/protocol/conformance.
2
+ //
3
+ // Loaders + harness for the LSDP/1 conformance scenarios. Used by the
4
+ // `lumencast-js conformance` CLI and by interop matrix runs.
5
+
6
+ export {
7
+ parseScenario,
8
+ type Scenario,
9
+ type Step,
10
+ type StepKind,
11
+ type Tag,
12
+ type Target,
13
+ type ClientAction,
14
+ type BundleDecl,
15
+ } from "./scenario.js";
16
+
17
+ export { loadScenarios, type LoadOptions } from "./loader.js";
18
+ export { matchFrame, matchValue, type MatchError } from "./match.js";
19
+ export { substitute } from "./placeholders.js";
20
+ export { canonicalize, hashInlineBundle } from "./bundle-hash.js";
21
+ export {
22
+ ControlClient,
23
+ type SetupRequest,
24
+ type SetupResponse,
25
+ type StateResponse,
26
+ type HealthResponse,
27
+ } from "./control-client.js";
28
+ export {
29
+ Harness,
30
+ type HarnessOptions,
31
+ type Outcome,
32
+ type ScenarioResult,
33
+ type Report,
34
+ } from "./harness.js";
@@ -0,0 +1,39 @@
1
+ // Filesystem loader for scenarios. Reads YAML files from disk by path. The
2
+ // CLI uses this to find scenarios under `lumencast-protocol/conformance/v1/scenarios/`.
3
+
4
+ import { readdirSync, readFileSync, statSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import { parseScenario, type Scenario } from "./scenario.js";
7
+
8
+ export interface LoadOptions {
9
+ /** Directory containing the *.yaml scenarios. Required. */
10
+ scenariosDir: string;
11
+ /** When set, only load this single scenario (basename without .yaml). */
12
+ scenarioName?: string;
13
+ }
14
+
15
+ export function loadScenarios(opts: LoadOptions): Scenario[] {
16
+ const dir = resolve(opts.scenariosDir);
17
+ const stat = statSync(dir, { throwIfNoEntry: false });
18
+ if (!stat || !stat.isDirectory()) {
19
+ throw new Error(`scenarios directory not found: ${dir}`);
20
+ }
21
+
22
+ let files = readdirSync(dir).filter((f) => f.endsWith(".yaml") || f.endsWith(".yml"));
23
+ if (opts.scenarioName) {
24
+ files = files.filter(
25
+ (f) => f === `${opts.scenarioName}.yaml` || f === `${opts.scenarioName}.yml`,
26
+ );
27
+ if (files.length === 0) {
28
+ throw new Error(`scenario not found in ${dir}: ${opts.scenarioName}`);
29
+ }
30
+ }
31
+ files.sort();
32
+
33
+ const out: Scenario[] = [];
34
+ for (const f of files) {
35
+ const raw = readFileSync(join(dir, f), "utf8");
36
+ out.push(parseScenario(raw));
37
+ }
38
+ return out;
39
+ }
@@ -0,0 +1,92 @@
1
+ // Frame matcher with $ANY / $ANY_HASH sentinels. Mirrors
2
+ // lumencast-go/conformance/match.go.
3
+ //
4
+ // matchValue rules :
5
+ // - "$ANY" → matches any present value
6
+ // - "$ANY_HASH" → matches a sha256:<hex64> string
7
+ // - object → recurse on each key in the expected template; extra fields in
8
+ // the actual object are tolerated (forward-compat)
9
+ // - array → length and element-wise match
10
+ // - scalar → strict equality (numeric tower normalized)
11
+
12
+ const SHA256_RE = /^sha256:[0-9a-f]{64}$/;
13
+
14
+ export interface MatchError {
15
+ path: string;
16
+ reason: string;
17
+ }
18
+
19
+ export function matchFrame(
20
+ expected: Record<string, unknown>,
21
+ actual: Record<string, unknown>,
22
+ ): MatchError | null {
23
+ for (const [k, want] of Object.entries(expected)) {
24
+ if (!(k in actual)) {
25
+ return { path: k, reason: `missing field` };
26
+ }
27
+ const err = matchValue(want, actual[k], k);
28
+ if (err) return err;
29
+ }
30
+ return null;
31
+ }
32
+
33
+ export function matchValue(expected: unknown, actual: unknown, path: string): MatchError | null {
34
+ if (typeof expected === "string") {
35
+ if (expected === "$ANY") return null;
36
+ if (expected === "$ANY_HASH") {
37
+ if (typeof actual !== "string" || !SHA256_RE.test(actual)) {
38
+ return { path, reason: `not a sha256 hash: ${JSON.stringify(actual)}` };
39
+ }
40
+ return null;
41
+ }
42
+ }
43
+ if (Array.isArray(expected)) {
44
+ if (!Array.isArray(actual)) {
45
+ return { path, reason: `want array, got ${typeOf(actual)}` };
46
+ }
47
+ if (expected.length !== actual.length) {
48
+ return { path, reason: `length ${expected.length} != ${actual.length}` };
49
+ }
50
+ for (let i = 0; i < expected.length; i++) {
51
+ const err = matchValue(expected[i], actual[i], `${path}[${i}]`);
52
+ if (err) return err;
53
+ }
54
+ return null;
55
+ }
56
+ if (typeof expected === "object" && expected !== null) {
57
+ if (typeof actual !== "object" || actual === null || Array.isArray(actual)) {
58
+ return { path, reason: `want object, got ${typeOf(actual)}` };
59
+ }
60
+ return matchFrame(expected as Record<string, unknown>, actual as Record<string, unknown>)
61
+ ? prefix(
62
+ path,
63
+ matchFrame(expected as Record<string, unknown>, actual as Record<string, unknown>)!,
64
+ )
65
+ : null;
66
+ }
67
+ if (!equalScalar(expected, actual)) {
68
+ return {
69
+ path,
70
+ reason: `want ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
71
+ };
72
+ }
73
+ return null;
74
+ }
75
+
76
+ function prefix(p: string, err: MatchError): MatchError {
77
+ return { path: `${p}.${err.path}`, reason: err.reason };
78
+ }
79
+
80
+ function equalScalar(a: unknown, b: unknown): boolean {
81
+ if (a === b) return true;
82
+ if (typeof a === "number" && typeof b === "number") {
83
+ return a === b || (Number.isNaN(a) && Number.isNaN(b));
84
+ }
85
+ return false;
86
+ }
87
+
88
+ function typeOf(v: unknown): string {
89
+ if (v === null) return "null";
90
+ if (Array.isArray(v)) return "array";
91
+ return typeof v;
92
+ }
@@ -0,0 +1,45 @@
1
+ // Placeholder substitution for scenarios.
2
+ //
3
+ // Two placeholder families :
4
+ // - $TOKEN_OPERATOR / $TOKEN_VIEWER / $TOKEN_SERVICE / $TOKEN_TEST / $TOKEN_INVALID
5
+ // → live token strings supplied by the harness via /test/setup
6
+ // - $BUNDLE.<id>.hash
7
+ // → sha256:<hex> of the inline bundle declared in scenario.bundles
8
+ //
9
+ // Unknown placeholders pass through verbatim. Some scenarios rely on this
10
+ // (auth-denied uses $TOKEN_INVALID literally because the server should reject
11
+ // any value the harness substitutes — cleaner to send the literal string).
12
+
13
+ const TOKEN_PREFIX = "$TOKEN_";
14
+ const BUNDLE_PREFIX = "$BUNDLE.";
15
+ const BUNDLE_SUFFIX = ".hash";
16
+
17
+ export function substitute(
18
+ value: unknown,
19
+ tokens: Record<string, string>,
20
+ bundleHashes: Record<string, string>,
21
+ ): unknown {
22
+ if (typeof value === "string") {
23
+ if (value.startsWith(TOKEN_PREFIX)) {
24
+ const replacement = tokens[value];
25
+ return replacement ?? value;
26
+ }
27
+ if (value.startsWith(BUNDLE_PREFIX) && value.endsWith(BUNDLE_SUFFIX)) {
28
+ const id = value.slice(BUNDLE_PREFIX.length, value.length - BUNDLE_SUFFIX.length);
29
+ const hash = bundleHashes[id];
30
+ return hash ?? value;
31
+ }
32
+ return value;
33
+ }
34
+ if (Array.isArray(value)) {
35
+ return value.map((v) => substitute(v, tokens, bundleHashes));
36
+ }
37
+ if (typeof value === "object" && value !== null) {
38
+ const out: Record<string, unknown> = {};
39
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
40
+ out[k] = substitute(v, tokens, bundleHashes);
41
+ }
42
+ return out;
43
+ }
44
+ return value;
45
+ }