@danypops/vehicle-conformance 0.4.0 → 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 +13 -3
- package/dist/bun.d.ts +6 -0
- package/dist/bun.js +16 -0
- package/dist/vehicle-conformance.d.ts +121 -0
- package/dist/vehicle-conformance.js +494 -0
- package/dist/vitest.d.ts +11 -0
- package/dist/vitest.js +17 -0
- package/package.json +22 -9
- package/src/bun.ts +27 -0
- package/src/vehicle-conformance.ts +48 -8
- package/src/vitest.ts +34 -0
package/README.md
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# @danypops/vehicle-conformance
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/vitest.d.ts
ADDED
|
@@ -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.
|
|
4
|
-
"description": "
|
|
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": "./
|
|
8
|
-
"types": "./
|
|
7
|
+
"main": "./dist/bun.js",
|
|
8
|
+
"types": "./dist/bun.d.ts",
|
|
9
9
|
"exports": {
|
|
10
|
-
".":
|
|
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
|
-
"
|
|
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.
|
|
18
|
-
"@danypops/vehicle-server": "^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.
|
|
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
|
|
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
|
|
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";
|