@danypops/vehicle-conformance 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -0
- package/package.json +32 -0
- package/src/vehicle-conformance.ts +308 -0
package/README.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @danypops/vehicle-conformance
|
|
2
|
+
|
|
3
|
+
Host-neutral `bun:test` conformance suite for any `VehicleClient`
|
|
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.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
bun add -d @danypops/vehicle-conformance
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { registerConformanceOperations, runVehicleClientConformance } from "@danypops/vehicle-conformance";
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
See the [workspace README](https://github.com/DanyPops/daemon-kit#readme) for
|
|
17
|
+
the full Vehicle package layout.
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@danypops/vehicle-conformance",
|
|
3
|
+
"version": "0.1.0",
|
|
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.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/vehicle-conformance.ts",
|
|
8
|
+
"types": "./src/vehicle-conformance.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/vehicle-conformance.ts"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "bun test test",
|
|
14
|
+
"typecheck": "tsc --noEmit"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@danypops/vehicle-core": "workspace:*",
|
|
18
|
+
"@danypops/vehicle-server": "workspace:*"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@danypops/vehicle-client": "workspace:*",
|
|
22
|
+
"@types/node": "^22.0.0",
|
|
23
|
+
"typescript": "latest"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/DanyPops/daemon-kit.git",
|
|
28
|
+
"directory": "packages/vehicle-conformance"
|
|
29
|
+
},
|
|
30
|
+
"keywords": ["vehicle", "agent-tools", "testing"],
|
|
31
|
+
"files": ["src", "README.md"]
|
|
32
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
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 { describe, expect, it } from "bun:test";
|
|
22
|
+
import { bindVehicleOperation, defineVehicleOperation, defineVehicleSchema, VehicleError } from "@danypops/vehicle-core";
|
|
23
|
+
import type { VehicleClient } from "@danypops/vehicle-core";
|
|
24
|
+
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
25
|
+
|
|
26
|
+
const passthroughSchema = defineVehicleSchema<{ value: string }>({
|
|
27
|
+
jsonSchema: { type: "object", properties: { value: { type: "string" } }, additionalProperties: false },
|
|
28
|
+
safeParse(value: unknown) {
|
|
29
|
+
if (typeof value === "object" && value !== null && typeof (value as { value?: unknown }).value === "string") {
|
|
30
|
+
return { success: true, value: value as { value: string } };
|
|
31
|
+
}
|
|
32
|
+
return { success: false, issues: [{ path: ["value"], message: "value must be a string" }] };
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const outputSchema = defineVehicleSchema<{ echoed: string }>({
|
|
37
|
+
jsonSchema: { type: "object", properties: { echoed: { type: "string" } }, additionalProperties: false },
|
|
38
|
+
safeParse(value: unknown) {
|
|
39
|
+
if (typeof value === "object" && value !== null && typeof (value as { echoed?: unknown }).echoed === "string") {
|
|
40
|
+
return { success: true, value: value as { echoed: string } };
|
|
41
|
+
}
|
|
42
|
+
return { success: false, issues: [{ path: ["echoed"], message: "echoed must be a string" }] };
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const LIMITS = { defaultTimeoutMs: 200, maxTimeoutMs: 2_000, maxRequestBytes: 256, maxResponseBytes: 256 } as const;
|
|
47
|
+
|
|
48
|
+
const ConformanceEcho = defineVehicleOperation({
|
|
49
|
+
name: "conformance.echo",
|
|
50
|
+
version: 1,
|
|
51
|
+
description: "Echoes its input.",
|
|
52
|
+
input: passthroughSchema,
|
|
53
|
+
output: outputSchema,
|
|
54
|
+
permissions: ["conformance:echo"],
|
|
55
|
+
effect: "read",
|
|
56
|
+
idempotency: { mode: "safe" },
|
|
57
|
+
limits: LIMITS,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const ConformanceBoom = defineVehicleOperation({
|
|
61
|
+
name: "conformance.boom",
|
|
62
|
+
version: 1,
|
|
63
|
+
description: "Always throws a real VehicleError from its handler.",
|
|
64
|
+
input: passthroughSchema,
|
|
65
|
+
output: outputSchema,
|
|
66
|
+
permissions: [],
|
|
67
|
+
effect: "read",
|
|
68
|
+
idempotency: { mode: "safe" },
|
|
69
|
+
limits: LIMITS,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const ConformanceKeyed = defineVehicleOperation({
|
|
73
|
+
name: "conformance.keyed",
|
|
74
|
+
version: 1,
|
|
75
|
+
description: "Requires a keyed idempotency key.",
|
|
76
|
+
input: passthroughSchema,
|
|
77
|
+
output: outputSchema,
|
|
78
|
+
permissions: [],
|
|
79
|
+
effect: "external-write",
|
|
80
|
+
idempotency: { mode: "keyed", retentionMs: 60_000 },
|
|
81
|
+
limits: LIMITS,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const ConformanceProgress = defineVehicleOperation({
|
|
85
|
+
name: "conformance.progress",
|
|
86
|
+
version: 1,
|
|
87
|
+
description: "Reports two progress events, then resolves.",
|
|
88
|
+
input: passthroughSchema,
|
|
89
|
+
output: outputSchema,
|
|
90
|
+
permissions: [],
|
|
91
|
+
effect: "read",
|
|
92
|
+
idempotency: { mode: "safe" },
|
|
93
|
+
limits: LIMITS,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const ConformanceNever = defineVehicleOperation({
|
|
97
|
+
name: "conformance.never",
|
|
98
|
+
version: 1,
|
|
99
|
+
description: "Never resolves on its own -- only via cancellation or deadline.",
|
|
100
|
+
input: passthroughSchema,
|
|
101
|
+
output: outputSchema,
|
|
102
|
+
permissions: [],
|
|
103
|
+
effect: "read",
|
|
104
|
+
idempotency: { mode: "safe" },
|
|
105
|
+
limits: LIMITS,
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
/** Registers the fixed conformance operation set onto `registry`. Every fixture must call this before handing back its client. */
|
|
109
|
+
export function registerConformanceOperations(registry: VehicleRegistry): void {
|
|
110
|
+
registry.register(
|
|
111
|
+
"conformance",
|
|
112
|
+
bindVehicleOperation(ConformanceEcho, () => async (context) => ({ echoed: context.input.value })),
|
|
113
|
+
);
|
|
114
|
+
registry.register(
|
|
115
|
+
"conformance",
|
|
116
|
+
bindVehicleOperation(ConformanceBoom, () => async () => {
|
|
117
|
+
throw new VehicleError("conformance-boom", "conformance.boom always fails", { category: "internal" });
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
registry.register(
|
|
121
|
+
"conformance",
|
|
122
|
+
bindVehicleOperation(ConformanceKeyed, () => async (context) => ({ echoed: context.input.value })),
|
|
123
|
+
);
|
|
124
|
+
registry.register(
|
|
125
|
+
"conformance",
|
|
126
|
+
bindVehicleOperation(ConformanceProgress, () => async (context) => {
|
|
127
|
+
context.reportProgress({ step: 1 });
|
|
128
|
+
context.reportProgress({ step: 2 });
|
|
129
|
+
return { echoed: context.input.value };
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
registry.register(
|
|
133
|
+
"conformance",
|
|
134
|
+
bindVehicleOperation(ConformanceNever, () => (context) => {
|
|
135
|
+
return new Promise((_resolve, reject) => {
|
|
136
|
+
context.signal.addEventListener("abort", () => reject(new Error("conformance.never aborted")), { once: true });
|
|
137
|
+
});
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface VehicleConformanceFixture {
|
|
143
|
+
/** Used in describe() block titles, e.g. "LocalVehicleClient" or "RemoteVehicleClient (HTTP)". */
|
|
144
|
+
label: string;
|
|
145
|
+
/** 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. */
|
|
146
|
+
create(): Promise<{ client: VehicleClient; cleanup: () => Promise<void> }>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function runVehicleClientConformance(fixture: VehicleConformanceFixture): void {
|
|
150
|
+
describe(`Vehicle client conformance: ${fixture.label}`, () => {
|
|
151
|
+
it("manifest() lists every registered operation with its real descriptor fields", async () => {
|
|
152
|
+
const { client, cleanup } = await fixture.create();
|
|
153
|
+
try {
|
|
154
|
+
const manifest = await client.manifest();
|
|
155
|
+
const names = manifest.operations.map((op) => `${op.name}@${op.version}`).sort();
|
|
156
|
+
expect(names).toEqual([
|
|
157
|
+
"conformance.boom@1",
|
|
158
|
+
"conformance.echo@1",
|
|
159
|
+
"conformance.keyed@1",
|
|
160
|
+
"conformance.never@1",
|
|
161
|
+
"conformance.progress@1",
|
|
162
|
+
]);
|
|
163
|
+
const echo = manifest.operations.find((op) => op.name === "conformance.echo");
|
|
164
|
+
expect(echo?.permissions).toEqual(["conformance:echo"]);
|
|
165
|
+
expect(echo?.idempotency).toEqual({ mode: "safe" });
|
|
166
|
+
// available defaults to true for every operation, and must survive
|
|
167
|
+
// the wire round trip identically for a remote (HTTP/JSON) client,
|
|
168
|
+
// not just the in-process local one.
|
|
169
|
+
expect(manifest.operations.every((op) => op.available === true)).toBe(true);
|
|
170
|
+
expect(echo?.unavailableReason).toBeUndefined();
|
|
171
|
+
} finally {
|
|
172
|
+
await cleanup();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("invoke() returns the real handler output on success", async () => {
|
|
177
|
+
const { client, cleanup } = await fixture.create();
|
|
178
|
+
try {
|
|
179
|
+
const result = await client.invoke<{ echoed: string }>("conformance.echo", 1, { value: "hi" }, { permissions: ["conformance:echo"] });
|
|
180
|
+
expect(result).toEqual({ echoed: "hi" });
|
|
181
|
+
} finally {
|
|
182
|
+
await cleanup();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("invoke() rejects invalid input before the handler ever runs", async () => {
|
|
187
|
+
const { client, cleanup } = await fixture.create();
|
|
188
|
+
try {
|
|
189
|
+
await expect(client.invoke("conformance.echo", 1, { value: 123 }, { permissions: ["conformance:echo"] })).rejects.toMatchObject({
|
|
190
|
+
code: "invalid-input",
|
|
191
|
+
});
|
|
192
|
+
} finally {
|
|
193
|
+
await cleanup();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("invoke() enforces required permissions with permission-denied/authorization", async () => {
|
|
198
|
+
const { client, cleanup } = await fixture.create();
|
|
199
|
+
try {
|
|
200
|
+
await expect(client.invoke("conformance.echo", 1, { value: "hi" }, {})).rejects.toMatchObject({
|
|
201
|
+
code: "permission-denied",
|
|
202
|
+
category: "authorization",
|
|
203
|
+
});
|
|
204
|
+
} finally {
|
|
205
|
+
await cleanup();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("invoke() surfaces a real handler failure's own code/category/message, not a generic wrapper", async () => {
|
|
210
|
+
const { client, cleanup } = await fixture.create();
|
|
211
|
+
try {
|
|
212
|
+
await expect(client.invoke("conformance.boom", 1, { value: "x" }, {})).rejects.toMatchObject({
|
|
213
|
+
code: "conformance-boom",
|
|
214
|
+
message: "conformance.boom always fails",
|
|
215
|
+
});
|
|
216
|
+
} finally {
|
|
217
|
+
await cleanup();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("invoke() requires an idempotency key for a keyed operation", async () => {
|
|
222
|
+
const { client, cleanup } = await fixture.create();
|
|
223
|
+
try {
|
|
224
|
+
await expect(client.invoke("conformance.keyed", 1, { value: "x" }, {})).rejects.toMatchObject({
|
|
225
|
+
code: "idempotency-key-required",
|
|
226
|
+
});
|
|
227
|
+
const result = await client.invoke<{ echoed: string }>("conformance.keyed", 1, { value: "x" }, { idempotencyKey: "k-1" });
|
|
228
|
+
expect(result).toEqual({ echoed: "x" });
|
|
229
|
+
} finally {
|
|
230
|
+
await cleanup();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("invoke() rejects a request exceeding its declared byte bound", async () => {
|
|
235
|
+
const { client, cleanup } = await fixture.create();
|
|
236
|
+
try {
|
|
237
|
+
const oversized = "x".repeat(1024);
|
|
238
|
+
await expect(client.invoke("conformance.echo", 1, { value: oversized }, { permissions: ["conformance:echo"] })).rejects.toMatchObject({
|
|
239
|
+
code: "request-too-large",
|
|
240
|
+
});
|
|
241
|
+
} finally {
|
|
242
|
+
await cleanup();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("invoke() rejects an operation for a name/version pair that was never registered", async () => {
|
|
247
|
+
const { client, cleanup } = await fixture.create();
|
|
248
|
+
try {
|
|
249
|
+
await expect(client.invoke("conformance.nonexistent", 1, {}, {})).rejects.toMatchObject({ code: "not-found" });
|
|
250
|
+
} finally {
|
|
251
|
+
await cleanup();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("invoke() delivers every progress event before resolving with the final result, never after", async () => {
|
|
256
|
+
const { client, cleanup } = await fixture.create();
|
|
257
|
+
try {
|
|
258
|
+
const progress: unknown[] = [];
|
|
259
|
+
let resolved = false;
|
|
260
|
+
const result = await client.invoke<{ echoed: string }>("conformance.progress", 1, { value: "hi" }, {
|
|
261
|
+
onProgress: (p) => {
|
|
262
|
+
expect(resolved).toBe(false);
|
|
263
|
+
progress.push(p);
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
resolved = true;
|
|
267
|
+
expect(progress).toEqual([{ step: 1 }, { step: 2 }]);
|
|
268
|
+
expect(result).toEqual({ echoed: "hi" });
|
|
269
|
+
} finally {
|
|
270
|
+
await cleanup();
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("invoke() propagates cancellation via AbortSignal to the operation itself", async () => {
|
|
275
|
+
const { client, cleanup } = await fixture.create();
|
|
276
|
+
try {
|
|
277
|
+
const controller = new AbortController();
|
|
278
|
+
const invocation = client.invoke("conformance.never", 1, { value: "x" }, { signal: controller.signal });
|
|
279
|
+
await new Promise((resolve) => setTimeout(resolve, 15));
|
|
280
|
+
controller.abort();
|
|
281
|
+
await expect(invocation).rejects.toBeTruthy();
|
|
282
|
+
} finally {
|
|
283
|
+
await cleanup();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("invoke() respects an explicit deadline that has already elapsed", async () => {
|
|
288
|
+
const { client, cleanup } = await fixture.create();
|
|
289
|
+
try {
|
|
290
|
+
await expect(client.invoke("conformance.echo", 1, { value: "hi" }, { permissions: ["conformance:echo"], deadline: Date.now() - 1 })).rejects.toMatchObject({
|
|
291
|
+
code: "deadline-exceeded",
|
|
292
|
+
});
|
|
293
|
+
} finally {
|
|
294
|
+
await cleanup();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("close() prevents further invoke()/manifest() calls on this client instance", async () => {
|
|
299
|
+
const { client, cleanup } = await fixture.create();
|
|
300
|
+
try {
|
|
301
|
+
await client.close();
|
|
302
|
+
await expect(client.manifest()).rejects.toBeTruthy();
|
|
303
|
+
} finally {
|
|
304
|
+
await cleanup();
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
}
|