Files
emdash-cms--emdash/packages/plugin-cli/tests/probe-schema.test.ts
T
wehub-resource-sync b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

321 lines
11 KiB
TypeScript

/**
* Tests for `parseProbedDefault`, the runtime validation the build
* pipeline runs against the imported default export of a probed plugin.
*
* These tests lock in the user-facing error-message contract: plugin
* authors and any aggregator that scrapes stderr depend on these
* exact strings. Behaviour changes here should be deliberate.
*/
import { describe, expect, it } from "vitest";
import { BuildPipelineError, parseProbedDefault } from "../src/build/pipeline.js";
const PLUGIN_ENTRY = "src/plugin.ts";
function expectFailure(input: Record<string, unknown>): BuildPipelineError {
try {
parseProbedDefault(PLUGIN_ENTRY, input);
} catch (error) {
if (error instanceof BuildPipelineError) return error;
throw error;
}
throw new Error("Expected parseProbedDefault to throw, but it succeeded");
}
describe("parseProbedDefault", () => {
describe("valid shapes", () => {
it("accepts the empty default export", () => {
const result = parseProbedDefault(PLUGIN_ENTRY, {});
expect(result.hooks).toBeUndefined();
expect(result.routes).toBeUndefined();
});
it("normalises bare-function hooks to the config form", () => {
const handler = (): void => {};
const result = parseProbedDefault(PLUGIN_ENTRY, {
hooks: { "content:beforeSave": handler },
});
expect(result.hooks?.["content:beforeSave"]).toEqual({ handler });
});
it("preserves config-form hook fields", () => {
const handler = (): void => {};
const result = parseProbedDefault(PLUGIN_ENTRY, {
hooks: {
"content:beforeSave": {
handler,
priority: 50,
timeout: 1000,
dependencies: ["other-plugin"],
errorPolicy: "continue",
exclusive: true,
},
},
});
expect(result.hooks?.["content:beforeSave"]).toEqual({
handler,
priority: 50,
timeout: 1000,
dependencies: ["other-plugin"],
errorPolicy: "continue",
exclusive: true,
});
});
it("normalises bare-function routes to the config form", () => {
const handler = (): void => {};
const result = parseProbedDefault(PLUGIN_ENTRY, {
routes: { ping: handler },
});
expect(result.routes?.ping).toEqual({ handler });
});
it("preserves config-form route fields including public", () => {
const handler = (): void => {};
const result = parseProbedDefault(PLUGIN_ENTRY, {
routes: { ping: { handler, public: true } },
});
expect(result.routes?.ping).toEqual({ handler, public: true });
});
it("passes through unknown extra keys on the default export", () => {
const handler = (): void => {};
const result = parseProbedDefault(PLUGIN_ENTRY, {
hooks: { h: { handler, experimentalKnob: "future" } },
somethingElse: 42,
});
expect(result.hooks?.h).toMatchObject({ handler, experimentalKnob: "future" });
});
});
describe("hook validation errors", () => {
it("rejects a non-function/non-object hook entry", () => {
const error = expectFailure({ hooks: { h: "not a function" } });
expect(error.code).toBe("INVALID_PLUGIN_FORMAT");
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" must be a function or { handler: function, ... }. Got string.`,
);
});
it("rejects a Date hook entry as the wrong shape", () => {
// Only plain objects (`Object.prototype` or null prototype)
// reach the per-field validation. Anything with a more
// specific prototype is treated as a wrong-shaped entry.
const error = expectFailure({ hooks: { h: new Date() } });
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" must be a function or { handler: function, ... }. Got object.`,
);
});
it("rejects a RegExp hook entry as the wrong shape", () => {
const error = expectFailure({ hooks: { h: /x/ } });
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" must be a function or { handler: function, ... }. Got object.`,
);
});
it("rejects a Promise hook entry as the wrong shape", () => {
// Forgot-to-await mistake: `hooks: { h: makeHandler() }` where
// `makeHandler` is async. Caught at the entry-shape level so
// the author sees the standard wrong-shape message.
const error = expectFailure({ hooks: { h: Promise.resolve(() => {}) } });
expect(error.code).toBe("INVALID_PLUGIN_FORMAT");
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" must be a function or { handler: function, ... }. Got object.`,
);
});
it("rejects a hook entry missing its handler", () => {
const error = expectFailure({ hooks: { h: { errorPolicy: "abort" } } });
expect(error.code).toBe("INVALID_PLUGIN_FORMAT");
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" has invalid handler undefined (must be a function).`,
);
});
it("rejects an invalid errorPolicy", () => {
const error = expectFailure({
hooks: { h: { handler: (): void => {}, errorPolicy: "bogus" } },
});
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" has invalid errorPolicy "bogus" (must be "continue" or "abort").`,
);
});
it("rejects a non-number priority", () => {
const error = expectFailure({
hooks: { h: { handler: (): void => {}, priority: "high" } },
});
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" has invalid priority "high" (must be a finite number).`,
);
});
it("rejects an Infinity priority", () => {
const error = expectFailure({
hooks: { h: { handler: (): void => {}, priority: Infinity } },
});
// `JSON.stringify(Infinity)` is the string "null" per the JSON
// spec; safeStringify passes that through unchanged.
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" has invalid priority null (must be a finite number).`,
);
});
it("rejects a NaN priority", () => {
const error = expectFailure({
hooks: { h: { handler: (): void => {}, priority: Number.NaN } },
});
expect(error.message).toContain(`hook "h" has invalid priority`);
expect(error.message).toContain(`must be a finite number`);
});
it("rejects a negative timeout", () => {
const error = expectFailure({
hooks: { h: { handler: (): void => {}, timeout: -100 } },
});
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" has invalid timeout -100 (must be a non-negative finite number).`,
);
});
it("rejects a non-array dependencies", () => {
const error = expectFailure({
hooks: { h: { handler: (): void => {}, dependencies: "single" } },
});
expect(error.message).toContain(`hook "h" has invalid dependencies "single"`);
});
it("rejects a non-boolean exclusive", () => {
const error = expectFailure({
hooks: { h: { handler: (): void => {}, exclusive: "yes" } },
});
expect(error.message).toContain(`hook "h" has invalid exclusive "yes"`);
});
it("renders a BigInt field value as <n>n", () => {
// `JSON.stringify` throws on BigInt; safeStringify marshals
// it through a JSON replacer that emits the `10n` notation.
const error = expectFailure({
hooks: { h: { handler: (): void => {}, priority: 10n } },
});
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" has invalid priority "10n" (must be a finite number).`,
);
});
it("renders a function field value as 'function'", () => {
// `JSON.stringify(fn)` returns the JS value `undefined`;
// safeStringify falls back to `describeShape`.
const error = expectFailure({
hooks: { h: { handler: (): void => {}, priority: (): void => {} } },
});
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" has invalid priority function (must be a finite number).`,
);
});
it("renders a symbol field value as 'symbol'", () => {
const error = expectFailure({
hooks: { h: { handler: (): void => {}, priority: Symbol("x") } },
});
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" has invalid priority symbol (must be a finite number).`,
);
});
it("renders a cyclic-object field value as 'object'", () => {
// `JSON.stringify` throws on cyclic structures; safeStringify
// catches and falls back to `describeShape`.
const cycle: Record<string, unknown> = {};
cycle.self = cycle;
const error = expectFailure({
hooks: { h: { handler: (): void => {}, priority: cycle } },
});
expect(error.message).toBe(
`${PLUGIN_ENTRY}: hook "h" has invalid priority object (must be a finite number).`,
);
});
it("displays the whole array when a single element of dependencies is bad", () => {
// The issue path is `["hooks","h","dependencies",1]`; the
// displayed value is the field as a whole rather than the
// offending element. The Zod message names the actual fault.
const error = expectFailure({
hooks: {
h: { handler: (): void => {}, dependencies: ["a", 42, "c"] },
},
});
expect(error.message).toContain(`hook "h" has invalid dependencies[1] ["a",42,"c"]`);
expect(error.message).toContain("expected string");
});
it("surfaces exactly one issue when multiple fields are bad", () => {
const error = expectFailure({
hooks: {
h: { handler: (): void => {}, priority: "high", errorPolicy: "bogus" },
},
});
// Don't lock in *which* field surfaces first (Zod issue order
// is an implementation detail); enforce that only one does.
const matches = error.message.match(/has invalid /g);
expect(matches).toHaveLength(1);
expect(error.message).toMatch(/hook "h" has invalid (priority|errorPolicy)/);
});
});
describe("route validation errors", () => {
it("rejects a non-function/non-object route entry", () => {
const error = expectFailure({ routes: { ping: 42 } });
expect(error.message).toBe(
`${PLUGIN_ENTRY}: route "ping" must be a function or { handler: function, ... }. Got number.`,
);
});
it("rejects a route entry missing its handler", () => {
const error = expectFailure({ routes: { ping: { public: true } } });
expect(error.message).toBe(
`${PLUGIN_ENTRY}: route "ping" has invalid handler undefined (must be a function).`,
);
});
it("rejects a non-boolean public flag", () => {
const error = expectFailure({
routes: { ping: { handler: (): void => {}, public: "yes" } },
});
expect(error.message).toContain(`route "ping" has invalid public "yes"`);
});
});
describe("non-record collection coercion", () => {
// A malformed `hooks` / `routes` outer collection harvests no
// entries but doesn't fail the build. Entries themselves are
// still strictly validated.
it("treats an array hooks field as empty", () => {
const result = parseProbedDefault(PLUGIN_ENTRY, { hooks: [] });
expect(result.hooks).toEqual({});
});
it("treats a string hooks field as empty", () => {
const result = parseProbedDefault(PLUGIN_ENTRY, { hooks: "nope" });
expect(result.hooks).toEqual({});
});
it("treats a null hooks field as empty", () => {
const result = parseProbedDefault(PLUGIN_ENTRY, { hooks: null });
expect(result.hooks).toEqual({});
});
it("treats a string routes field as empty", () => {
const result = parseProbedDefault(PLUGIN_ENTRY, { routes: "all of them" });
expect(result.routes).toEqual({});
});
it("treats a number routes field as empty", () => {
const result = parseProbedDefault(PLUGIN_ENTRY, { routes: 42 });
expect(result.routes).toEqual({});
});
});
});