@danypops/vehicle-conformance 0.4.1 → 0.4.2

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/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # @danypops/vehicle-conformance
2
2
 
3
- Host-neutral `bun:test` conformance suite for any `VehicleClient`
3
+ Compiled runner-neutral conformance suite for any `VehicleClient`
4
4
  implementation -- one shared assertion set that a `LocalVehicleClient`, a
5
- `RemoteVehicleClient`, or any future transport must satisfy identically.
6
- Ships raw TypeScript; a test-time devDependency, not a runtime library.
5
+ `RemoteVehicleClient`, or any future transport must satisfy identically. The
6
+ root export registers through Bun; `./core` exposes the runner-neutral matrix,
7
+ and `./vitest` adapts a Vitest-compatible API.
7
8
 
8
9
  ```bash
9
10
  bun add -d @danypops/vehicle-conformance
@@ -17,6 +18,15 @@ import {
17
18
  } from "@danypops/vehicle-conformance";
18
19
  ```
19
20
 
21
+ Vitest consumers provide their installed runner API rather than loading Bun:
22
+
23
+ ```ts
24
+ import { describe, expect, it } from "vitest";
25
+ import { runVehicleClientConformanceWithVitest } from "@danypops/vehicle-conformance/vitest";
26
+
27
+ runVehicleClientConformanceWithVitest({ describe, expect, it }, fixture);
28
+ ```
29
+
20
30
  `runToolShellDualChannelConformance(fixture)` is the host-neutral Tool Shell
21
31
  matrix. A fixture adapts one provider's real projection/rendering boundary via
22
32
  `execute`, `render`, `replay`, `renderCall`, and `invalidProjection`; the shared
package/dist/bun.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { type ToolShellDualChannelFixture, type VehicleConformanceFixture } from "./vehicle-conformance.js";
2
+ /** Registers the shared Vehicle client matrix with Bun's test runner. */
3
+ export declare function runVehicleClientConformance(fixture: VehicleConformanceFixture): void;
4
+ /** Registers the shared Tool Shell matrix with Bun's test runner. */
5
+ export declare function runToolShellDualChannelConformance(fixture: ToolShellDualChannelFixture): void;
6
+ export * from "./vehicle-conformance.js";
package/dist/bun.js ADDED
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { registerToolShellDualChannelConformance, registerVehicleClientConformance, } from "./vehicle-conformance.js";
3
+ const bunRunner = {
4
+ describe: (name, body) => describe(name, body),
5
+ it: (name, body) => it(name, body),
6
+ expect: (actual, message) => expect(actual, message),
7
+ };
8
+ /** Registers the shared Vehicle client matrix with Bun's test runner. */
9
+ export function runVehicleClientConformance(fixture) {
10
+ registerVehicleClientConformance(bunRunner, fixture);
11
+ }
12
+ /** Registers the shared Tool Shell matrix with Bun's test runner. */
13
+ export function runToolShellDualChannelConformance(fixture) {
14
+ registerToolShellDualChannelConformance(bunRunner, fixture);
15
+ }
16
+ export * from "./vehicle-conformance.js";
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Host-neutral conformance suite: one shared set of assertions that any
3
+ * VehicleClient implementation (LocalVehicleClient, RemoteVehicleClient,
4
+ * and any future MCP/CLI projection) must satisfy identically. Registers
5
+ * its own fixed set of test operations onto whatever registry the fixture
6
+ * hands back, so the *same* operation definitions exercise every
7
+ * implementation -- two independently hand-written test files could drift
8
+ * apart without either one noticing; a shared suite can't.
9
+ *
10
+ * Deliberately built on bun:test directly (not a framework-agnostic DSL) --
11
+ * every consumer of this package that would run it is already a Bun
12
+ * project, and inventing a test-runner abstraction for a single-runtime
13
+ * ecosystem would be pure ceremony.
14
+ *
15
+ * A fixture only supplies a fresh, isolated registry + a client bound to
16
+ * it + cleanup -- it does not define operations or assertions itself, so
17
+ * host-specific concerns (Alef's bus/context/display assertions, a CLI's
18
+ * argument parsing) stay out of this module entirely, per the extraction
19
+ * scope this generalizes.
20
+ */
21
+ import type { VehicleClient } from "@danypops/vehicle-core";
22
+ export interface VehicleConformanceMatchers {
23
+ toEqual(expected: unknown): unknown;
24
+ toMatchObject(expected: unknown): unknown;
25
+ toBe(expected: unknown): unknown;
26
+ toBeTruthy(): unknown;
27
+ toBeUndefined(): unknown;
28
+ toContain(expected: unknown): unknown;
29
+ toMatch(expected: RegExp | string): unknown;
30
+ toBeGreaterThan(expected: number): unknown;
31
+ toBeGreaterThanOrEqual(expected: number): unknown;
32
+ toBeLessThan(expected: number): unknown;
33
+ toBeLessThanOrEqual(expected: number): unknown;
34
+ readonly not: VehicleConformanceMatchers;
35
+ readonly rejects: VehicleConformanceMatchers;
36
+ readonly resolves: VehicleConformanceMatchers;
37
+ }
38
+ export interface VehicleConformanceRunner {
39
+ describe(name: string, body: () => void): unknown;
40
+ it(name: string, body: () => void | Promise<void>): unknown;
41
+ expect(actual: unknown, message?: string): VehicleConformanceMatchers;
42
+ }
43
+ import type { VehicleRegistry } from "@danypops/vehicle-server";
44
+ /** Registers the fixed conformance operation set onto `registry`. Every fixture must call this before handing back its client. */
45
+ export declare function registerConformanceOperations(registry: VehicleRegistry): void;
46
+ export interface VehicleConformanceFixture {
47
+ /** Used in describe() block titles, e.g. "LocalVehicleClient" or "RemoteVehicleClient (HTTP)". */
48
+ label: string;
49
+ /** Builds a fresh, isolated registry (with registerConformanceOperations already applied) plus a client bound to it. Must not share state across calls -- each test gets its own. */
50
+ create(): Promise<{
51
+ client: VehicleClient;
52
+ cleanup: () => Promise<void>;
53
+ }>;
54
+ }
55
+ export declare function registerVehicleClientConformance(runner: VehicleConformanceRunner, fixture: VehicleConformanceFixture): void;
56
+ export interface ToolShellConformanceSnapshot {
57
+ readonly content: string;
58
+ readonly details: unknown;
59
+ }
60
+ export interface ToolShellRenderOptions {
61
+ readonly width: 40 | 80 | 120;
62
+ readonly expanded: boolean;
63
+ readonly partial?: boolean;
64
+ }
65
+ /**
66
+ * Host adapter for the Tool Shell's two independent persisted channels. The
67
+ * conformance package stays Pi-free: a Pi adapter supplies component output,
68
+ * while a CLI or another host can supply its own renderer through this same API.
69
+ */
70
+ export interface ToolShellDualChannelSubject {
71
+ readonly bounds: {
72
+ readonly modelContentBytes: number;
73
+ readonly presentationDetailsBytes: number;
74
+ };
75
+ execute(): Promise<ToolShellConformanceSnapshot>;
76
+ render(snapshot: ToolShellConformanceSnapshot, options: ToolShellRenderOptions): readonly string[];
77
+ replay(details: unknown, fallbackContent: string, options: ToolShellRenderOptions): readonly string[];
78
+ renderCall(args: unknown, width: 40 | 80 | 120): readonly string[];
79
+ invalidProjection(): Promise<unknown>;
80
+ /**
81
+ * Optional -- the discriminator values (a `format`/`kind`/`action`/... field) this provider's
82
+ * own presentation-details schema declares, each paired with a representative raw application
83
+ * payload for that value. Supplying this (together with renderDeclaredValue) enables the
84
+ * declared-value coverage check below, which generically catches the pi-web-spider bug class
85
+ * (see doc 4e9e08c1, Finding 1/4): most declared values falling through to an undifferentiated
86
+ * JSON.stringify dump of their own payload instead of a real projected view. Omit entirely for
87
+ * a subject with no such discriminator -- the check then no-ops.
88
+ */
89
+ readonly declaredValueCases?: readonly ToolShellDeclaredValueCase[];
90
+ /** Required alongside declaredValueCases: renders the expanded view for one declared value's
91
+ * own raw payload, through exactly the same projection+render pipeline the real handler uses. */
92
+ renderDeclaredValue?(value: string, rawPayload: unknown, options: ToolShellRenderOptions): readonly string[];
93
+ }
94
+ export interface ToolShellDeclaredValueCase {
95
+ /** e.g. a WebFormat value ('search'/'lean'/...), a PackageToolDetails['kind'], a tickets action name. */
96
+ readonly value: string;
97
+ /** The real, untransformed application output this declared value would carry. */
98
+ readonly rawPayload: unknown;
99
+ }
100
+ export interface DeclaredValueCoverageResult {
101
+ /** Declared values whose rendered output is NOT indistinguishable from a raw JSON.stringify dump of their own payload. */
102
+ readonly nonRawValues: readonly string[];
103
+ /** Declared values whose rendered output IS indistinguishable from a raw JSON.stringify dump of their own payload. */
104
+ readonly rawValues: readonly string[];
105
+ }
106
+ /**
107
+ * Pure, independently unit-testable core of the declared-value coverage check -- separated from
108
+ * the bun:test `it()` wiring below so a fixture reproducing a known-bad shape (e.g.
109
+ * pi-web-spider's own pre-fix behavior) can be asserted against directly, proving the classifier
110
+ * itself actually detects that bug class rather than trusting the wrapping `it()` alone.
111
+ */
112
+ export declare function evaluateDeclaredValueCoverage(cases: readonly ToolShellDeclaredValueCase[], renderDeclaredValue: (value: string, rawPayload: unknown, options: ToolShellRenderOptions) => readonly string[], options?: ToolShellRenderOptions): DeclaredValueCoverageResult;
113
+ export interface ToolShellDualChannelFixture {
114
+ readonly label: string;
115
+ create(): Promise<{
116
+ readonly subject: ToolShellDualChannelSubject;
117
+ readonly cleanup: () => Promise<void>;
118
+ }>;
119
+ }
120
+ /** Reusable provider-facing dual-channel contract matrix. */
121
+ export declare function registerToolShellDualChannelConformance(runner: VehicleConformanceRunner, fixture: ToolShellDualChannelFixture): void;
@@ -0,0 +1,494 @@
1
+ import { bindVehicleOperation, defineVehicleOperation, defineVehicleSchema, VehicleError } from "@danypops/vehicle-core";
2
+ const passthroughSchema = defineVehicleSchema({
3
+ jsonSchema: { type: "object", properties: { value: { type: "string" } }, additionalProperties: false },
4
+ safeParse(value) {
5
+ if (typeof value === "object" && value !== null && typeof value.value === "string") {
6
+ return { success: true, value: value };
7
+ }
8
+ return { success: false, issues: [{ path: ["value"], message: "value must be a string" }] };
9
+ },
10
+ });
11
+ const outputSchema = defineVehicleSchema({
12
+ jsonSchema: { type: "object", properties: { echoed: { type: "string" } }, additionalProperties: false },
13
+ safeParse(value) {
14
+ if (typeof value === "object" && value !== null && typeof value.echoed === "string") {
15
+ return { success: true, value: value };
16
+ }
17
+ return { success: false, issues: [{ path: ["echoed"], message: "echoed must be a string" }] };
18
+ },
19
+ });
20
+ const LIMITS = { defaultTimeoutMs: 200, maxTimeoutMs: 2_000, maxRequestBytes: 256, maxResponseBytes: 256 };
21
+ const ConformanceEcho = defineVehicleOperation({
22
+ name: "conformance.echo",
23
+ version: 1,
24
+ description: "Echoes its input.",
25
+ input: passthroughSchema,
26
+ output: outputSchema,
27
+ permissions: ["conformance:echo"],
28
+ effect: "read",
29
+ idempotency: { mode: "safe" },
30
+ limits: LIMITS,
31
+ });
32
+ const ConformanceBoom = defineVehicleOperation({
33
+ name: "conformance.boom",
34
+ version: 1,
35
+ description: "Always throws a real VehicleError from its handler.",
36
+ input: passthroughSchema,
37
+ output: outputSchema,
38
+ permissions: [],
39
+ effect: "read",
40
+ idempotency: { mode: "safe" },
41
+ limits: LIMITS,
42
+ });
43
+ const ConformanceKeyed = defineVehicleOperation({
44
+ name: "conformance.keyed",
45
+ version: 1,
46
+ description: "Requires a keyed idempotency key.",
47
+ input: passthroughSchema,
48
+ output: outputSchema,
49
+ permissions: [],
50
+ effect: "external-write",
51
+ idempotency: { mode: "keyed", retentionMs: 60_000 },
52
+ limits: LIMITS,
53
+ });
54
+ const ConformanceProgress = defineVehicleOperation({
55
+ name: "conformance.progress",
56
+ version: 1,
57
+ description: "Reports two progress events, then resolves.",
58
+ input: passthroughSchema,
59
+ output: outputSchema,
60
+ permissions: [],
61
+ effect: "read",
62
+ idempotency: { mode: "safe" },
63
+ limits: LIMITS,
64
+ });
65
+ const ConformanceNever = defineVehicleOperation({
66
+ name: "conformance.never",
67
+ version: 1,
68
+ description: "Never resolves on its own -- only via cancellation or deadline.",
69
+ input: passthroughSchema,
70
+ output: outputSchema,
71
+ permissions: [],
72
+ effect: "read",
73
+ idempotency: { mode: "safe" },
74
+ limits: LIMITS,
75
+ });
76
+ /** Genuinely slow (unlike ConformanceProgress, which resolves near-instantly) -- the streaming-progress-required check needs real elapsed time to exceed its threshold before the "did it report progress" assertion means anything. */
77
+ const SLOW_PROGRESS_DELAY_MS = 60;
78
+ const ConformanceSlowProgress = defineVehicleOperation({
79
+ name: "conformance.slow-progress",
80
+ version: 1,
81
+ description: "Reports one progress event partway through a real delay, then resolves -- streaming: true declares it must never silently block.",
82
+ input: passthroughSchema,
83
+ output: outputSchema,
84
+ permissions: [],
85
+ effect: "read",
86
+ idempotency: { mode: "safe" },
87
+ streaming: true,
88
+ limits: LIMITS,
89
+ });
90
+ /** Registers the fixed conformance operation set onto `registry`. Every fixture must call this before handing back its client. */
91
+ export function registerConformanceOperations(registry) {
92
+ registry.register("conformance", bindVehicleOperation(ConformanceEcho, () => async (context) => ({ echoed: context.input.value })));
93
+ registry.register("conformance", bindVehicleOperation(ConformanceBoom, () => async () => {
94
+ throw new VehicleError("conformance-boom", "conformance.boom always fails", { category: "internal" });
95
+ }));
96
+ registry.register("conformance", bindVehicleOperation(ConformanceKeyed, () => async (context) => ({ echoed: context.input.value })));
97
+ registry.register("conformance", bindVehicleOperation(ConformanceProgress, () => async (context) => {
98
+ context.reportProgress({ step: 1 });
99
+ context.reportProgress({ step: 2 });
100
+ return { echoed: context.input.value };
101
+ }));
102
+ registry.register("conformance", bindVehicleOperation(ConformanceNever, () => (context) => {
103
+ return new Promise((_resolve, reject) => {
104
+ context.signal.addEventListener("abort", () => reject(new Error("conformance.never aborted")), { once: true });
105
+ });
106
+ }));
107
+ registry.register("conformance", bindVehicleOperation(ConformanceSlowProgress, () => async (context) => {
108
+ context.reportProgress({ step: 1 });
109
+ await new Promise((resolve) => setTimeout(resolve, SLOW_PROGRESS_DELAY_MS));
110
+ return { echoed: context.input.value };
111
+ }));
112
+ }
113
+ /** Every operation this suite declares with streaming: true -- the streaming-progress-required check generates one named it() per entry, per this project's own "per-check test isolation" requirement. */
114
+ const STREAMING_OPERATIONS = [{ descriptor: ConformanceSlowProgress.descriptor, thresholdMs: SLOW_PROGRESS_DELAY_MS / 2 }];
115
+ export function registerVehicleClientConformance(runner, fixture) {
116
+ const { describe, expect, it } = runner;
117
+ describe(`Vehicle client conformance: ${fixture.label}`, () => {
118
+ it("manifest() lists every registered operation with its real descriptor fields", async () => {
119
+ const { client, cleanup } = await fixture.create();
120
+ try {
121
+ const manifest = await client.manifest();
122
+ const names = manifest.operations.map((op) => `${op.name}@${op.version}`).sort();
123
+ expect(names).toEqual([
124
+ "conformance.boom@1",
125
+ "conformance.echo@1",
126
+ "conformance.keyed@1",
127
+ "conformance.never@1",
128
+ "conformance.progress@1",
129
+ "conformance.slow-progress@1",
130
+ ]);
131
+ const echo = manifest.operations.find((op) => op.name === "conformance.echo");
132
+ expect(echo?.permissions).toEqual(["conformance:echo"]);
133
+ expect(echo?.idempotency).toEqual({ mode: "safe" });
134
+ // available defaults to true for every operation, and must survive
135
+ // the wire round trip identically for a remote (HTTP/JSON) client,
136
+ // not just the in-process local one.
137
+ expect(manifest.operations.every((op) => op.available === true)).toBe(true);
138
+ expect(echo?.unavailableReason).toBeUndefined();
139
+ }
140
+ finally {
141
+ await cleanup();
142
+ }
143
+ });
144
+ it("negotiate() agrees on the shared protocol and rejects an incompatible range", async () => {
145
+ const { client, cleanup } = await fixture.create();
146
+ try {
147
+ if (!client.negotiate)
148
+ throw new Error("Vehicle client does not implement protocol negotiation");
149
+ await expect(client.negotiate({ minimumVersion: 1, maximumVersion: 2, requiredCapabilities: [], optionalCapabilities: ["future"] })).resolves.toEqual({ version: 1, capabilities: [] });
150
+ await expect(client.negotiate({ minimumVersion: 2, maximumVersion: 3, requiredCapabilities: [], optionalCapabilities: [] })).rejects.toMatchObject({ code: "protocol-version-incompatible" });
151
+ }
152
+ finally {
153
+ await cleanup();
154
+ }
155
+ });
156
+ it("invoke() returns the real handler output on success", async () => {
157
+ const { client, cleanup } = await fixture.create();
158
+ try {
159
+ const result = await client.invoke("conformance.echo", 1, { value: "hi" }, { permissions: ["conformance:echo"] });
160
+ expect(result).toEqual({ echoed: "hi" });
161
+ }
162
+ finally {
163
+ await cleanup();
164
+ }
165
+ });
166
+ it("invoke() rejects invalid input before the handler ever runs", async () => {
167
+ const { client, cleanup } = await fixture.create();
168
+ try {
169
+ await expect(client.invoke("conformance.echo", 1, { value: 123 }, { permissions: ["conformance:echo"] })).rejects.toMatchObject({
170
+ code: "invalid-input",
171
+ });
172
+ }
173
+ finally {
174
+ await cleanup();
175
+ }
176
+ });
177
+ it("invoke() enforces required permissions with permission-denied/authorization", async () => {
178
+ const { client, cleanup } = await fixture.create();
179
+ try {
180
+ await expect(client.invoke("conformance.echo", 1, { value: "hi" }, {})).rejects.toMatchObject({
181
+ code: "permission-denied",
182
+ category: "authorization",
183
+ });
184
+ }
185
+ finally {
186
+ await cleanup();
187
+ }
188
+ });
189
+ it("invoke() surfaces a real handler failure's own code/category/message, not a generic wrapper", async () => {
190
+ const { client, cleanup } = await fixture.create();
191
+ try {
192
+ await expect(client.invoke("conformance.boom", 1, { value: "x" }, {})).rejects.toMatchObject({
193
+ code: "conformance-boom",
194
+ message: "conformance.boom always fails",
195
+ });
196
+ }
197
+ finally {
198
+ await cleanup();
199
+ }
200
+ });
201
+ it("invoke() requires an idempotency key for a keyed operation", async () => {
202
+ const { client, cleanup } = await fixture.create();
203
+ try {
204
+ await expect(client.invoke("conformance.keyed", 1, { value: "x" }, {})).rejects.toMatchObject({
205
+ code: "idempotency-key-required",
206
+ });
207
+ const result = await client.invoke("conformance.keyed", 1, { value: "x" }, { idempotencyKey: "k-1" });
208
+ expect(result).toEqual({ echoed: "x" });
209
+ }
210
+ finally {
211
+ await cleanup();
212
+ }
213
+ });
214
+ it("invoke() rejects a request exceeding its declared byte bound", async () => {
215
+ const { client, cleanup } = await fixture.create();
216
+ try {
217
+ const oversized = "x".repeat(1024);
218
+ await expect(client.invoke("conformance.echo", 1, { value: oversized }, { permissions: ["conformance:echo"] })).rejects.toMatchObject({
219
+ code: "request-too-large",
220
+ });
221
+ }
222
+ finally {
223
+ await cleanup();
224
+ }
225
+ });
226
+ it("invoke() rejects an operation for a name/version pair that was never registered", async () => {
227
+ const { client, cleanup } = await fixture.create();
228
+ try {
229
+ await expect(client.invoke("conformance.nonexistent", 1, {}, {})).rejects.toMatchObject({ code: "not-found" });
230
+ }
231
+ finally {
232
+ await cleanup();
233
+ }
234
+ });
235
+ it("invoke() delivers every progress event before resolving with the final result, never after", async () => {
236
+ const { client, cleanup } = await fixture.create();
237
+ try {
238
+ const progress = [];
239
+ let resolved = false;
240
+ const result = await client.invoke("conformance.progress", 1, { value: "hi" }, {
241
+ onProgress: (p) => {
242
+ expect(resolved).toBe(false);
243
+ progress.push(p);
244
+ },
245
+ });
246
+ resolved = true;
247
+ expect(progress).toEqual([{ step: 1 }, { step: 2 }]);
248
+ expect(result).toEqual({ echoed: "hi" });
249
+ }
250
+ finally {
251
+ await cleanup();
252
+ }
253
+ });
254
+ it("invoke() propagates cancellation via AbortSignal to the operation itself", async () => {
255
+ const { client, cleanup } = await fixture.create();
256
+ try {
257
+ const controller = new AbortController();
258
+ const invocation = client.invoke("conformance.never", 1, { value: "x" }, { signal: controller.signal });
259
+ await new Promise((resolve) => setTimeout(resolve, 15));
260
+ controller.abort();
261
+ await expect(invocation).rejects.toBeTruthy();
262
+ }
263
+ finally {
264
+ await cleanup();
265
+ }
266
+ });
267
+ it("invoke() respects an explicit deadline that has already elapsed", async () => {
268
+ const { client, cleanup } = await fixture.create();
269
+ try {
270
+ await expect(client.invoke("conformance.echo", 1, { value: "hi" }, { permissions: ["conformance:echo"], deadline: Date.now() - 1 })).rejects.toMatchObject({
271
+ code: "deadline-exceeded",
272
+ });
273
+ }
274
+ finally {
275
+ await cleanup();
276
+ }
277
+ });
278
+ it("close() prevents further invoke()/manifest() calls on this client instance", async () => {
279
+ const { client, cleanup } = await fixture.create();
280
+ try {
281
+ await client.close();
282
+ await expect(client.manifest()).rejects.toBeTruthy();
283
+ }
284
+ finally {
285
+ await cleanup();
286
+ }
287
+ });
288
+ // Schema-rejection timing: an invalid-input invocation must resolve (with a
289
+ // validation error) within a small bound, never falling through to a general
290
+ // timeout -- catches a handler whose validation path accidentally does real
291
+ // I/O before checking input shape. Ported from Alef's own adapter-contract.ts
292
+ // runSchemaContract (200ms bound), a separate named check from the existing
293
+ // "rejects invalid input" test above per this suite's own per-check isolation.
294
+ it("invoke() rejects invalid input within a bounded time, never falling through to a general timeout", async () => {
295
+ const { client, cleanup } = await fixture.create();
296
+ try {
297
+ const start = Date.now();
298
+ await expect(client.invoke("conformance.echo", 1, { value: 123 }, { permissions: ["conformance:echo"] })).rejects.toMatchObject({
299
+ code: "invalid-input",
300
+ });
301
+ const elapsed = Date.now() - start;
302
+ expect(elapsed, `schema rejection took ${elapsed}ms -- should be immediate (<200ms)`).toBeLessThan(200);
303
+ }
304
+ finally {
305
+ await cleanup();
306
+ }
307
+ });
308
+ // Human-readable error messages: a validation failure's own .message must
309
+ // never leak an internal validation-library-specific type name or a bare
310
+ // stringified object -- a real bug class this ports from Alef's own
311
+ // adapter-contract.ts (a live zod "[InputValidation]" prefix leak there).
312
+ it("invoke() rejects invalid input with a human-readable message, never a raw validation-library leak", async () => {
313
+ const { client, cleanup } = await fixture.create();
314
+ try {
315
+ const error = await client.invoke("conformance.echo", 1, { value: 123 }, { permissions: ["conformance:echo"] }).catch((e) => e);
316
+ const message = error.message;
317
+ expect(typeof message).toBe("string");
318
+ expect(message).not.toBe("[object Object]");
319
+ expect(message).not.toMatch(/ValueError|TypeBoxError|\[InputValidation\]|ZodError/);
320
+ // Genuinely readable: names which operation failed, not just "invalid".
321
+ expect(message).toContain("conformance.echo");
322
+ }
323
+ finally {
324
+ await cleanup();
325
+ }
326
+ });
327
+ // Streaming-progress-required: any operation declared streaming: true must
328
+ // emit at least one progress event before resolving, once its real
329
+ // execution exceeds a threshold duration -- catches a handler that
330
+ // silently blocks the caller instead of reporting progress despite
331
+ // declaring progress support. One named it() per discovered
332
+ // streaming-capable operation (this suite currently declares exactly one),
333
+ // per this project's own per-check test isolation.
334
+ describe("streaming-progress-required (operations declared streaming: true)", () => {
335
+ for (const { descriptor, thresholdMs } of STREAMING_OPERATIONS) {
336
+ it(`${descriptor.name}@${descriptor.version} emits progress before resolving, once it runs past ${thresholdMs}ms`, async () => {
337
+ const { client, cleanup } = await fixture.create();
338
+ try {
339
+ const progress = [];
340
+ const start = Date.now();
341
+ await client.invoke(descriptor.name, descriptor.version, { value: "hi" }, { onProgress: (p) => progress.push(p) });
342
+ const elapsed = Date.now() - start;
343
+ expect(elapsed, `test fixture ran in ${elapsed}ms, below its own ${thresholdMs}ms threshold -- this check can't prove anything`).toBeGreaterThan(thresholdMs);
344
+ expect(progress.length, `${descriptor.name} ran for ${elapsed}ms but emitted zero progress events -- a streaming: true operation must never silently block`).toBeGreaterThan(0);
345
+ }
346
+ finally {
347
+ await cleanup();
348
+ }
349
+ });
350
+ }
351
+ });
352
+ });
353
+ }
354
+ function normalizeForComparison(text) {
355
+ return text.replace(/\s+/g, "");
356
+ }
357
+ /**
358
+ * True when `renderedLines` is textually indistinguishable (ignoring ANSI styling and whitespace)
359
+ * from `JSON.stringify(rawPayload, null, 2)` -- the exact shape pi-web-spider's `primaryLines()`
360
+ * fell back to for every non-"markdown" format. Whitespace-insensitive so a renderer that reflows
361
+ * the same JSON text to a narrower width still counts as "raw", matching the real bug (a Text
362
+ * component wrapping the identical JSON.stringify output).
363
+ */
364
+ function looksLikeRawJsonDump(renderedLines, rawPayload) {
365
+ let rawJson;
366
+ try {
367
+ rawJson = JSON.stringify(rawPayload, null, 2) ?? "";
368
+ }
369
+ catch {
370
+ return false;
371
+ }
372
+ if (rawJson.length === 0)
373
+ return false;
374
+ const renderedText = renderedLines.join("\n").replace(ANSI_CSI_PATTERN, "");
375
+ return normalizeForComparison(renderedText) === normalizeForComparison(rawJson);
376
+ }
377
+ /**
378
+ * Pure, independently unit-testable core of the declared-value coverage check -- separated from
379
+ * the bun:test `it()` wiring below so a fixture reproducing a known-bad shape (e.g.
380
+ * pi-web-spider's own pre-fix behavior) can be asserted against directly, proving the classifier
381
+ * itself actually detects that bug class rather than trusting the wrapping `it()` alone.
382
+ */
383
+ export function evaluateDeclaredValueCoverage(cases, renderDeclaredValue, options = { width: 80, expanded: true }) {
384
+ const nonRawValues = [];
385
+ const rawValues = [];
386
+ for (const { value, rawPayload } of cases) {
387
+ const lines = renderDeclaredValue(value, rawPayload, options);
388
+ (looksLikeRawJsonDump(lines, rawPayload) ? rawValues : nonRawValues).push(value);
389
+ }
390
+ return { nonRawValues, rawValues };
391
+ }
392
+ function utf8Length(value) {
393
+ return new TextEncoder().encode(value).byteLength;
394
+ }
395
+ // biome-ignore lint/complexity/useRegexLiterals: a constructor avoids control-character lint on the equivalent literal.
396
+ const ANSI_CSI_PATTERN = new RegExp("\\u001B\\[[0-?]*[ -/]*[@-~]", "g");
397
+ function assertPhysicalLines(expect, lines, width) {
398
+ expect(lines.length).toBeGreaterThan(0);
399
+ for (const line of lines) {
400
+ expect(line).not.toContain("\n");
401
+ // ANSI is forbidden in model content, but permitted in host rendering.
402
+ const visible = line.replace(ANSI_CSI_PATTERN, "");
403
+ expect([...visible].length).toBeLessThanOrEqual(width);
404
+ }
405
+ }
406
+ /** Reusable provider-facing dual-channel contract matrix. */
407
+ export function registerToolShellDualChannelConformance(runner, fixture) {
408
+ const { describe, expect, it } = runner;
409
+ describe(`Vehicle Tool Shell dual-channel conformance: ${fixture.label}`, () => {
410
+ it("keeps model and persisted-presentation sentinels isolated under independent named bounds", async () => {
411
+ const { subject, cleanup } = await fixture.create();
412
+ try {
413
+ const snapshot = await subject.execute();
414
+ expect(snapshot.content).toContain("MODEL_ONLY");
415
+ expect(snapshot.content).not.toContain("PRESENTATION_ONLY");
416
+ const details = JSON.stringify(snapshot.details);
417
+ expect(details).toContain("PRESENTATION_ONLY");
418
+ expect(details).not.toContain("MODEL_ONLY");
419
+ expect(details).not.toContain("RAW_SECRET");
420
+ expect(utf8Length(snapshot.content)).toBeLessThanOrEqual(subject.bounds.modelContentBytes);
421
+ expect(utf8Length(details)).toBeLessThanOrEqual(subject.bounds.presentationDetailsBytes);
422
+ }
423
+ finally {
424
+ await cleanup();
425
+ }
426
+ });
427
+ it("keeps model content semantic, ANSI-free, and useful when replay details reject", async () => {
428
+ const { subject, cleanup } = await fixture.create();
429
+ try {
430
+ const snapshot = await subject.execute();
431
+ expect(snapshot.content).not.toContain("\u001b[");
432
+ for (const details of [{ schema: "unknown/v99" }, { malformed: true }, { output: { legacy: true } }, undefined]) {
433
+ const lines = subject.replay(details, snapshot.content, { width: 80, expanded: false });
434
+ expect(lines.join("\n")).toContain("MODEL_ONLY");
435
+ }
436
+ }
437
+ finally {
438
+ await cleanup();
439
+ }
440
+ });
441
+ it("changes only human rendering across collapsed/expanded and 40/80/120 layouts", async () => {
442
+ const { subject, cleanup } = await fixture.create();
443
+ try {
444
+ const snapshot = await subject.execute();
445
+ const before = JSON.stringify(snapshot);
446
+ for (const width of [40, 80, 120]) {
447
+ assertPhysicalLines(expect, subject.render(snapshot, { width, expanded: false }), width);
448
+ assertPhysicalLines(expect, subject.render(snapshot, { width, expanded: true }), width);
449
+ assertPhysicalLines(expect, subject.render(snapshot, { width, expanded: false, partial: true }), width);
450
+ }
451
+ expect(JSON.stringify(snapshot)).toBe(before);
452
+ }
453
+ finally {
454
+ await cleanup();
455
+ }
456
+ });
457
+ it("never echoes schema-sensitive call input and follows the documented projector exception policy", async () => {
458
+ const { subject, cleanup } = await fixture.create();
459
+ try {
460
+ for (const width of [40, 80, 120]) {
461
+ const call = subject.renderCall({ name: "safe-task", token: "RAW_SECRET" }, width).join("\n");
462
+ expect(call).toContain("safe-task");
463
+ expect(call).not.toContain("RAW_SECRET");
464
+ }
465
+ await expect(subject.invalidProjection()).rejects.toBeTruthy();
466
+ }
467
+ finally {
468
+ await cleanup();
469
+ }
470
+ });
471
+ it("renders most of its own declared discriminator values as more than a raw JSON dump of their own payload", async () => {
472
+ const { subject, cleanup } = await fixture.create();
473
+ try {
474
+ const cases = subject.declaredValueCases;
475
+ if (!cases || cases.length === 0)
476
+ return; // opt-in: no discriminator declared, nothing to check
477
+ if (!subject.renderDeclaredValue) {
478
+ throw new Error("declaredValueCases supplied without a matching renderDeclaredValue implementation");
479
+ }
480
+ const renderDeclaredValue = subject.renderDeclaredValue.bind(subject);
481
+ const options = { width: 80, expanded: true };
482
+ for (const { value, rawPayload } of cases) {
483
+ assertPhysicalLines(expect, renderDeclaredValue(value, rawPayload, options), options.width);
484
+ }
485
+ const { nonRawValues, rawValues } = evaluateDeclaredValueCoverage(cases, renderDeclaredValue, options);
486
+ expect(nonRawValues.length, `declared values [${cases.map((c) => c.value).join(", ")}] mostly render as an undifferentiated JSON.stringify dump of ` +
487
+ `their own payload -- only [${nonRawValues.join(", ") || "none"}] escape it, [${rawValues.join(", ")}] don't`).toBeGreaterThanOrEqual(Math.min(2, cases.length));
488
+ }
489
+ finally {
490
+ await cleanup();
491
+ }
492
+ });
493
+ });
494
+ }
@@ -0,0 +1,11 @@
1
+ import { type ToolShellDualChannelFixture, type VehicleConformanceFixture } from "./vehicle-conformance.js";
2
+ export interface VitestConformanceApi {
3
+ describe(name: string, body: () => void): unknown;
4
+ it(name: string, body: () => void | Promise<void>): unknown;
5
+ expect(actual: unknown, message?: string): unknown;
6
+ }
7
+ /** Registers the shared Vehicle client matrix through a Vitest-compatible API. */
8
+ export declare function runVehicleClientConformanceWithVitest(api: VitestConformanceApi, fixture: VehicleConformanceFixture): void;
9
+ /** Registers the shared Tool Shell matrix through a Vitest-compatible API. */
10
+ export declare function runToolShellDualChannelConformanceWithVitest(api: VitestConformanceApi, fixture: ToolShellDualChannelFixture): void;
11
+ export * from "./vehicle-conformance.js";
package/dist/vitest.js ADDED
@@ -0,0 +1,17 @@
1
+ import { registerToolShellDualChannelConformance, registerVehicleClientConformance, } from "./vehicle-conformance.js";
2
+ function runner(api) {
3
+ return {
4
+ describe: (name, body) => api.describe(name, body),
5
+ it: (name, body) => api.it(name, body),
6
+ expect: (actual, message) => api.expect(actual, message),
7
+ };
8
+ }
9
+ /** Registers the shared Vehicle client matrix through a Vitest-compatible API. */
10
+ export function runVehicleClientConformanceWithVitest(api, fixture) {
11
+ registerVehicleClientConformance(runner(api), fixture);
12
+ }
13
+ /** Registers the shared Tool Shell matrix through a Vitest-compatible API. */
14
+ export function runToolShellDualChannelConformanceWithVitest(api, fixture) {
15
+ registerToolShellDualChannelConformance(runner(api), fixture);
16
+ }
17
+ export * from "./vehicle-conformance.js";
package/package.json CHANGED
@@ -1,24 +1,36 @@
1
1
  {
2
2
  "name": "@danypops/vehicle-conformance",
3
- "version": "0.4.1",
4
- "description": "Host-neutral conformance suite for any VehicleClient implementation: one shared bun:test assertion set that LocalVehicleClient, RemoteVehicleClient, and any future transport must satisfy identically. A Bun-only devDependency for testing, not a runtime library -- ships raw TypeScript, never precompiled.",
3
+ "version": "0.4.2",
4
+ "description": "Runner-neutral conformance suite for VehicleClient implementations, with Bun and Vitest adapters over one shared assertion matrix.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
- "main": "./src/vehicle-conformance.ts",
8
- "types": "./src/vehicle-conformance.ts",
7
+ "main": "./dist/bun.js",
8
+ "types": "./dist/bun.d.ts",
9
9
  "exports": {
10
- ".": "./src/vehicle-conformance.ts"
10
+ ".": {
11
+ "types": "./dist/bun.d.ts",
12
+ "default": "./dist/bun.js"
13
+ },
14
+ "./core": {
15
+ "types": "./dist/vehicle-conformance.d.ts",
16
+ "default": "./dist/vehicle-conformance.js"
17
+ },
18
+ "./vitest": {
19
+ "types": "./dist/vitest.d.ts",
20
+ "default": "./dist/vitest.js"
21
+ }
11
22
  },
12
23
  "scripts": {
13
- "test": "bun test test",
24
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
25
+ "test": "bun run build && bun test test",
14
26
  "typecheck": "tsc --noEmit"
15
27
  },
16
28
  "dependencies": {
17
- "@danypops/vehicle-core": "^0.19.0",
18
- "@danypops/vehicle-server": "^0.27.0"
29
+ "@danypops/vehicle-core": "^0.19.1",
30
+ "@danypops/vehicle-server": "^0.27.1"
19
31
  },
20
32
  "devDependencies": {
21
- "@danypops/vehicle-client": "^0.10.6",
33
+ "@danypops/vehicle-client": "^0.10.8",
22
34
  "@types/node": "^22.0.0",
23
35
  "typescript": "^5.9.3"
24
36
  },
@@ -34,6 +46,7 @@
34
46
  ],
35
47
  "files": [
36
48
  "src",
49
+ "dist",
37
50
  "README.md"
38
51
  ]
39
52
  }
package/src/bun.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ registerToolShellDualChannelConformance,
4
+ registerVehicleClientConformance,
5
+ type ToolShellDualChannelFixture,
6
+ type VehicleConformanceFixture,
7
+ type VehicleConformanceMatchers,
8
+ type VehicleConformanceRunner,
9
+ } from "./vehicle-conformance.js";
10
+
11
+ const bunRunner: VehicleConformanceRunner = {
12
+ describe: (name, body) => describe(name, body),
13
+ it: (name, body) => it(name, body),
14
+ expect: (actual, message) => expect(actual, message) as unknown as VehicleConformanceMatchers,
15
+ };
16
+
17
+ /** Registers the shared Vehicle client matrix with Bun's test runner. */
18
+ export function runVehicleClientConformance(fixture: VehicleConformanceFixture): void {
19
+ registerVehicleClientConformance(bunRunner, fixture);
20
+ }
21
+
22
+ /** Registers the shared Tool Shell matrix with Bun's test runner. */
23
+ export function runToolShellDualChannelConformance(fixture: ToolShellDualChannelFixture): void {
24
+ registerToolShellDualChannelConformance(bunRunner, fixture);
25
+ }
26
+
27
+ export * from "./vehicle-conformance.js";
@@ -18,8 +18,31 @@
18
18
  * argument parsing) stay out of this module entirely, per the extraction
19
19
  * scope this generalizes.
20
20
  */
21
- import { describe, expect, it } from "bun:test";
22
21
  import type { VehicleClient } from "@danypops/vehicle-core";
22
+
23
+ export interface VehicleConformanceMatchers {
24
+ toEqual(expected: unknown): unknown;
25
+ toMatchObject(expected: unknown): unknown;
26
+ toBe(expected: unknown): unknown;
27
+ toBeTruthy(): unknown;
28
+ toBeUndefined(): unknown;
29
+ toContain(expected: unknown): unknown;
30
+ toMatch(expected: RegExp | string): unknown;
31
+ toBeGreaterThan(expected: number): unknown;
32
+ toBeGreaterThanOrEqual(expected: number): unknown;
33
+ toBeLessThan(expected: number): unknown;
34
+ toBeLessThanOrEqual(expected: number): unknown;
35
+ readonly not: VehicleConformanceMatchers;
36
+ readonly rejects: VehicleConformanceMatchers;
37
+ readonly resolves: VehicleConformanceMatchers;
38
+ }
39
+
40
+ export interface VehicleConformanceRunner {
41
+ describe(name: string, body: () => void): unknown;
42
+ it(name: string, body: () => void | Promise<void>): unknown;
43
+ expect(actual: unknown, message?: string): VehicleConformanceMatchers;
44
+ }
45
+
23
46
  import { bindVehicleOperation, defineVehicleOperation, defineVehicleSchema, VehicleError } from "@danypops/vehicle-core";
24
47
  import type { VehicleRegistry } from "@danypops/vehicle-server";
25
48
 
@@ -173,7 +196,8 @@ export interface VehicleConformanceFixture {
173
196
  create(): Promise<{ client: VehicleClient; cleanup: () => Promise<void> }>;
174
197
  }
175
198
 
176
- export function runVehicleClientConformance(fixture: VehicleConformanceFixture): void {
199
+ export function registerVehicleClientConformance(runner: VehicleConformanceRunner, fixture: VehicleConformanceFixture): void {
200
+ const { describe, expect, it } = runner;
177
201
  describe(`Vehicle client conformance: ${fixture.label}`, () => {
178
202
  it("manifest() lists every registered operation with its real descriptor fields", async () => {
179
203
  const { client, cleanup } = await fixture.create();
@@ -201,6 +225,21 @@ export function runVehicleClientConformance(fixture: VehicleConformanceFixture):
201
225
  }
202
226
  });
203
227
 
228
+ it("negotiate() agrees on the shared protocol and rejects an incompatible range", async () => {
229
+ const { client, cleanup } = await fixture.create();
230
+ try {
231
+ if (!client.negotiate) throw new Error("Vehicle client does not implement protocol negotiation");
232
+ await expect(
233
+ client.negotiate({ minimumVersion: 1, maximumVersion: 2, requiredCapabilities: [], optionalCapabilities: ["future"] }),
234
+ ).resolves.toEqual({ version: 1, capabilities: [] });
235
+ await expect(
236
+ client.negotiate({ minimumVersion: 2, maximumVersion: 3, requiredCapabilities: [], optionalCapabilities: [] }),
237
+ ).rejects.toMatchObject({ code: "protocol-version-incompatible" });
238
+ } finally {
239
+ await cleanup();
240
+ }
241
+ });
242
+
204
243
  it("invoke() returns the real handler output on success", async () => {
205
244
  const { client, cleanup } = await fixture.create();
206
245
  try {
@@ -526,7 +565,7 @@ function utf8Length(value: string): number {
526
565
  // biome-ignore lint/complexity/useRegexLiterals: a constructor avoids control-character lint on the equivalent literal.
527
566
  const ANSI_CSI_PATTERN = new RegExp("\\u001B\\[[0-?]*[ -/]*[@-~]", "g");
528
567
 
529
- function assertPhysicalLines(lines: readonly string[], width: number): void {
568
+ function assertPhysicalLines(expect: VehicleConformanceRunner["expect"], lines: readonly string[], width: number): void {
530
569
  expect(lines.length).toBeGreaterThan(0);
531
570
  for (const line of lines) {
532
571
  expect(line).not.toContain("\n");
@@ -537,7 +576,8 @@ function assertPhysicalLines(lines: readonly string[], width: number): void {
537
576
  }
538
577
 
539
578
  /** Reusable provider-facing dual-channel contract matrix. */
540
- export function runToolShellDualChannelConformance(fixture: ToolShellDualChannelFixture): void {
579
+ export function registerToolShellDualChannelConformance(runner: VehicleConformanceRunner, fixture: ToolShellDualChannelFixture): void {
580
+ const { describe, expect, it } = runner;
541
581
  describe(`Vehicle Tool Shell dual-channel conformance: ${fixture.label}`, () => {
542
582
  it("keeps model and persisted-presentation sentinels isolated under independent named bounds", async () => {
543
583
  const { subject, cleanup } = await fixture.create();
@@ -576,9 +616,9 @@ export function runToolShellDualChannelConformance(fixture: ToolShellDualChannel
576
616
  const snapshot = await subject.execute();
577
617
  const before = JSON.stringify(snapshot);
578
618
  for (const width of [40, 80, 120] as const) {
579
- assertPhysicalLines(subject.render(snapshot, { width, expanded: false }), width);
580
- assertPhysicalLines(subject.render(snapshot, { width, expanded: true }), width);
581
- assertPhysicalLines(subject.render(snapshot, { width, expanded: false, partial: true }), width);
619
+ assertPhysicalLines(expect, subject.render(snapshot, { width, expanded: false }), width);
620
+ assertPhysicalLines(expect, subject.render(snapshot, { width, expanded: true }), width);
621
+ assertPhysicalLines(expect, subject.render(snapshot, { width, expanded: false, partial: true }), width);
582
622
  }
583
623
  expect(JSON.stringify(snapshot)).toBe(before);
584
624
  } finally {
@@ -611,7 +651,7 @@ export function runToolShellDualChannelConformance(fixture: ToolShellDualChannel
611
651
  const renderDeclaredValue = subject.renderDeclaredValue.bind(subject);
612
652
  const options: ToolShellRenderOptions = { width: 80, expanded: true };
613
653
  for (const { value, rawPayload } of cases) {
614
- assertPhysicalLines(renderDeclaredValue(value, rawPayload, options), options.width);
654
+ assertPhysicalLines(expect, renderDeclaredValue(value, rawPayload, options), options.width);
615
655
  }
616
656
  const { nonRawValues, rawValues } = evaluateDeclaredValueCoverage(cases, renderDeclaredValue, options);
617
657
  expect(
package/src/vitest.ts ADDED
@@ -0,0 +1,34 @@
1
+ import {
2
+ registerToolShellDualChannelConformance,
3
+ registerVehicleClientConformance,
4
+ type ToolShellDualChannelFixture,
5
+ type VehicleConformanceFixture,
6
+ type VehicleConformanceMatchers,
7
+ type VehicleConformanceRunner,
8
+ } from "./vehicle-conformance.js";
9
+
10
+ export interface VitestConformanceApi {
11
+ describe(name: string, body: () => void): unknown;
12
+ it(name: string, body: () => void | Promise<void>): unknown;
13
+ expect(actual: unknown, message?: string): unknown;
14
+ }
15
+
16
+ function runner(api: VitestConformanceApi): VehicleConformanceRunner {
17
+ return {
18
+ describe: (name, body) => api.describe(name, body),
19
+ it: (name, body) => api.it(name, body),
20
+ expect: (actual, message) => api.expect(actual, message) as VehicleConformanceMatchers,
21
+ };
22
+ }
23
+
24
+ /** Registers the shared Vehicle client matrix through a Vitest-compatible API. */
25
+ export function runVehicleClientConformanceWithVitest(api: VitestConformanceApi, fixture: VehicleConformanceFixture): void {
26
+ registerVehicleClientConformance(runner(api), fixture);
27
+ }
28
+
29
+ /** Registers the shared Tool Shell matrix through a Vitest-compatible API. */
30
+ export function runToolShellDualChannelConformanceWithVitest(api: VitestConformanceApi, fixture: ToolShellDualChannelFixture): void {
31
+ registerToolShellDualChannelConformance(runner(api), fixture);
32
+ }
33
+
34
+ export * from "./vehicle-conformance.js";