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
218 lines
7.2 KiB
TypeScript
218 lines
7.2 KiB
TypeScript
/**
|
|
* Coverage for the manifest loader. The loader's value-add over plain
|
|
* `JSON.parse + Zod.parse` is two-fold:
|
|
*
|
|
* 1. JSONC tolerance: trailing commas + comments are accepted (matches
|
|
* the wrangler.jsonc / tsconfig.json convention).
|
|
* 2. Source locations on validation errors: the error path is mapped
|
|
* back to a 1-indexed line:column so `emdash-plugin validate`
|
|
* points editors at the offending field.
|
|
*
|
|
* The tests below use `parseAndValidateManifest` (the in-memory variant)
|
|
* to avoid touching disk for the happy paths and Zod-fail paths. The
|
|
* filesystem entry point `loadManifest` is exercised separately for its
|
|
* directory-vs-file resolution and ENOENT shape.
|
|
*/
|
|
|
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import {
|
|
ManifestError,
|
|
loadManifest,
|
|
MANIFEST_FILENAME,
|
|
parseAndValidateManifest,
|
|
} from "../src/manifest/load.js";
|
|
|
|
const MINIMAL = `{
|
|
"slug": "my-plugin",
|
|
"version": "0.1.0",
|
|
"publisher": "example.com",
|
|
"license": "MIT",
|
|
"author": { "name": "Jane Doe" },
|
|
"security": { "email": "security@example.com" }
|
|
}`;
|
|
|
|
describe("parseAndValidateManifest (in-memory)", () => {
|
|
it("parses + validates a minimal manifest", () => {
|
|
const { manifest, path } = parseAndValidateManifest(MINIMAL, "/virtual/manifest.jsonc");
|
|
expect(path).toBe("/virtual/manifest.jsonc");
|
|
expect(manifest.license).toBe("MIT");
|
|
expect(manifest.author?.name).toBe("Jane Doe");
|
|
expect(manifest.security?.email).toBe("security@example.com");
|
|
});
|
|
|
|
it("accepts JSONC features (comments + trailing commas)", () => {
|
|
const source = `{
|
|
// top-level comment
|
|
"slug": "my-plugin",
|
|
"version": "0.1.0",
|
|
"publisher": "example.com",
|
|
"license": "MIT", /* block comment */
|
|
"author": { "name": "Jane Doe", },
|
|
"security": { "email": "security@example.com", },
|
|
}`;
|
|
const { manifest } = parseAndValidateManifest(source, "/virtual/manifest.jsonc");
|
|
expect(manifest.license).toBe("MIT");
|
|
});
|
|
|
|
describe("MANIFEST_PARSE_ERROR", () => {
|
|
it("reports a missing closing brace with line:column", () => {
|
|
const source = `{
|
|
"license": "MIT"
|
|
"author": { "name": "x" }
|
|
`;
|
|
expect(() => parseAndValidateManifest(source, "/v/m.jsonc")).toThrow(ManifestError);
|
|
try {
|
|
parseAndValidateManifest(source, "/v/m.jsonc");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ManifestError);
|
|
const err = error as ManifestError;
|
|
expect(err.code).toBe("MANIFEST_PARSE_ERROR");
|
|
// Error message includes the file:line:col pointer.
|
|
expect(err.message).toMatch(/\/v\/m\.jsonc:\d+:\d+/);
|
|
}
|
|
});
|
|
|
|
it("rejects an empty file", () => {
|
|
expect(() => parseAndValidateManifest("", "/v/m.jsonc")).toThrow(ManifestError);
|
|
});
|
|
});
|
|
|
|
describe("MANIFEST_VALIDATION_ERROR", () => {
|
|
it("reports the field path", () => {
|
|
const source = `{
|
|
"license": "",
|
|
"author": { "name": "Jane" },
|
|
"security": { "email": "a@b.com" }
|
|
}`;
|
|
try {
|
|
parseAndValidateManifest(source, "/v/m.jsonc");
|
|
expect.fail("expected ManifestError");
|
|
} catch (error) {
|
|
const err = error as ManifestError;
|
|
expect(err.code).toBe("MANIFEST_VALIDATION_ERROR");
|
|
const license = err.issues.find((i) => i.path === "license");
|
|
expect(license).toBeDefined();
|
|
// Source location points at the offending VALUE (the empty
|
|
// string on line 2), not the key. We don't pin the exact
|
|
// column because Zod can emit slightly different paths
|
|
// across versions; the line number is enough to confirm
|
|
// the mapping works.
|
|
expect(license?.location?.line).toBe(2);
|
|
}
|
|
});
|
|
|
|
it("collects multiple issues in one error", () => {
|
|
const source = `{
|
|
"license": "",
|
|
"author": { "name": "" },
|
|
"security": {}
|
|
}`;
|
|
try {
|
|
parseAndValidateManifest(source, "/v/m.jsonc");
|
|
expect.fail("expected ManifestError");
|
|
} catch (error) {
|
|
const err = error as ManifestError;
|
|
expect(err.issues.length).toBeGreaterThanOrEqual(3);
|
|
// Every issue must have a path and a message.
|
|
for (const issue of err.issues) {
|
|
expect(typeof issue.path).toBe("string");
|
|
expect(typeof issue.message).toBe("string");
|
|
}
|
|
}
|
|
});
|
|
|
|
it("rejects unknown top-level keys with strict mode", () => {
|
|
const source = `{
|
|
"license": "MIT",
|
|
"licens": "MIT",
|
|
"author": { "name": "Jane" },
|
|
"security": { "email": "a@b.com" }
|
|
}`;
|
|
try {
|
|
parseAndValidateManifest(source, "/v/m.jsonc");
|
|
expect.fail("expected ManifestError");
|
|
} catch (error) {
|
|
const err = error as ManifestError;
|
|
expect(err.code).toBe("MANIFEST_VALIDATION_ERROR");
|
|
// The line:col must point at the typo'd key, not at the
|
|
// parent object's opening brace. Typos are the most
|
|
// common error class; landing on the right line matters.
|
|
// Regression: previously this returned undefined because
|
|
// `findNodeAtPath` couldn't resolve a key that didn't
|
|
// exist in the schema.
|
|
const typoIssue = err.issues.find((i) => i.message.includes('"licens"'));
|
|
expect(typoIssue).toBeDefined();
|
|
expect(typoIssue?.location?.line).toBe(3);
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("loadManifest (filesystem)", () => {
|
|
let dir: string;
|
|
|
|
beforeAll(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), "emdash-manifest-test-"));
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("loads from a directory by appending the conventional filename", async () => {
|
|
await writeFile(join(dir, MANIFEST_FILENAME), MINIMAL, "utf8");
|
|
const { manifest, path } = await loadManifest(dir);
|
|
expect(path.endsWith(MANIFEST_FILENAME)).toBe(true);
|
|
expect(manifest.license).toBe("MIT");
|
|
});
|
|
|
|
it("loads from an explicit .jsonc path", async () => {
|
|
const filePath = join(dir, "explicit.jsonc");
|
|
await writeFile(filePath, MINIMAL, "utf8");
|
|
const { manifest, path } = await loadManifest(filePath);
|
|
expect(path).toBe(filePath);
|
|
expect(manifest.license).toBe("MIT");
|
|
});
|
|
|
|
it("loads from an explicit .json path", async () => {
|
|
const filePath = join(dir, "explicit.json");
|
|
await writeFile(filePath, MINIMAL, "utf8");
|
|
const { manifest } = await loadManifest(filePath);
|
|
expect(manifest.license).toBe("MIT");
|
|
});
|
|
|
|
it("returns MANIFEST_NOT_FOUND when the file is missing", async () => {
|
|
const missing = join(dir, "no-such-manifest.jsonc");
|
|
try {
|
|
await loadManifest(missing);
|
|
expect.fail("expected ManifestError");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ManifestError);
|
|
expect((error as ManifestError).code).toBe("MANIFEST_NOT_FOUND");
|
|
}
|
|
});
|
|
|
|
it("returns MANIFEST_TOO_LARGE when the file exceeds the cap", async () => {
|
|
// Build a file just over the 1 MiB cap. Filler is JSONC-friendly
|
|
// (a long string value) so the bytes can't be misread as a
|
|
// syntactic short-circuit.
|
|
const { MANIFEST_MAX_BYTES } = await import("../src/manifest/load.js");
|
|
const filler = "x".repeat(MANIFEST_MAX_BYTES);
|
|
const oversize = `{ "license": "${filler}", "author": {"name":"J"}, "security": {"email":"a@b.com"} }`;
|
|
const filePath = join(dir, "oversize.jsonc");
|
|
await writeFile(filePath, oversize, "utf8");
|
|
try {
|
|
await loadManifest(filePath);
|
|
expect.fail("expected ManifestError");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(ManifestError);
|
|
expect((error as ManifestError).code).toBe("MANIFEST_TOO_LARGE");
|
|
}
|
|
});
|
|
});
|