feat(core-runner-protocol): v0 message schemas with round-trip tests
Envelope pins protocolVersion "0" and carries the workspace-scoped auth token; discriminated union on type covers hello/ready, clone, install, scan, adapter-start, render-frame, status (stage + elapsed), and error (named causes). All .strict() so the control plane, editor, and runner cannot drift apart silently (ADR-027, PRD user story 7). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016j8z4VHjedXDTjEDNg7qHK
This commit is contained in:
310
packages/core-runner-protocol/src/messages.test.ts
Normal file
310
packages/core-runner-protocol/src/messages.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
RUNNER_STAGES,
|
||||
RUNNER_ERROR_CAUSES,
|
||||
helloMessageSchema,
|
||||
readyMessageSchema,
|
||||
cloneMessageSchema,
|
||||
installMessageSchema,
|
||||
scanMessageSchema,
|
||||
adapterStartMessageSchema,
|
||||
renderFrameMessageSchema,
|
||||
statusMessageSchema,
|
||||
errorMessageSchema,
|
||||
runnerMessageSchema,
|
||||
type RunnerMessage,
|
||||
} from "@/messages";
|
||||
|
||||
/** Serialize → deserialize → parse: the honest wire round-trip. */
|
||||
function wireRoundTrip(schema: z.ZodTypeAny, value: unknown): unknown {
|
||||
return schema.parse(JSON.parse(JSON.stringify(value)));
|
||||
}
|
||||
|
||||
describe("helloMessageSchema", () => {
|
||||
it("accepts a wire round-trip", () => {
|
||||
const msg = { type: "hello" };
|
||||
expect(wireRoundTrip(helloMessageSchema, msg)).toEqual(msg);
|
||||
});
|
||||
|
||||
it("rejects unknown fields", () => {
|
||||
expect(() =>
|
||||
helloMessageSchema.parse({ type: "hello", extra: true }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a wrong type literal", () => {
|
||||
expect(() => helloMessageSchema.parse({ type: "ready" })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readyMessageSchema", () => {
|
||||
it("accepts a wire round-trip", () => {
|
||||
const msg = { type: "ready" };
|
||||
expect(wireRoundTrip(readyMessageSchema, msg)).toEqual(msg);
|
||||
});
|
||||
|
||||
it("rejects unknown fields", () => {
|
||||
expect(() =>
|
||||
readyMessageSchema.parse({ type: "ready", capabilities: [] }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloneMessageSchema", () => {
|
||||
it("accepts a wire round-trip with a PAT", () => {
|
||||
const msg = {
|
||||
type: "clone",
|
||||
gitUrl: "https://git.example.com/acme/design-system.git",
|
||||
pat: "ghp_workspace-scoped",
|
||||
};
|
||||
expect(wireRoundTrip(cloneMessageSchema, msg)).toEqual(msg);
|
||||
});
|
||||
|
||||
it("accepts a public clone without a PAT", () => {
|
||||
const msg = { type: "clone", gitUrl: "https://git.example.com/oss.git" };
|
||||
expect(wireRoundTrip(cloneMessageSchema, msg)).toEqual(msg);
|
||||
});
|
||||
|
||||
it("rejects a missing gitUrl", () => {
|
||||
expect(() => cloneMessageSchema.parse({ type: "clone" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an empty gitUrl", () => {
|
||||
expect(() =>
|
||||
cloneMessageSchema.parse({ type: "clone", gitUrl: "" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown fields", () => {
|
||||
expect(() =>
|
||||
cloneMessageSchema.parse({
|
||||
type: "clone",
|
||||
gitUrl: "https://git.example.com/x.git",
|
||||
branch: "main",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("installMessageSchema", () => {
|
||||
it("accepts a wire round-trip", () => {
|
||||
const msg = { type: "install" };
|
||||
expect(wireRoundTrip(installMessageSchema, msg)).toEqual(msg);
|
||||
});
|
||||
|
||||
it("rejects unknown fields", () => {
|
||||
expect(() =>
|
||||
installMessageSchema.parse({ type: "install", packageManager: "pnpm" }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanMessageSchema", () => {
|
||||
it("accepts a wire round-trip", () => {
|
||||
const msg = { type: "scan" };
|
||||
expect(wireRoundTrip(scanMessageSchema, msg)).toEqual(msg);
|
||||
});
|
||||
|
||||
it("rejects unknown fields", () => {
|
||||
expect(() =>
|
||||
scanMessageSchema.parse({ type: "scan", glob: "src/**" }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapterStartMessageSchema", () => {
|
||||
it("accepts a wire round-trip", () => {
|
||||
const msg = { type: "adapter-start" };
|
||||
expect(wireRoundTrip(adapterStartMessageSchema, msg)).toEqual(msg);
|
||||
});
|
||||
|
||||
it("rejects unknown fields", () => {
|
||||
expect(() =>
|
||||
adapterStartMessageSchema.parse({ type: "adapter-start", port: 5173 }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderFrameMessageSchema", () => {
|
||||
it("accepts a wire round-trip with props", () => {
|
||||
const msg = {
|
||||
type: "render-frame",
|
||||
componentId: "button",
|
||||
props: { label: "Save", disabled: false },
|
||||
};
|
||||
expect(wireRoundTrip(renderFrameMessageSchema, msg)).toEqual(msg);
|
||||
});
|
||||
|
||||
it("accepts default props by omission", () => {
|
||||
const msg = { type: "render-frame", componentId: "button" };
|
||||
expect(wireRoundTrip(renderFrameMessageSchema, msg)).toEqual(msg);
|
||||
});
|
||||
|
||||
it("rejects a missing componentId", () => {
|
||||
expect(() =>
|
||||
renderFrameMessageSchema.parse({ type: "render-frame" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown fields", () => {
|
||||
expect(() =>
|
||||
renderFrameMessageSchema.parse({
|
||||
type: "render-frame",
|
||||
componentId: "button",
|
||||
viewport: "mobile",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("statusMessageSchema", () => {
|
||||
it.each(RUNNER_STAGES)("accepts a wire round-trip for stage %s", (stage) => {
|
||||
const msg = { type: "status", stage, elapsedMs: 1200 };
|
||||
expect(wireRoundTrip(statusMessageSchema, msg)).toEqual(msg);
|
||||
});
|
||||
|
||||
it("rejects an unknown stage", () => {
|
||||
expect(() =>
|
||||
statusMessageSchema.parse({
|
||||
type: "status",
|
||||
stage: "compiling",
|
||||
elapsedMs: 0,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a missing elapsedMs", () => {
|
||||
expect(() =>
|
||||
statusMessageSchema.parse({ type: "status", stage: "cloning" }),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a negative elapsedMs", () => {
|
||||
expect(() =>
|
||||
statusMessageSchema.parse({
|
||||
type: "status",
|
||||
stage: "cloning",
|
||||
elapsedMs: -1,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a non-integer elapsedMs", () => {
|
||||
expect(() =>
|
||||
statusMessageSchema.parse({
|
||||
type: "status",
|
||||
stage: "cloning",
|
||||
elapsedMs: 3.5,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown fields", () => {
|
||||
expect(() =>
|
||||
statusMessageSchema.parse({
|
||||
type: "status",
|
||||
stage: "cloning",
|
||||
elapsedMs: 0,
|
||||
percent: 40,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorMessageSchema", () => {
|
||||
it.each(RUNNER_ERROR_CAUSES)(
|
||||
"accepts a wire round-trip for cause %s",
|
||||
(cause) => {
|
||||
const msg = { type: "error", cause, message: "named cause surfaced" };
|
||||
expect(wireRoundTrip(errorMessageSchema, msg)).toEqual(msg);
|
||||
},
|
||||
);
|
||||
|
||||
it("accepts an error pinned to a stage", () => {
|
||||
const msg = {
|
||||
type: "error",
|
||||
cause: "install-failed",
|
||||
message: "pnpm install exited 1",
|
||||
stage: "installing",
|
||||
};
|
||||
expect(wireRoundTrip(errorMessageSchema, msg)).toEqual(msg);
|
||||
});
|
||||
|
||||
it("rejects an unknown cause", () => {
|
||||
expect(() =>
|
||||
errorMessageSchema.parse({
|
||||
type: "error",
|
||||
cause: "mystery",
|
||||
message: "?",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an empty message", () => {
|
||||
expect(() =>
|
||||
errorMessageSchema.parse({
|
||||
type: "error",
|
||||
cause: "clone-failed",
|
||||
message: "",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an unknown stage", () => {
|
||||
expect(() =>
|
||||
errorMessageSchema.parse({
|
||||
type: "error",
|
||||
cause: "clone-failed",
|
||||
message: "x",
|
||||
stage: "compiling",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects unknown fields", () => {
|
||||
expect(() =>
|
||||
errorMessageSchema.parse({
|
||||
type: "error",
|
||||
cause: "clone-failed",
|
||||
message: "x",
|
||||
retryable: true,
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runnerMessageSchema (discriminated union)", () => {
|
||||
const oneOfEach: RunnerMessage[] = [
|
||||
{ type: "hello" },
|
||||
{ type: "ready" },
|
||||
{ type: "clone", gitUrl: "https://git.example.com/acme.git", pat: "tok" },
|
||||
{ type: "install" },
|
||||
{ type: "scan" },
|
||||
{ type: "adapter-start" },
|
||||
{ type: "render-frame", componentId: "button", props: { label: "Go" } },
|
||||
{ type: "status", stage: "cloning", elapsedMs: 42 },
|
||||
{ type: "error", cause: "clone-failed", message: "boom", stage: "cloning" },
|
||||
];
|
||||
|
||||
it.each(oneOfEach.map((msg) => [msg.type, msg] as const))(
|
||||
"routes %s through the union on a wire round-trip",
|
||||
(_type, msg) => {
|
||||
expect(wireRoundTrip(runnerMessageSchema, msg)).toEqual(msg);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an unknown type", () => {
|
||||
expect(() => runnerMessageSchema.parse({ type: "watch" })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a missing type", () => {
|
||||
expect(() => runnerMessageSchema.parse({})).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a known type with another type's fields", () => {
|
||||
expect(() =>
|
||||
runnerMessageSchema.parse({ type: "install", stage: "installing" }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user