chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { ResolvedPlugin } from "../src/bundle/types.js";
|
||||
import {
|
||||
collectBundleEntries,
|
||||
extractManifest,
|
||||
findNodeBuiltinImports,
|
||||
findSourceExports,
|
||||
formatBytes,
|
||||
MAX_FILE_COUNT,
|
||||
MAX_FILE_SIZE,
|
||||
totalBundleBytes,
|
||||
validateBundleSize,
|
||||
} from "../src/bundle/utils.js";
|
||||
|
||||
const minimalResolved = (overrides: Partial<ResolvedPlugin> = {}): ResolvedPlugin => ({
|
||||
id: "test-plugin",
|
||||
version: "0.1.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: {},
|
||||
routes: {},
|
||||
admin: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("extractManifest", () => {
|
||||
it("emits plain hook names when metadata is at defaults", () => {
|
||||
const manifest = extractManifest(
|
||||
minimalResolved({
|
||||
hooks: {
|
||||
"content:beforeCreate": {
|
||||
handler: () => {},
|
||||
priority: 100,
|
||||
timeout: 5000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(manifest.hooks).toEqual(["content:beforeCreate"]);
|
||||
});
|
||||
|
||||
it("emits structured hook entries when metadata differs from defaults", () => {
|
||||
const manifest = extractManifest(
|
||||
minimalResolved({
|
||||
hooks: {
|
||||
"email:deliver": {
|
||||
handler: () => {},
|
||||
priority: 50,
|
||||
timeout: 30_000,
|
||||
exclusive: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(manifest.hooks).toEqual([
|
||||
{ name: "email:deliver", exclusive: true, priority: 50, timeout: 30_000 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves the route name list", () => {
|
||||
const manifest = extractManifest(
|
||||
minimalResolved({
|
||||
routes: { admin: { handler: () => {} }, api: { handler: () => {} } },
|
||||
}),
|
||||
);
|
||||
expect(manifest.routes.toSorted((a, b) => a.localeCompare(b))).toEqual(["admin", "api"]);
|
||||
});
|
||||
|
||||
it("strips the runtime entry pointer from admin", () => {
|
||||
const manifest = extractManifest(
|
||||
minimalResolved({
|
||||
admin: { entry: "@emdash-cms/some/admin", pages: [{ path: "/x" }] },
|
||||
}),
|
||||
);
|
||||
expect(manifest.admin).not.toHaveProperty("entry");
|
||||
expect(manifest.admin.pages).toEqual([{ path: "/x" }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findNodeBuiltinImports", () => {
|
||||
it("flags require('node:fs')", () => {
|
||||
expect(findNodeBuiltinImports(`require("node:fs")`)).toEqual(["fs"]);
|
||||
});
|
||||
|
||||
it("flags require('crypto') without the node: prefix", () => {
|
||||
expect(findNodeBuiltinImports(`require('crypto')`)).toEqual(["crypto"]);
|
||||
});
|
||||
|
||||
it("flags ESM `import { promises } from 'node:fs'`", () => {
|
||||
expect(findNodeBuiltinImports(`import { promises } from "node:fs/promises";`)).toEqual(["fs"]);
|
||||
});
|
||||
|
||||
it("flags dynamic `await import('node:child_process')`", () => {
|
||||
expect(findNodeBuiltinImports(`await import("node:child_process")`)).toEqual(["child_process"]);
|
||||
});
|
||||
|
||||
it("does not flag user-land package names that share a name with a builtin substring", () => {
|
||||
// "events-utils" is not the "events" builtin
|
||||
expect(findNodeBuiltinImports(`require("events-utils")`)).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not flag whitelisted globals like 'astro' or 'react'", () => {
|
||||
expect(findNodeBuiltinImports(`import x from "astro"`)).toEqual([]);
|
||||
expect(findNodeBuiltinImports(`import x from "react"`)).toEqual([]);
|
||||
});
|
||||
|
||||
it("deduplicates repeated imports of the same builtin", () => {
|
||||
expect(
|
||||
findNodeBuiltinImports(`require("node:fs"); require("fs"); import "node:fs/promises";`),
|
||||
).toEqual(["fs"]);
|
||||
});
|
||||
|
||||
it("returns empty for builtin-free code", () => {
|
||||
expect(findNodeBuiltinImports(`const x = 1; export default x;`)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findSourceExports", () => {
|
||||
it("flags string exports pointing at TypeScript source", () => {
|
||||
const issues = findSourceExports({
|
||||
".": "./src/index.ts",
|
||||
"./util": "./src/util.tsx",
|
||||
});
|
||||
expect(issues).toEqual([
|
||||
{ exportPath: ".", resolvedPath: "./src/index.ts" },
|
||||
{ exportPath: "./util", resolvedPath: "./src/util.tsx" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("flags conditional exports whose `import` resolves to source", () => {
|
||||
const issues = findSourceExports({
|
||||
".": { import: "./src/index.ts", types: "./dist/index.d.ts" },
|
||||
});
|
||||
expect(issues).toEqual([{ exportPath: ".", resolvedPath: "./src/index.ts" }]);
|
||||
});
|
||||
|
||||
it("does not flag exports pointing at built `.mjs` / `.js` / `.cjs`", () => {
|
||||
expect(
|
||||
findSourceExports({
|
||||
".": "./dist/index.mjs",
|
||||
"./util": "./dist/util.js",
|
||||
"./cjs": "./dist/util.cjs",
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores non-string, non-import exports", () => {
|
||||
expect(
|
||||
findSourceExports({
|
||||
".": { types: "./dist/index.d.ts" }, // no `import` field
|
||||
"./empty": null,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateBundleSize", () => {
|
||||
it("accepts an empty bundle (boundary: zero files)", () => {
|
||||
expect(validateBundleSize([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts a bundle that sits exactly at every cap", () => {
|
||||
// 19 small files + one file at the per-file cap. Total stays under
|
||||
// MAX_BUNDLE_SIZE because per-file cap × file count is much larger
|
||||
// than the bundle cap; we deliberately undershoot the total.
|
||||
const entries = [
|
||||
{ name: "backend.js", bytes: MAX_FILE_SIZE },
|
||||
...Array.from({ length: MAX_FILE_COUNT - 1 }, (_, i) => ({
|
||||
name: `extra-${i}.js`,
|
||||
bytes: 100,
|
||||
})),
|
||||
];
|
||||
expect(validateBundleSize(entries)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags total bundle size when it exceeds MAX_BUNDLE_SIZE without tripping per-file cap", () => {
|
||||
// Three files at exactly MAX_FILE_SIZE each — per-file cap is satisfied
|
||||
// (`>` not `>=`), but the sum (3 × 128 KB = 384 KB) exceeds the 256 KB
|
||||
// total cap. Isolates the total-size assertion from per-file noise.
|
||||
const entries = [
|
||||
{ name: "a.js", bytes: MAX_FILE_SIZE },
|
||||
{ name: "b.js", bytes: MAX_FILE_SIZE },
|
||||
{ name: "c.js", bytes: MAX_FILE_SIZE },
|
||||
];
|
||||
const violations = validateBundleSize(entries);
|
||||
expect(violations).toHaveLength(1);
|
||||
expect(violations[0]).toMatch(/Bundle size .* exceeds maximum of/);
|
||||
});
|
||||
|
||||
it("does not flag a bundle exactly at MAX_BUNDLE_SIZE", () => {
|
||||
// Use MAX_FILE_SIZE-bounded files so per-file cap is satisfied too.
|
||||
// MAX_BUNDLE_SIZE / MAX_FILE_SIZE = 256/128 = 2 files.
|
||||
const entries = [
|
||||
{ name: "a.js", bytes: MAX_FILE_SIZE },
|
||||
{ name: "b.js", bytes: MAX_FILE_SIZE },
|
||||
];
|
||||
expect(validateBundleSize(entries)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags file count when it exceeds MAX_FILE_COUNT", () => {
|
||||
const entries = Array.from({ length: MAX_FILE_COUNT + 1 }, (_, i) => ({
|
||||
name: `f-${i}.js`,
|
||||
bytes: 10,
|
||||
}));
|
||||
const violations = validateBundleSize(entries);
|
||||
expect(violations).toContainEqual(
|
||||
expect.stringMatching(new RegExp(`contains ${MAX_FILE_COUNT + 1} files`)),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags every oversized file individually", () => {
|
||||
const entries = [
|
||||
{ name: "ok.js", bytes: 100 },
|
||||
{ name: "huge-b.js", bytes: MAX_FILE_SIZE + 1 },
|
||||
{ name: "huge-a.js", bytes: MAX_FILE_SIZE + 2 },
|
||||
];
|
||||
const violations = validateBundleSize(entries);
|
||||
const fileViolations = violations.filter((v) => v.startsWith("File "));
|
||||
expect(fileViolations).toHaveLength(2);
|
||||
// Alphabetical ordering — huge-a before huge-b — keeps error text
|
||||
// deterministic for the same bundle.
|
||||
expect(fileViolations[0]).toMatch(/File huge-a\.js/);
|
||||
expect(fileViolations[1]).toMatch(/File huge-b\.js/);
|
||||
});
|
||||
|
||||
it("reports total, count, and per-file violations together when all three trip", () => {
|
||||
const entries = Array.from({ length: MAX_FILE_COUNT + 5 }, (_, i) => ({
|
||||
name: `chunk-${String(i).padStart(2, "0")}.js`,
|
||||
bytes: MAX_FILE_SIZE + 1,
|
||||
}));
|
||||
const violations = validateBundleSize(entries);
|
||||
// One total-size violation, one file-count violation, one per oversized file.
|
||||
expect(violations[0]).toMatch(/Bundle size/);
|
||||
expect(violations[1]).toMatch(/contains/);
|
||||
expect(violations.length).toBe(2 + entries.length);
|
||||
});
|
||||
|
||||
it("returns the same violation list when called twice with the same input", () => {
|
||||
const entries = [
|
||||
{ name: "z.js", bytes: MAX_FILE_SIZE + 1 },
|
||||
{ name: "a.js", bytes: MAX_FILE_SIZE + 1 },
|
||||
];
|
||||
expect(validateBundleSize(entries)).toEqual(validateBundleSize(entries));
|
||||
});
|
||||
});
|
||||
|
||||
describe("totalBundleBytes", () => {
|
||||
it("returns 0 for an empty list", () => {
|
||||
expect(totalBundleBytes([])).toBe(0);
|
||||
});
|
||||
|
||||
it("sums the byte sizes of all entries", () => {
|
||||
expect(
|
||||
totalBundleBytes([
|
||||
{ name: "a", bytes: 10 },
|
||||
{ name: "b", bytes: 20 },
|
||||
{ name: "c", bytes: 30 },
|
||||
]),
|
||||
).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectBundleEntries", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "emdash-collect-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns an empty list for an empty directory", async () => {
|
||||
expect(await collectBundleEntries(dir)).toEqual([]);
|
||||
});
|
||||
|
||||
it("flattens nested directories with forward-slash relative paths", async () => {
|
||||
await writeFile(join(dir, "manifest.json"), "{}");
|
||||
await mkdir(join(dir, "screenshots"));
|
||||
await writeFile(join(dir, "screenshots", "one.png"), "x");
|
||||
await writeFile(join(dir, "screenshots", "two.png"), "yz");
|
||||
|
||||
const entries = await collectBundleEntries(dir);
|
||||
const byName = Object.fromEntries(entries.map((e) => [e.name, e.bytes]));
|
||||
expect(byName).toEqual({
|
||||
"manifest.json": 2,
|
||||
"screenshots/one.png": 1,
|
||||
"screenshots/two.png": 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("integrates with validateBundleSize for end-to-end cap enforcement", async () => {
|
||||
await writeFile(join(dir, "backend.js"), "x".repeat(MAX_FILE_SIZE + 1));
|
||||
const violations = validateBundleSize(await collectBundleEntries(dir));
|
||||
expect(violations).toContainEqual(expect.stringMatching(/File backend\.js is/));
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBytes", () => {
|
||||
it("renders bytes under 1 KB with the B suffix", () => {
|
||||
expect(formatBytes(0)).toBe("0 B");
|
||||
expect(formatBytes(1023)).toBe("1023 B");
|
||||
});
|
||||
|
||||
it("renders KB at one decimal place from 1 KB through just under 1 MB", () => {
|
||||
expect(formatBytes(1024)).toBe("1.0 KB");
|
||||
expect(formatBytes(256 * 1024)).toBe("256.0 KB");
|
||||
});
|
||||
|
||||
it("renders MB at two decimal places at or above 1 MB", () => {
|
||||
expect(formatBytes(1024 * 1024)).toBe("1.00 MB");
|
||||
expect(formatBytes(5 * 1024 * 1024)).toBe("5.00 MB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, rmdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { BundleError, bundlePlugin, type PluginManifest } from "../src/api.js";
|
||||
import {
|
||||
probeAndAssemble,
|
||||
type ProbeAndAssembleContext,
|
||||
type ResolvedSources,
|
||||
} from "../src/build/pipeline.js";
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL("./fixtures/minimal-plugin", import.meta.url));
|
||||
const BAD_FIXTURE = fileURLToPath(new URL("./fixtures/bad-plugin", import.meta.url));
|
||||
|
||||
/**
|
||||
* End-to-end bundling: invoke `bundlePlugin` against a real plugin source
|
||||
* directory, assert the resulting tarball + manifest match expectations.
|
||||
*
|
||||
* Each test runs the bundler at a different `outDir` under a fresh tempdir so
|
||||
* concurrent runs don't collide, and so `--out-dir` resolution works as
|
||||
* advertised (it can be either absolute or relative to `dir`).
|
||||
*/
|
||||
describe("bundlePlugin", () => {
|
||||
let outDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
outDir = await mkdtemp(join(tmpdir(), "emdash-bundle-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("produces a tarball + manifest for a minimal valid plugin", async () => {
|
||||
const result = await bundlePlugin({ dir: FIXTURE, outDir });
|
||||
|
||||
expect(result.manifest.id).toBe("fixture-minimal");
|
||||
expect(result.manifest.version).toBe("1.2.3");
|
||||
expect(result.manifest.capabilities).toEqual(["content:read"]);
|
||||
expect(result.manifest.allowedHosts).toEqual(["api.example.com"]);
|
||||
expect(result.tarballPath).not.toBeNull();
|
||||
expect(result.tarballPath).toMatch(/fixture-minimal-1\.2\.3\.tar\.gz$/);
|
||||
expect(result.tarballBytes).toBeGreaterThan(0);
|
||||
expect(result.sha256).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("captures hooks and routes from the src/plugin.ts probe", async () => {
|
||||
const result = await bundlePlugin({ dir: FIXTURE, outDir });
|
||||
const manifest = result.manifest;
|
||||
|
||||
// Plain hook name (defaults).
|
||||
expect(manifest.hooks).toContain("content:beforeSave");
|
||||
// Routes are extracted from src/plugin.ts's default export.
|
||||
expect(manifest.routes).toContain("admin");
|
||||
});
|
||||
|
||||
it("validateOnly returns the manifest but writes no tarball", async () => {
|
||||
const result = await bundlePlugin({
|
||||
dir: FIXTURE,
|
||||
outDir,
|
||||
validateOnly: true,
|
||||
});
|
||||
expect(result.manifest.id).toBe("fixture-minimal");
|
||||
expect(result.tarballPath).toBeNull();
|
||||
expect(result.tarballBytes).toBeNull();
|
||||
expect(result.sha256).toBeNull();
|
||||
});
|
||||
|
||||
it("the tarball contains manifest.json + backend.js with the expected manifest body", async () => {
|
||||
const result = await bundlePlugin({ dir: FIXTURE, outDir });
|
||||
expect(result.tarballPath).not.toBeNull();
|
||||
const tarballBytes = await readFile(result.tarballPath!);
|
||||
|
||||
const { unpackTar, createGzipDecoder } = await import("modern-tar");
|
||||
const source = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(tarballBytes);
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const decoded = source.pipeThrough(createGzipDecoder()) as ReadableStream<Uint8Array>;
|
||||
const entries = await unpackTar(decoded);
|
||||
|
||||
const names = entries.map((e) => e.header.name).toSorted();
|
||||
expect(names).toContain("manifest.json");
|
||||
expect(names).toContain("backend.js");
|
||||
|
||||
const manifestEntry = entries.find((e) => e.header.name === "manifest.json");
|
||||
expect(manifestEntry?.data).toBeDefined();
|
||||
const parsed = JSON.parse(new TextDecoder().decode(manifestEntry!.data!)) as PluginManifest;
|
||||
expect(parsed.id).toBe("fixture-minimal");
|
||||
expect(parsed.version).toBe("1.2.3");
|
||||
});
|
||||
|
||||
it("throws BundleError(MISSING_MANIFEST) for a directory with no emdash-plugin.jsonc", async () => {
|
||||
const empty = await mkdtemp(join(tmpdir(), "emdash-empty-"));
|
||||
try {
|
||||
await expect(bundlePlugin({ dir: empty, outDir })).rejects.toMatchObject({
|
||||
name: "BundleError",
|
||||
code: "MISSING_MANIFEST",
|
||||
});
|
||||
} finally {
|
||||
await rm(empty, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("BundleError instances are structurally identifiable", async () => {
|
||||
const empty = await mkdtemp(join(tmpdir(), "emdash-empty-"));
|
||||
try {
|
||||
let caught: unknown;
|
||||
try {
|
||||
await bundlePlugin({ dir: empty, outDir });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(BundleError);
|
||||
expect((caught as BundleError).code).toBe("MISSING_MANIFEST");
|
||||
expect((caught as BundleError).message).toMatch(/No emdash-plugin\.jsonc/);
|
||||
} finally {
|
||||
await rm(empty, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards progress messages through the optional logger", async () => {
|
||||
const messages: Array<{ kind: string; msg: string }> = [];
|
||||
await bundlePlugin({
|
||||
dir: FIXTURE,
|
||||
outDir,
|
||||
logger: {
|
||||
start: (m) => messages.push({ kind: "start", msg: m }),
|
||||
info: (m) => messages.push({ kind: "info", msg: m }),
|
||||
success: (m) => messages.push({ kind: "success", msg: m }),
|
||||
warn: (m) => messages.push({ kind: "warn", msg: m }),
|
||||
},
|
||||
});
|
||||
|
||||
// Spot-check: bundle starts with "Bundling plugin..." and ends with a
|
||||
// "Created ..." success line. Don't pin every intermediate step --
|
||||
// they're implementation detail.
|
||||
expect(messages[0]).toMatchObject({ kind: "start", msg: /Bundling/ });
|
||||
expect(messages.some((m) => m.kind === "success" && /Created/.test(m.msg))).toBe(true);
|
||||
});
|
||||
|
||||
it("validateOnly bundles never write the tarball even if outDir exists", async () => {
|
||||
// validateOnly skips tarball creation but still produces the
|
||||
// build artifacts in dist/. Tarball-specific checks (`.tar.gz`
|
||||
// presence) are what we're asserting here, not "dist is empty".
|
||||
const result = await bundlePlugin({
|
||||
dir: FIXTURE,
|
||||
outDir,
|
||||
validateOnly: true,
|
||||
});
|
||||
expect(result.tarballPath).toBeNull();
|
||||
expect(result.tarballBytes).toBeNull();
|
||||
expect(result.sha256).toBeNull();
|
||||
|
||||
const fs = await import("node:fs/promises");
|
||||
const contents = await fs.readdir(outDir);
|
||||
// Dist artifacts ARE expected (build runs unconditionally), but no
|
||||
// tarball.
|
||||
expect(contents.some((f) => f.endsWith(".tar.gz"))).toBe(false);
|
||||
});
|
||||
|
||||
it("hard-fails when the plugin has a manifest but no src/plugin.ts", async () => {
|
||||
// The bad-plugin fixture has a valid emdash-plugin.jsonc but no
|
||||
// src/plugin.ts. Without the guard, the bundler would happily
|
||||
// produce a tarball with no backend.js, leaving the runtime with
|
||||
// nothing to load.
|
||||
await expect(bundlePlugin({ dir: BAD_FIXTURE, outDir })).rejects.toMatchObject({
|
||||
name: "BundleError",
|
||||
code: "MISSING_PLUGIN_ENTRY",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not collide between concurrent bundle runs", async () => {
|
||||
// Each bundle invocation gets its own mkdtemp dir; running two in
|
||||
// parallel must not corrupt each other.
|
||||
const [a, b] = await Promise.all([
|
||||
bundlePlugin({ dir: FIXTURE, outDir, validateOnly: true }),
|
||||
bundlePlugin({ dir: FIXTURE, outDir, validateOnly: true }),
|
||||
]);
|
||||
expect(a.manifest.id).toBe("fixture-minimal");
|
||||
expect(b.manifest.id).toBe("fixture-minimal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeAndAssemble", () => {
|
||||
it("imports drive-letter probe artifact paths through file URLs", async () => {
|
||||
const tmpDir = await createDriveLetterTmpDir();
|
||||
try {
|
||||
const entries = makeResolvedSources(tmpDir);
|
||||
const build: ProbeAndAssembleContext["build"] = async (options) => {
|
||||
if (typeof options?.outDir !== "string") {
|
||||
throw new Error("Expected probe build to receive an outDir");
|
||||
}
|
||||
await mkdir(options.outDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(options.outDir, "plugin.mjs"),
|
||||
"export default { hooks: {}, routes: {} };\n",
|
||||
);
|
||||
return [];
|
||||
};
|
||||
|
||||
const result = await probeAndAssemble({ entries, tmpDir, build });
|
||||
|
||||
expect(result.id).toBe("fixture-minimal");
|
||||
expect(result.hooks).toEqual({});
|
||||
expect(result.routes).toEqual({});
|
||||
} finally {
|
||||
await removeDriveLetterTmpDir(tmpDir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function createDriveLetterTmpDir(): Promise<string> {
|
||||
if (process.platform === "win32") {
|
||||
return mkdtemp(join(tmpdir(), "emdash-probe-"));
|
||||
}
|
||||
|
||||
const driveRoot = "Z:";
|
||||
const base = join(driveRoot, `emdash-probe-${process.pid}-${Date.now()}`);
|
||||
await mkdir(base, { recursive: true });
|
||||
return mkdtemp(join(base, "tmp-"));
|
||||
}
|
||||
|
||||
async function removeDriveLetterTmpDir(tmpDir: string): Promise<void> {
|
||||
if (process.platform === "win32") {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await rm(join(tmpDir, ".."), { recursive: true, force: true });
|
||||
await rmdir("Z:").catch(() => {});
|
||||
}
|
||||
|
||||
function makeResolvedSources(tmpDir: string): ResolvedSources {
|
||||
return {
|
||||
pluginDir: tmpDir,
|
||||
pluginEntry: join(tmpDir, "src", "plugin.ts"),
|
||||
manifest: {
|
||||
slug: "fixture-minimal",
|
||||
version: "1.2.3",
|
||||
publisher: "did:plc:fixture",
|
||||
license: "MIT",
|
||||
authors: [{ name: "Fixture Author" }],
|
||||
securityContacts: [{ email: "security@example.com" }],
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
keywords: undefined,
|
||||
repo: undefined,
|
||||
requires: undefined,
|
||||
artifacts: undefined,
|
||||
capabilities: ["content:read"],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: { pages: [], widgets: [] },
|
||||
},
|
||||
manifestPath: join(tmpDir, "emdash-plugin.jsonc"),
|
||||
packageName: "fixture-minimal",
|
||||
hasPackageJson: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { DEFAULT_AGGREGATOR_URL, resolveAggregatorUrl } from "../src/config.js";
|
||||
|
||||
const ORIGINAL_ENV = process.env["EMDASH_REGISTRY_URL"];
|
||||
|
||||
describe("resolveAggregatorUrl", () => {
|
||||
afterEach(() => {
|
||||
// Restore the env var if a test mutated it.
|
||||
if (ORIGINAL_ENV === undefined) {
|
||||
delete process.env["EMDASH_REGISTRY_URL"];
|
||||
} else {
|
||||
process.env["EMDASH_REGISTRY_URL"] = ORIGINAL_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers an explicit flag over the env var and default", () => {
|
||||
process.env["EMDASH_REGISTRY_URL"] = "https://from-env.test";
|
||||
expect(resolveAggregatorUrl("https://from-flag.test")).toBe("https://from-flag.test");
|
||||
});
|
||||
|
||||
it("falls back to the env var when no flag is given", () => {
|
||||
process.env["EMDASH_REGISTRY_URL"] = "https://from-env.test";
|
||||
expect(resolveAggregatorUrl()).toBe("https://from-env.test");
|
||||
expect(resolveAggregatorUrl("")).toBe("https://from-env.test");
|
||||
});
|
||||
|
||||
it("falls back to the experimental default when neither flag nor env var is set", () => {
|
||||
delete process.env["EMDASH_REGISTRY_URL"];
|
||||
expect(resolveAggregatorUrl()).toBe(DEFAULT_AGGREGATOR_URL);
|
||||
});
|
||||
|
||||
it("treats an empty env var as unset", () => {
|
||||
process.env["EMDASH_REGISTRY_URL"] = "";
|
||||
expect(resolveAggregatorUrl()).toBe(DEFAULT_AGGREGATOR_URL);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"slug": "bad-plugin",
|
||||
"version": "0.1.0",
|
||||
"publisher": "fixture.example.com",
|
||||
"license": "MIT",
|
||||
"author": { "name": "Test Author" },
|
||||
"security": { "email": "security@example.com" },
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "fixture-bad-plugin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"slug": "fixture-minimal",
|
||||
"publisher": "fixture.example.com",
|
||||
"license": "MIT",
|
||||
"author": { "name": "Test Author" },
|
||||
"security": { "email": "security@example.com" },
|
||||
"capabilities": ["content:read"],
|
||||
"allowedHosts": ["api.example.com"],
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "fixture-minimal-plugin",
|
||||
"version": "1.2.3",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Test fixture: minimal sandbox entry. Exports a default object with hooks
|
||||
* and routes so the bundler's probe captures shape into the manifest.
|
||||
*
|
||||
* Uses the new authoring shape: bare default export with a
|
||||
* `satisfies SandboxedPlugin` annotation. The import is type-only, so
|
||||
* the bundler erases it — no runtime resolution of `emdash/plugin`
|
||||
* needed.
|
||||
*/
|
||||
import type { SandboxedPlugin } from "emdash/plugin";
|
||||
|
||||
// `content:beforeCreate` isn't in the strict mapped type, so use the
|
||||
// canonical content hook name. Test assertions also expect `content:beforeSave`
|
||||
// via the runtime hook vocabulary.
|
||||
export default {
|
||||
hooks: {
|
||||
"content:beforeSave": (event) => Promise.resolve(event.content),
|
||||
},
|
||||
routes: {
|
||||
admin: () => Promise.resolve(new Response("ok")),
|
||||
},
|
||||
} satisfies SandboxedPlugin;
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Coverage for the init scaffolder's environment probe.
|
||||
*
|
||||
* The probe reads three external sources: git config, git remote, and
|
||||
* package.json. Tests focus on the parts we control directly — repo
|
||||
* URL normalisation and package.json field extraction. The git-config
|
||||
* and git-remote subprocess calls are tested indirectly via `init`
|
||||
* integration tests; mocking child_process gets brittle fast and the
|
||||
* subprocess wrapper is a thin layer that doesn't have much to fail.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { probeEnvironment } from "../src/init/environment.js";
|
||||
|
||||
describe("probeEnvironment — package.json extraction", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "emdash-env-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns all-undefined for a directory with no package.json", async () => {
|
||||
const env = await probeEnvironment(dir);
|
||||
expect(env.license).toBeUndefined();
|
||||
expect(env.description).toBeUndefined();
|
||||
// Note: authorName / authorEmail may be set from the user's
|
||||
// global git config; we don't assert on them here. The package
|
||||
// extraction is the focus.
|
||||
});
|
||||
|
||||
it("reads license, description, and repository from package.json", async () => {
|
||||
await writeFile(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "test",
|
||||
license: "Apache-2.0",
|
||||
description: "A test plugin",
|
||||
repository: "https://github.com/example/test",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const env = await probeEnvironment(dir);
|
||||
expect(env.license).toBe("Apache-2.0");
|
||||
expect(env.description).toBe("A test plugin");
|
||||
expect(env.repo).toBe("https://github.com/example/test");
|
||||
});
|
||||
|
||||
it("normalizes git@github.com SSH URLs to https", async () => {
|
||||
await writeFile(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "test",
|
||||
repository: { type: "git", url: "git+ssh://git@github.com:example/test.git" },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
// SSH URL with the `ssh://` scheme prefix isn't the shape our
|
||||
// normaliser handles — the user-facing common case is
|
||||
// `git@host:path`, not `ssh://git@host/path`. The probe
|
||||
// returns undefined; the prompt's fallback chain handles it.
|
||||
const env = await probeEnvironment(dir);
|
||||
expect(env.repo).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips the .git suffix and the git+ prefix from package.json#repository.url", async () => {
|
||||
await writeFile(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "test",
|
||||
repository: { type: "git", url: "git+https://github.com/example/test.git" },
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const env = await probeEnvironment(dir);
|
||||
expect(env.repo).toBe("https://github.com/example/test");
|
||||
});
|
||||
|
||||
it("treats a malformed package.json as missing", async () => {
|
||||
await writeFile(join(dir, "package.json"), "{ not valid json", "utf8");
|
||||
const env = await probeEnvironment(dir);
|
||||
expect(env.license).toBeUndefined();
|
||||
expect(env.description).toBeUndefined();
|
||||
expect(env.repo).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats a non-object package.json as missing", async () => {
|
||||
// Valid JSON but not an object — pathological but possible.
|
||||
await writeFile(join(dir, "package.json"), "[]", "utf8");
|
||||
const env = await probeEnvironment(dir);
|
||||
expect(env.license).toBeUndefined();
|
||||
expect(env.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores oversized package.json (defence against weird files)", async () => {
|
||||
// Build a 100 KiB file. The cap is 64 KiB; the probe should
|
||||
// skip rather than buffer the whole thing.
|
||||
const fluff = " ".repeat(100 * 1024);
|
||||
await writeFile(
|
||||
join(dir, "package.json"),
|
||||
`{ "license": "MIT", "_fluff": "${fluff}" }`,
|
||||
"utf8",
|
||||
);
|
||||
const env = await probeEnvironment(dir);
|
||||
expect(env.license).toBeUndefined();
|
||||
});
|
||||
|
||||
it("treats empty-string license / description as undefined", async () => {
|
||||
await writeFile(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({ name: "test", license: "", description: " " }),
|
||||
"utf8",
|
||||
);
|
||||
const env = await probeEnvironment(dir);
|
||||
expect(env.license).toBeUndefined();
|
||||
expect(env.description).toBeUndefined();
|
||||
});
|
||||
|
||||
it("trims whitespace from license / description values", async () => {
|
||||
await writeFile(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({ name: "test", license: " MIT ", description: " hello " }),
|
||||
"utf8",
|
||||
);
|
||||
const env = await probeEnvironment(dir);
|
||||
expect(env.license).toBe("MIT");
|
||||
expect(env.description).toBe("hello");
|
||||
});
|
||||
|
||||
it("accepts repository as a bare string", async () => {
|
||||
await writeFile(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({ name: "test", repository: "https://github.com/example/test.git" }),
|
||||
"utf8",
|
||||
);
|
||||
const env = await probeEnvironment(dir);
|
||||
expect(env.repo).toBe("https://github.com/example/test");
|
||||
});
|
||||
|
||||
it("returns undefined for unrecognised repository shapes", async () => {
|
||||
await writeFile(
|
||||
join(dir, "package.json"),
|
||||
JSON.stringify({ name: "test", repository: { type: "git" /* no url */ } }),
|
||||
"utf8",
|
||||
);
|
||||
const env = await probeEnvironment(dir);
|
||||
expect(env.repo).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* End-to-end coverage for `scaffold()`: the filesystem half of init.
|
||||
*
|
||||
* Each test runs against a fresh tempdir so writes don't collide. The
|
||||
* suite's job is to verify the file tree, the overwrite policy, and
|
||||
* the "the scaffold round-trips through the loader" invariant — the
|
||||
* scaffolder shouldn't produce a manifest that its own validator
|
||||
* rejects (when the user has supplied all required fields).
|
||||
*/
|
||||
|
||||
import { mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { InitError, scaffold } from "../src/init/scaffold.js";
|
||||
import type { ScaffoldInputs } from "../src/init/templates.js";
|
||||
import { loadManifest } from "../src/manifest/load.js";
|
||||
|
||||
const FULL_INPUTS: ScaffoldInputs = {
|
||||
slug: "gallery",
|
||||
publisher: "did:plc:abc123def456",
|
||||
publisherHandle: "example.com",
|
||||
license: "MIT",
|
||||
author: { name: "Jane Doe" },
|
||||
security: { email: "security@example.com" },
|
||||
description: undefined,
|
||||
repo: undefined,
|
||||
};
|
||||
|
||||
const MINIMAL_INPUTS: ScaffoldInputs = {
|
||||
slug: "gallery",
|
||||
publisher: undefined,
|
||||
publisherHandle: undefined,
|
||||
license: undefined,
|
||||
author: undefined,
|
||||
security: undefined,
|
||||
description: undefined,
|
||||
repo: undefined,
|
||||
};
|
||||
|
||||
describe("scaffold", () => {
|
||||
let targetDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Each test gets its own tempdir, then immediately removes the
|
||||
// dir so the scaffolder is creating from scratch (matches what
|
||||
// a real `init` invocation does into a brand-new directory).
|
||||
const root = await mkdtemp(join(tmpdir(), "emdash-init-test-"));
|
||||
targetDir = join(root, "plugin");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// rm the parent root, not just targetDir, so we don't leak
|
||||
// the mkdtemp parent behind.
|
||||
const root = targetDir.replace(/\/plugin$/, "");
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("writes the expected file tree", async () => {
|
||||
const result = await scaffold({ targetDir, inputs: FULL_INPUTS, force: false });
|
||||
expect(result.written).toHaveLength(7);
|
||||
|
||||
// Spot-check the structure rather than pinning the array order.
|
||||
const fileSet = new Set(result.written.map((p) => p.replace(`${targetDir}/`, "")));
|
||||
expect(fileSet.has("emdash-plugin.jsonc")).toBe(true);
|
||||
expect(fileSet.has("package.json")).toBe(true);
|
||||
expect(fileSet.has("tsconfig.json")).toBe(true);
|
||||
expect(fileSet.has(".gitignore")).toBe(true);
|
||||
expect(fileSet.has("README.md")).toBe(true);
|
||||
expect(fileSet.has("src/plugin.ts")).toBe(true);
|
||||
expect(fileSet.has("tests/plugin.test.ts")).toBe(true);
|
||||
});
|
||||
|
||||
it("produces a manifest that the loader accepts", async () => {
|
||||
await scaffold({ targetDir, inputs: FULL_INPUTS, force: false });
|
||||
const { manifest } = await loadManifest(targetDir);
|
||||
expect(manifest.slug).toBe("gallery");
|
||||
// `version` is intentionally omitted from the scaffold manifest;
|
||||
// the build reads it from package.json instead.
|
||||
expect(manifest.version).toBeUndefined();
|
||||
expect(manifest.publisher).toBe("did:plc:abc123def456");
|
||||
expect(manifest.license).toBe("MIT");
|
||||
});
|
||||
|
||||
it("produces a minimal manifest that round-trips through the loader (with empty publisher)", async () => {
|
||||
// Minimal scaffold writes TODO placeholders. The loader's JSONC
|
||||
// parse succeeds; the schema rejects on `publisher` being empty.
|
||||
// We catch that explicitly so the user knows what to fix.
|
||||
await scaffold({ targetDir, inputs: MINIMAL_INPUTS, force: false });
|
||||
await expect(loadManifest(targetDir)).rejects.toMatchObject({
|
||||
name: "ManifestError",
|
||||
code: "MANIFEST_VALIDATION_ERROR",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses to overwrite an existing file without --force", async () => {
|
||||
// Pre-create one of the target files with different content.
|
||||
const { mkdir } = await import("node:fs/promises");
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await writeFile(join(targetDir, "package.json"), "{}", "utf8");
|
||||
|
||||
await expect(scaffold({ targetDir, inputs: FULL_INPUTS, force: false })).rejects.toMatchObject({
|
||||
name: "InitError",
|
||||
code: "TARGET_FILE_EXISTS",
|
||||
conflicts: ["package.json"],
|
||||
});
|
||||
|
||||
// The original file must be untouched.
|
||||
const contents = await readFile(join(targetDir, "package.json"), "utf8");
|
||||
expect(contents).toBe("{}");
|
||||
});
|
||||
|
||||
it("does not partially write when a conflict is detected", async () => {
|
||||
// Same setup as above. The conflict check runs BEFORE any
|
||||
// write, so nothing else should appear in the target dir.
|
||||
const { mkdir } = await import("node:fs/promises");
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await writeFile(join(targetDir, "package.json"), "{}", "utf8");
|
||||
|
||||
await expect(scaffold({ targetDir, inputs: FULL_INPUTS, force: false })).rejects.toThrow(
|
||||
InitError,
|
||||
);
|
||||
|
||||
const entries = await readdir(targetDir);
|
||||
// Only the pre-existing file should be there.
|
||||
expect(entries).toEqual(["package.json"]);
|
||||
});
|
||||
|
||||
it("overwrites existing files when --force is set", async () => {
|
||||
const { mkdir } = await import("node:fs/promises");
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await writeFile(join(targetDir, "package.json"), "{}", "utf8");
|
||||
|
||||
await scaffold({ targetDir, inputs: FULL_INPUTS, force: true });
|
||||
const contents = await readFile(join(targetDir, "package.json"), "utf8");
|
||||
// The scaffold wrote the real package.json over the stub.
|
||||
const parsed = JSON.parse(contents) as { name: string };
|
||||
expect(parsed.name).toBe("gallery");
|
||||
});
|
||||
|
||||
it("creates intermediate directories (src/, tests/)", async () => {
|
||||
await scaffold({ targetDir, inputs: FULL_INPUTS, force: false });
|
||||
const srcStat = await stat(join(targetDir, "src"));
|
||||
const testsStat = await stat(join(targetDir, "tests"));
|
||||
expect(srcStat.isDirectory()).toBe(true);
|
||||
expect(testsStat.isDirectory()).toBe(true);
|
||||
});
|
||||
|
||||
it("invokes onFileWritten once per file in scaffold order", async () => {
|
||||
const calls: string[] = [];
|
||||
await scaffold({
|
||||
targetDir,
|
||||
inputs: FULL_INPUTS,
|
||||
force: false,
|
||||
onFileWritten: (rel) => calls.push(rel),
|
||||
});
|
||||
// The seven files, in some deterministic order (see FILES in
|
||||
// scaffold.ts). The exact order is part of the API surface
|
||||
// for CLI progress output.
|
||||
expect(calls).toEqual([
|
||||
"emdash-plugin.jsonc",
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
".gitignore",
|
||||
"README.md",
|
||||
"src/plugin.ts",
|
||||
"tests/plugin.test.ts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Coverage for the init scaffolder's pure template functions.
|
||||
*
|
||||
* Tests focus on the manifest renderer because that's the file users
|
||||
* see first and the one whose shape has to satisfy the schema. The
|
||||
* other templates (package.json, tsconfig, README) get smoke checks
|
||||
* that they produce valid JSON / non-empty content; their exact
|
||||
* wording is verified by the integration test in init-scaffold.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
renderGitignore,
|
||||
renderManifest,
|
||||
renderPackageJson,
|
||||
renderPluginEntry,
|
||||
renderReadme,
|
||||
renderTest,
|
||||
renderTsconfig,
|
||||
type ScaffoldInputs,
|
||||
} from "../src/init/templates.js";
|
||||
import { ManifestSchema } from "../src/manifest/schema.js";
|
||||
|
||||
const FULL_INPUTS: ScaffoldInputs = {
|
||||
slug: "gallery",
|
||||
publisher: "did:plc:abc123def456",
|
||||
publisherHandle: "example.com",
|
||||
license: "MIT",
|
||||
author: { name: "Jane Doe", url: "https://example.com", email: "jane@example.com" },
|
||||
security: { email: "security@example.com" },
|
||||
description: "Image gallery plugin",
|
||||
repo: "https://github.com/example/gallery",
|
||||
};
|
||||
|
||||
const MINIMAL_INPUTS: ScaffoldInputs = {
|
||||
slug: "gallery",
|
||||
publisher: undefined,
|
||||
publisherHandle: undefined,
|
||||
license: undefined,
|
||||
author: undefined,
|
||||
security: undefined,
|
||||
description: undefined,
|
||||
repo: undefined,
|
||||
};
|
||||
|
||||
describe("renderManifest (fully-populated)", () => {
|
||||
it("produces a manifest that passes the schema", () => {
|
||||
const source = renderManifest(FULL_INPUTS);
|
||||
// JSONC parser strips comments and trailing commas before
|
||||
// validation. We parse via the same loader path the CLI uses
|
||||
// elsewhere, but for the test a quick `parse` from jsonc-parser
|
||||
// is enough — we only need to confirm the rendered bytes
|
||||
// validate.
|
||||
const parsed = parseJsonc(source);
|
||||
const result = ManifestSchema.safeParse(parsed);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("renders identity, license, author, security, description, repo", () => {
|
||||
const source = renderManifest(FULL_INPUTS);
|
||||
expect(source).toContain('"slug": "gallery"');
|
||||
// `version` deliberately omitted from the manifest scaffold —
|
||||
// package.json#version is the source of truth.
|
||||
expect(source).not.toContain('"version":');
|
||||
expect(source).toContain('"publisher": "did:plc:abc123def456"');
|
||||
expect(source).toContain('"license": "MIT"');
|
||||
expect(source).toContain('"name": "Jane Doe"');
|
||||
// author's url. The publisher comment also contains "example.com",
|
||||
// so we anchor on the author block by looking for the
|
||||
// url-key shape rather than the bare hostname.
|
||||
expect(source).toContain('"url": "https://example.com"');
|
||||
expect(source).toContain('"email": "jane@example.com"');
|
||||
expect(source).toContain('"email": "security@example.com"');
|
||||
expect(source).toContain('"description": "Image gallery plugin"');
|
||||
expect(source).toContain('"repo": "https://github.com/example/gallery"');
|
||||
});
|
||||
|
||||
it("includes the handle as a line comment next to the pinned DID", () => {
|
||||
const source = renderManifest(FULL_INPUTS);
|
||||
const publisherLine = source.split("\n").find((l) => l.includes('"publisher"'))!;
|
||||
expect(publisherLine).toBeDefined();
|
||||
expect(publisherLine).toContain("// example.com");
|
||||
});
|
||||
|
||||
it("omits the publisher comment when no handle is known (DID-only input)", () => {
|
||||
const source = renderManifest({ ...FULL_INPUTS, publisherHandle: undefined });
|
||||
const publisherLine = source.split("\n").find((l) => l.includes('"publisher"'))!;
|
||||
expect(publisherLine).toBeDefined();
|
||||
expect(publisherLine).not.toContain("//");
|
||||
});
|
||||
|
||||
it("includes the $schema reference for IDE completion", () => {
|
||||
const source = renderManifest(FULL_INPUTS);
|
||||
expect(source).toContain(
|
||||
'"$schema": "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json"',
|
||||
);
|
||||
});
|
||||
|
||||
it("emits empty default arrays for the trust contract", () => {
|
||||
// init starts with no declared capabilities. The author opts in.
|
||||
const source = renderManifest(FULL_INPUTS);
|
||||
expect(source).toContain('"capabilities": []');
|
||||
expect(source).toContain('"allowedHosts": []');
|
||||
expect(source).toContain('"storage": {}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderManifest (minimal — no flags, no prompts)", () => {
|
||||
it("produces a manifest with TODO placeholders", () => {
|
||||
const source = renderManifest(MINIMAL_INPUTS);
|
||||
// Three TODOs: publisher, author, security. License has a
|
||||
// default (MIT) so it never carries a TODO.
|
||||
const todoLines = source.split("\n").filter((line) => line.includes("TODO"));
|
||||
expect(todoLines.length).toBeGreaterThanOrEqual(3);
|
||||
// At least one TODO mentions atproto (publisher), one mentions
|
||||
// the author name, one mentions security.
|
||||
expect(todoLines.some((l) => /atproto handle|DID/i.test(l))).toBe(true);
|
||||
expect(todoLines.some((l) => /name|author/i.test(l))).toBe(true);
|
||||
expect(todoLines.some((l) => /security/i.test(l))).toBe(true);
|
||||
});
|
||||
|
||||
it("emits an empty publisher value the schema will reject", () => {
|
||||
// The TODO is visible to the user; the empty string is what
|
||||
// schema validation hits. This is intentional: the manifest is
|
||||
// "valid JSONC, schema-invalid until publisher is filled in".
|
||||
const source = renderManifest(MINIMAL_INPUTS);
|
||||
expect(source).toContain('"publisher": ""');
|
||||
});
|
||||
|
||||
it("defaults license to MIT when unset", () => {
|
||||
const source = renderManifest(MINIMAL_INPUTS);
|
||||
expect(source).toContain('"license": "MIT"');
|
||||
});
|
||||
|
||||
it("renders to the smallest plausible manifest", () => {
|
||||
// description and repo are truly-optional fields. They must
|
||||
// not appear when unset (no empty-string keys lying around).
|
||||
const source = renderManifest(MINIMAL_INPUTS);
|
||||
expect(source).not.toMatch(/"description":/);
|
||||
expect(source).not.toMatch(/"repo":/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderManifest (partial author/security)", () => {
|
||||
it("emits author.url and author.email only when provided", () => {
|
||||
const source = renderManifest({
|
||||
...FULL_INPUTS,
|
||||
author: { name: "Jane Doe" }, // no url, no email
|
||||
});
|
||||
expect(source).toContain('"name": "Jane Doe"');
|
||||
expect(source).not.toContain('"url":');
|
||||
expect(source).not.toContain('"jane@example.com"');
|
||||
});
|
||||
|
||||
it("emits security.url when only the url is provided", () => {
|
||||
const source = renderManifest({
|
||||
...FULL_INPUTS,
|
||||
security: { url: "https://example.com/security" },
|
||||
});
|
||||
expect(source).toContain('"url": "https://example.com/security"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPackageJson", () => {
|
||||
it("uses the slug as the package name and starts private", () => {
|
||||
const parsed = JSON.parse(renderPackageJson(FULL_INPUTS));
|
||||
expect(parsed.name).toBe("gallery");
|
||||
expect(parsed.private).toBe(true);
|
||||
expect(parsed.type).toBe("module");
|
||||
});
|
||||
|
||||
it("ships build/dev/typecheck/test scripts", () => {
|
||||
const parsed = JSON.parse(renderPackageJson(FULL_INPUTS));
|
||||
expect(parsed.scripts.build).toBe("emdash-plugin build");
|
||||
expect(parsed.scripts.dev).toBe("emdash-plugin dev");
|
||||
expect(parsed.scripts.typecheck).toBeDefined();
|
||||
expect(parsed.scripts.test).toBeDefined();
|
||||
});
|
||||
|
||||
it("ships npm-shape main/exports/files so the plugin is pnpm-add-able", () => {
|
||||
const parsed = JSON.parse(renderPackageJson(FULL_INPUTS));
|
||||
expect(parsed.main).toBe("dist/index.mjs");
|
||||
expect(parsed.exports["."]).toBeDefined();
|
||||
expect(parsed.exports["./sandbox"]).toBe("./dist/plugin.mjs");
|
||||
expect(parsed.files).toContain("dist");
|
||||
expect(parsed.files).toContain("emdash-plugin.jsonc");
|
||||
});
|
||||
|
||||
it("declares @emdash-cms/plugin-cli as a devDep (provides emdash-plugin binary)", () => {
|
||||
const parsed = JSON.parse(renderPackageJson(FULL_INPUTS));
|
||||
expect(parsed.devDependencies["@emdash-cms/plugin-cli"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderTsconfig", () => {
|
||||
it("produces a strict standalone tsconfig", () => {
|
||||
const parsed = JSON.parse(renderTsconfig());
|
||||
expect(parsed.compilerOptions.strict).toBe(true);
|
||||
// No outDir / declaration — source is the artefact, bundle
|
||||
// transpiles at publish time.
|
||||
expect(parsed.compilerOptions.outDir).toBeUndefined();
|
||||
expect(parsed.compilerOptions.declaration).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes both src and tests", () => {
|
||||
const parsed = JSON.parse(renderTsconfig());
|
||||
expect(parsed.include).toContain("src/**/*");
|
||||
expect(parsed.include).toContain("tests/**/*");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderPluginEntry", () => {
|
||||
it("type-only-imports SandboxedPlugin from emdash/plugin", () => {
|
||||
const source = renderPluginEntry();
|
||||
expect(source).toContain('import type { SandboxedPlugin } from "emdash/plugin"');
|
||||
// No runtime emdash imports — sandboxed plugins must not pull
|
||||
// the emdash runtime into their bundle.
|
||||
expect(source).not.toContain('import { definePlugin } from "emdash"');
|
||||
});
|
||||
|
||||
it("default-exports a bare object with `satisfies SandboxedPlugin` and a hello route", () => {
|
||||
const source = renderPluginEntry();
|
||||
expect(source).toContain("export default {");
|
||||
expect(source).toContain("satisfies SandboxedPlugin");
|
||||
expect(source).toContain("hello:");
|
||||
expect(source).toContain("greeting:");
|
||||
// definePlugin must not appear in the scaffold — it's
|
||||
// native-only now and would throw at runtime if used here.
|
||||
expect(source).not.toContain("definePlugin");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderTest", () => {
|
||||
it("imports the plugin and exercises the hello route", () => {
|
||||
const source = renderTest();
|
||||
expect(source).toContain('from "../src/plugin.js"');
|
||||
expect(source).toContain("hello");
|
||||
expect(source).toContain("expect(result)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderGitignore", () => {
|
||||
it("ignores node_modules", () => {
|
||||
expect(renderGitignore()).toContain("node_modules");
|
||||
});
|
||||
|
||||
it("ignores dist — the build pipeline writes it but it shouldn't be committed", () => {
|
||||
expect(renderGitignore()).toContain("dist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderReadme", () => {
|
||||
it("documents the publish path", () => {
|
||||
const source = renderReadme(FULL_INPUTS);
|
||||
expect(source).toContain("emdash-plugin bundle");
|
||||
expect(source).toContain("emdash-plugin publish");
|
||||
});
|
||||
|
||||
it("documents version-bump rules for the trust contract", () => {
|
||||
const source = renderReadme(FULL_INPUTS);
|
||||
expect(source).toContain("capabilities");
|
||||
expect(source).toContain("trust contract");
|
||||
});
|
||||
|
||||
it("uses the slug as the title", () => {
|
||||
const source = renderReadme(FULL_INPUTS);
|
||||
expect(source.split("\n")[0]).toBe("# gallery");
|
||||
});
|
||||
|
||||
it("camel-cases the import binding so hyphenated slugs produce valid JS", () => {
|
||||
const source = renderReadme({ ...FULL_INPUTS, slug: "my-plugin" });
|
||||
// The import specifier is the slug as-is; the binding must be a
|
||||
// legal JS identifier (`myPlugin`, not `my-plugin`).
|
||||
expect(source).toContain('import myPlugin from "my-plugin"');
|
||||
expect(source).toContain("sandboxed: [myPlugin]");
|
||||
expect(source).not.toContain("import my-plugin");
|
||||
});
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse JSONC for testing the rendered manifest. We use the
|
||||
* jsonc-parser dep directly here rather than going through the full
|
||||
* loader because the loader requires a file path and we want to
|
||||
* keep these tests in-memory.
|
||||
*/
|
||||
function parseJsonc(source: string): unknown {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { parse } = require("jsonc-parser") as typeof import("jsonc-parser");
|
||||
const errors: import("jsonc-parser").ParseError[] = [];
|
||||
const value: unknown = parse(source, errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
});
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`JSONC parse errors: ${JSON.stringify(errors)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Coverage for the manifest publisher field and its check/write-back.
|
||||
*
|
||||
* Two subjects:
|
||||
* - The Zod schema's PublisherSchema (accepts DID or handle; rejects
|
||||
* anything else).
|
||||
* - `checkPublisher` and `writePublisherBack` in `manifest/publisher.ts`.
|
||||
*
|
||||
* The handle-resolution path is covered indirectly: we stub the resolver
|
||||
* by passing values that look like a DID directly, which bypasses
|
||||
* `@atcute/identity-resolver`. Real handle resolution requires DNS, which
|
||||
* we don't exercise in unit tests.
|
||||
*/
|
||||
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { Did } from "@atcute/lexicons/syntax";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { checkPublisher, writePublisherBack } from "../src/manifest/publisher.js";
|
||||
import { ManifestSchema, PublisherSchema } from "../src/manifest/schema.js";
|
||||
|
||||
const SESSION_DID = "did:plc:abc123def456" as Did;
|
||||
const OTHER_DID = "did:plc:xyz789otherrr" as Did;
|
||||
|
||||
describe("PublisherSchema", () => {
|
||||
it("accepts a did:plc identifier", () => {
|
||||
expect(PublisherSchema.parse("did:plc:abc123")).toBe("did:plc:abc123");
|
||||
});
|
||||
|
||||
it("accepts a did:web identifier", () => {
|
||||
expect(PublisherSchema.parse("did:web:example.com")).toBe("did:web:example.com");
|
||||
});
|
||||
|
||||
it("accepts a handle", () => {
|
||||
expect(PublisherSchema.parse("example.com")).toBe("example.com");
|
||||
expect(PublisherSchema.parse("jane.bsky.social")).toBe("jane.bsky.social");
|
||||
});
|
||||
|
||||
it("rejects a bare slug", () => {
|
||||
const result = PublisherSchema.safeParse("not-a-handle-or-did");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty string", () => {
|
||||
const result = PublisherSchema.safeParse("");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a malformed did", () => {
|
||||
// `did:` without method+id is not a valid DID.
|
||||
const result = PublisherSchema.safeParse("did:");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ManifestSchema with publisher", () => {
|
||||
const minimal = {
|
||||
slug: "my-plugin",
|
||||
version: "0.1.0",
|
||||
license: "MIT",
|
||||
author: { name: "Jane Doe" },
|
||||
security: { email: "security@example.com" },
|
||||
};
|
||||
|
||||
it("accepts a manifest with a DID publisher", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
publisher: "did:plc:abc123",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a manifest with a handle publisher", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
publisher: "example.com",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a manifest without a publisher", () => {
|
||||
// publisher is required for the runtime to compute the plugin's
|
||||
// AT URI. The author must fill it in before any local-dev or
|
||||
// publish run.
|
||||
const result = ManifestSchema.safeParse(minimal);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a manifest with an invalid publisher", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
publisher: "not-valid",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkPublisher", () => {
|
||||
it("returns 'unpinned' when the manifest has no publisher", async () => {
|
||||
const result = await checkPublisher({
|
||||
manifestPublisher: undefined,
|
||||
sessionDid: SESSION_DID,
|
||||
});
|
||||
expect(result.kind).toBe("unpinned");
|
||||
});
|
||||
|
||||
it("returns 'match' when a pinned DID equals the session DID", async () => {
|
||||
const result = await checkPublisher({
|
||||
manifestPublisher: SESSION_DID,
|
||||
sessionDid: SESSION_DID,
|
||||
});
|
||||
expect(result.kind).toBe("match");
|
||||
if (result.kind === "match") {
|
||||
expect(result.pinnedDid).toBe(SESSION_DID);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns 'mismatch' when a pinned DID differs from the session DID", async () => {
|
||||
const result = await checkPublisher({
|
||||
manifestPublisher: OTHER_DID,
|
||||
sessionDid: SESSION_DID,
|
||||
});
|
||||
expect(result.kind).toBe("mismatch");
|
||||
if (result.kind === "mismatch") {
|
||||
expect(result.pinnedDid).toBe(OTHER_DID);
|
||||
expect(result.pinnedDisplay).toBe(OTHER_DID);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle resolution requires DNS / .well-known reachability. We test
|
||||
// the DID code path directly; the handle path is exercised in
|
||||
// integration tests separately (and against a mock resolver in
|
||||
// publisher-handle.test.ts when we add one later).
|
||||
});
|
||||
|
||||
describe("writePublisherBack", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "emdash-publisher-test-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("inserts publisher after license, preserving comments and order", async () => {
|
||||
const path = join(dir, "emdash-plugin.jsonc");
|
||||
const source = `{
|
||||
// Top-level comment
|
||||
"slug": "my-plugin",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"author": { "name": "Jane Doe" },
|
||||
"security": { "email": "security@example.com" }
|
||||
}
|
||||
`;
|
||||
await writeFile(path, source, "utf8");
|
||||
await writePublisherBack({ manifestPath: path, sessionDid: SESSION_DID });
|
||||
const updated = await readFile(path, "utf8");
|
||||
// The new key is present, the comment survives, and the DID is
|
||||
// written verbatim.
|
||||
expect(updated).toContain(`"publisher": "${SESSION_DID}"`);
|
||||
expect(updated).toContain("// Top-level comment");
|
||||
expect(updated).toContain('"license": "MIT"');
|
||||
// The publisher must land AFTER license. We test ordering by
|
||||
// finding the byte offset of each key.
|
||||
const licenseIdx = updated.indexOf('"license"');
|
||||
const publisherIdx = updated.indexOf('"publisher"');
|
||||
const authorIdx = updated.indexOf('"author"');
|
||||
expect(licenseIdx).toBeLessThan(publisherIdx);
|
||||
expect(publisherIdx).toBeLessThan(authorIdx);
|
||||
});
|
||||
|
||||
it("appends a // <handle> comment when a session handle is provided", async () => {
|
||||
const path = join(dir, "emdash-plugin.jsonc");
|
||||
const source = `{
|
||||
"slug": "my-plugin",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"author": { "name": "Jane Doe" },
|
||||
"security": { "email": "security@example.com" }
|
||||
}
|
||||
`;
|
||||
await writeFile(path, source, "utf8");
|
||||
await writePublisherBack({
|
||||
manifestPath: path,
|
||||
sessionDid: SESSION_DID,
|
||||
sessionHandle: "jane.bsky.social",
|
||||
});
|
||||
const updated = await readFile(path, "utf8");
|
||||
// The comment lives on the same line as the DID; the comma (if
|
||||
// any) is between the DID and the comment per JSONC convention.
|
||||
expect(updated).toMatch(/"publisher": "did:plc:abc123def456",? \/\/ jane\.bsky\.social/);
|
||||
// And the round-trip still parses cleanly (the comment doesn't
|
||||
// break JSONC).
|
||||
const { loadManifest } = await import("../src/manifest/load.js");
|
||||
const { manifest } = await loadManifest(path);
|
||||
expect(manifest.publisher).toBe(SESSION_DID);
|
||||
});
|
||||
|
||||
it("anchors the comment to the publisher property even when the DID appears in another field", async () => {
|
||||
// Regression: a previous implementation searched for the DID
|
||||
// string anywhere in the document, which would attach the
|
||||
// `// <handle>` comment to whichever field happened to contain
|
||||
// the DID-shaped substring first. Real plugins that document a
|
||||
// "previous publisher was did:plc:..." in the description (e.g.
|
||||
// after a maintainer transfer) would have triggered this.
|
||||
const path = join(dir, "emdash-plugin.jsonc");
|
||||
const source = `{
|
||||
"slug": "my-plugin",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"description": "Originally published as ${SESSION_DID}. See changelog.",
|
||||
"author": { "name": "Jane Doe" },
|
||||
"security": { "email": "security@example.com" }
|
||||
}
|
||||
`;
|
||||
await writeFile(path, source, "utf8");
|
||||
await writePublisherBack({
|
||||
manifestPath: path,
|
||||
sessionDid: SESSION_DID,
|
||||
sessionHandle: "jane.bsky.social",
|
||||
});
|
||||
const updated = await readFile(path, "utf8");
|
||||
// The handle comment lives on the publisher line, NOT on the
|
||||
// description line — confirm by inspecting each.
|
||||
const lines = updated.split("\n");
|
||||
const descriptionLine = lines.find((l) => l.includes('"description"'))!;
|
||||
const publisherLine = lines.find((l) => l.includes('"publisher"'))!;
|
||||
expect(descriptionLine).toBeDefined();
|
||||
expect(publisherLine).toBeDefined();
|
||||
expect(descriptionLine).not.toMatch(/\/\/ jane\.bsky\.social/);
|
||||
expect(publisherLine).toMatch(/\/\/ jane\.bsky\.social/);
|
||||
});
|
||||
|
||||
it("omits the comment when no handle is provided", async () => {
|
||||
const path = join(dir, "emdash-plugin.jsonc");
|
||||
const source = `{
|
||||
"slug": "my-plugin",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"author": { "name": "Jane Doe" },
|
||||
"security": { "email": "security@example.com" }
|
||||
}
|
||||
`;
|
||||
await writeFile(path, source, "utf8");
|
||||
await writePublisherBack({ manifestPath: path, sessionDid: SESSION_DID });
|
||||
const updated = await readFile(path, "utf8");
|
||||
// The DID line has no trailing `//` comment.
|
||||
const publisherLine = updated.split("\n").find((l) => l.includes('"publisher"'));
|
||||
expect(publisherLine).toBeDefined();
|
||||
expect(publisherLine).not.toContain("//");
|
||||
});
|
||||
|
||||
it("preserves the source's indentation (2-space)", async () => {
|
||||
// Regression: an earlier version hard-coded tab indentation in
|
||||
// the modify() formattingOptions, which silently reformatted
|
||||
// any 2-space-indented manifest to tabs on first publish. The
|
||||
// detector sniffs the source's existing indent and matches it.
|
||||
const path = join(dir, "emdash-plugin.jsonc");
|
||||
const source = [
|
||||
"{",
|
||||
' "slug": "my-plugin",',
|
||||
' "version": "0.1.0",',
|
||||
' "license": "MIT",',
|
||||
' "author": { "name": "Jane Doe" },',
|
||||
' "security": { "email": "security@example.com" }',
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
await writeFile(path, source, "utf8");
|
||||
await writePublisherBack({ manifestPath: path, sessionDid: SESSION_DID });
|
||||
const updated = await readFile(path, "utf8");
|
||||
// Pre-existing 2-space lines should still be 2-space, no tab
|
||||
// characters should have appeared anywhere.
|
||||
expect(updated).not.toContain("\t");
|
||||
// The new publisher line should also use 2 spaces.
|
||||
const publisherLine = updated.split("\n").find((l) => l.includes('"publisher"'))!;
|
||||
expect(publisherLine.startsWith(" ")).toBe(true);
|
||||
expect(publisherLine.startsWith("\t")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not overwrite an existing publisher (defensive re-parse)", async () => {
|
||||
const path = join(dir, "emdash-plugin.jsonc");
|
||||
const source = `{
|
||||
"slug": "my-plugin",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"publisher": "did:plc:user-pinned-already",
|
||||
"author": { "name": "Jane Doe" },
|
||||
"security": { "email": "security@example.com" }
|
||||
}
|
||||
`;
|
||||
await writeFile(path, source, "utf8");
|
||||
let warnings = 0;
|
||||
let infos = 0;
|
||||
await writePublisherBack({
|
||||
manifestPath: path,
|
||||
sessionDid: SESSION_DID,
|
||||
onInfo: () => infos++,
|
||||
onWarn: () => warnings++,
|
||||
});
|
||||
const updated = await readFile(path, "utf8");
|
||||
// File unchanged: the existing publisher wins.
|
||||
expect(updated).toContain('"publisher": "did:plc:user-pinned-already"');
|
||||
expect(updated).not.toContain(SESSION_DID);
|
||||
expect(infos).toBe(1);
|
||||
expect(warnings).toBe(0);
|
||||
});
|
||||
|
||||
it("does not fail when the file is missing (warns only)", async () => {
|
||||
const path = join(dir, "no-such-file.jsonc");
|
||||
let warnings = 0;
|
||||
await writePublisherBack({
|
||||
manifestPath: path,
|
||||
sessionDid: SESSION_DID,
|
||||
onWarn: () => warnings++,
|
||||
});
|
||||
// The publish has already succeeded by the time write-back runs;
|
||||
// a missing file at this point is surprising but never fatal.
|
||||
expect(warnings).toBe(1);
|
||||
});
|
||||
|
||||
it("does not fail when the file no longer parses (warns only)", async () => {
|
||||
const path = join(dir, "broken.jsonc");
|
||||
// User broke the file while we were publishing.
|
||||
await writeFile(path, '{ "license": "MIT", broken syntax', "utf8");
|
||||
let warnings = 0;
|
||||
await writePublisherBack({
|
||||
manifestPath: path,
|
||||
sessionDid: SESSION_DID,
|
||||
onWarn: () => warnings++,
|
||||
});
|
||||
expect(warnings).toBe(1);
|
||||
});
|
||||
|
||||
it("produces a JSONC document that round-trips through the loader", async () => {
|
||||
const path = join(dir, "emdash-plugin.jsonc");
|
||||
const source = `{
|
||||
"slug": "my-plugin",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"author": { "name": "Jane Doe" },
|
||||
"security": { "email": "security@example.com" }
|
||||
}
|
||||
`;
|
||||
await writeFile(path, source, "utf8");
|
||||
await writePublisherBack({ manifestPath: path, sessionDid: SESSION_DID });
|
||||
|
||||
// Re-load through the actual loader. If write-back produced
|
||||
// malformed JSONC or a value the schema rejects, this throws.
|
||||
const { loadManifest } = await import("../src/manifest/load.js");
|
||||
const { manifest } = await loadManifest(path);
|
||||
expect(manifest.publisher).toBe(SESSION_DID);
|
||||
expect(manifest.license).toBe("MIT");
|
||||
expect(manifest.author?.name).toBe("Jane Doe");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,494 @@
|
||||
/**
|
||||
* Coverage for the `emdash-plugin.jsonc` Zod schema.
|
||||
*
|
||||
* The schema is the authoring contract with plugin publishers. A regression
|
||||
* here means user-visible behaviour change in the field names, validation
|
||||
* rules, or error messages that publishers rely on. Tests are organised by
|
||||
* field so a future field add lands cleanly alongside its own test block.
|
||||
*
|
||||
* Where applicable, tests assert on the EXACT Zod issue path / message
|
||||
* because those strings surface in `emdash-plugin validate` output --
|
||||
* users see them, and silently changing them breaks anyone who built
|
||||
* tooling around the strings.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ArtifactFileSchema,
|
||||
ArtifactsSchema,
|
||||
AuthorSchema,
|
||||
LicenseSchema,
|
||||
ManifestSchema,
|
||||
RepoSchema,
|
||||
RequiresSchema,
|
||||
SECTION_KEYS,
|
||||
SectionsSchema,
|
||||
SecurityContactSchema,
|
||||
} from "../src/manifest/schema.js";
|
||||
|
||||
describe("LicenseSchema", () => {
|
||||
it("accepts a typical SPDX expression", () => {
|
||||
expect(LicenseSchema.parse("MIT")).toBe("MIT");
|
||||
expect(LicenseSchema.parse("Apache-2.0")).toBe("Apache-2.0");
|
||||
expect(LicenseSchema.parse("MIT OR Apache-2.0")).toBe("MIT OR Apache-2.0");
|
||||
});
|
||||
|
||||
it("rejects the empty string", () => {
|
||||
const result = LicenseSchema.safeParse("");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a whitespace-only license", () => {
|
||||
const result = LicenseSchema.safeParse(" ");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects values over 256 characters", () => {
|
||||
const result = LicenseSchema.safeParse("A".repeat(257));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AuthorSchema", () => {
|
||||
it("accepts the minimal name-only form", () => {
|
||||
expect(AuthorSchema.parse({ name: "Jane Doe" })).toEqual({ name: "Jane Doe" });
|
||||
});
|
||||
|
||||
it("accepts name + url + email", () => {
|
||||
const author = {
|
||||
name: "Jane Doe",
|
||||
url: "https://example.com",
|
||||
email: "jane@example.com",
|
||||
};
|
||||
expect(AuthorSchema.parse(author)).toEqual(author);
|
||||
});
|
||||
|
||||
it("rejects an empty name", () => {
|
||||
const result = AuthorSchema.safeParse({ name: "" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown keys (strict mode)", () => {
|
||||
const result = AuthorSchema.safeParse({ name: "Jane", website: "https://example.com" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a malformed URL", () => {
|
||||
const result = AuthorSchema.safeParse({ name: "Jane", url: "not-a-url" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a malformed email", () => {
|
||||
const result = AuthorSchema.safeParse({ name: "Jane", email: "not-an-email" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SecurityContactSchema", () => {
|
||||
it("accepts an email-only contact", () => {
|
||||
expect(SecurityContactSchema.parse({ email: "security@example.com" })).toEqual({
|
||||
email: "security@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a url-only contact", () => {
|
||||
expect(SecurityContactSchema.parse({ url: "https://example.com/security" })).toEqual({
|
||||
url: "https://example.com/security",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an empty contact (no email or url)", () => {
|
||||
const result = SecurityContactSchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
// The exact message users see at validate-time; pin it.
|
||||
expect(result.error.issues[0]?.message).toContain("at least one of `url` or `email`");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("RepoSchema", () => {
|
||||
it("accepts an https:// URL", () => {
|
||||
expect(RepoSchema.parse("https://github.com/example/plugin")).toBe(
|
||||
"https://github.com/example/plugin",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects http:// URLs", () => {
|
||||
const result = RepoSchema.safeParse("http://github.com/example/plugin");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-URL strings", () => {
|
||||
const result = RepoSchema.safeParse("not a url");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RequiresSchema", () => {
|
||||
it("accepts env:* keys with semver-range values", () => {
|
||||
expect(RequiresSchema.parse({ "env:emdash": ">=1.0.0", "env:astro": ">=4.16" })).toEqual({
|
||||
"env:emdash": ">=1.0.0",
|
||||
"env:astro": ">=4.16",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts caret, tilde, and AND-set ranges", () => {
|
||||
expect(
|
||||
RequiresSchema.parse({
|
||||
"env:astro": "^4.0.0",
|
||||
"env:emdash": ">=1.0.0 <2.0.0",
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(RequiresSchema.parse({ "env:astro": "~4.16.0" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("accepts forward-compat DID-shaped keys", () => {
|
||||
expect(RequiresSchema.parse({ "did:plc:abc123": "^1.0.0" })).toEqual({
|
||||
"did:plc:abc123": "^1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects keys that are neither env:* nor DID-shaped", () => {
|
||||
expect(RequiresSchema.safeParse({ astro: ">=4.16" }).success).toBe(false);
|
||||
expect(RequiresSchema.safeParse({ "env:": ">=4.16" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects values that aren't valid semver ranges", () => {
|
||||
expect(RequiresSchema.safeParse({ "env:astro": "not-a-range" }).success).toBe(false);
|
||||
expect(RequiresSchema.safeParse({ "env:astro": "" }).success).toBe(false);
|
||||
expect(RequiresSchema.safeParse({ "env:astro": ">=" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ArtifactFileSchema", () => {
|
||||
it("accepts a bare file ref", () => {
|
||||
expect(ArtifactFileSchema.safeParse({ file: "./icon.png" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a file ref with a lang tag", () => {
|
||||
expect(ArtifactFileSchema.safeParse({ file: "./icon-fr.png", lang: "fr" }).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an empty file path", () => {
|
||||
expect(ArtifactFileSchema.safeParse({ file: "" }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown keys (e.g. a hand-written url/checksum)", () => {
|
||||
const result = ArtifactFileSchema.safeParse({
|
||||
file: "./icon.png",
|
||||
url: "https://example.com/icon.png",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ArtifactsSchema", () => {
|
||||
it("accepts icon and banner as single file refs", () => {
|
||||
const result = ArtifactsSchema.safeParse({
|
||||
icon: { file: "./icon.png" },
|
||||
banner: { file: "./banner.png" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts screenshots as an array of file refs", () => {
|
||||
const result = ArtifactsSchema.safeParse({
|
||||
screenshots: [{ file: "./s1.png" }, { file: "./s2.png", lang: "de" }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a single (non-array) screenshots value", () => {
|
||||
const result = ArtifactsSchema.safeParse({ screenshots: { file: "./s1.png" } });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty screenshots array", () => {
|
||||
expect(ArtifactsSchema.safeParse({ screenshots: [] }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects more than eight screenshots", () => {
|
||||
const screenshots = Array.from({ length: 9 }, (_, i) => ({ file: `./s${i}.png` }));
|
||||
expect(ArtifactsSchema.safeParse({ screenshots }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects the legacy singular `screenshot` key", () => {
|
||||
const result = ArtifactsSchema.safeParse({ screenshot: [{ file: "./s1.png" }] });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SectionsSchema", () => {
|
||||
it("accepts an inline string for each of the five keys", () => {
|
||||
for (const key of SECTION_KEYS) {
|
||||
const result = SectionsSchema.safeParse({ [key]: "# Heading\n\nSome **markdown**." });
|
||||
expect(result.success, key).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a { file } ref for each of the five keys", () => {
|
||||
for (const key of SECTION_KEYS) {
|
||||
const result = SectionsSchema.safeParse({ [key]: { file: "./docs/x.md" } });
|
||||
expect(result.success, key).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a mix of inline and file refs", () => {
|
||||
const result = SectionsSchema.safeParse({
|
||||
description: "inline",
|
||||
installation: { file: "./docs/install.md" },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("treats every key as optional (empty object is valid)", () => {
|
||||
expect(SectionsSchema.safeParse({}).success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown section key", () => {
|
||||
const result = SectionsSchema.safeParse({ instalation: "typo" });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an extra key in a file ref", () => {
|
||||
const result = SectionsSchema.safeParse({ description: { file: "./x.md", lang: "en" } });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an inline section over the 20000-byte cap while under the grapheme cap", () => {
|
||||
// A 25-byte family emoji (one grapheme via ZWJ). 1000 of them = 25000
|
||||
// bytes (over the byte cap) but only 1000 graphemes (under the
|
||||
// grapheme cap), so the byte check is the one that fires.
|
||||
const result = SectionsSchema.safeParse({ description: "👨👩👧👦".repeat(1000) });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((i) => i.message.includes("byte"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an inline section over the 2000-grapheme cap while under the byte cap", () => {
|
||||
// 2001 emoji. Each emoji is one grapheme but several UTF-8 bytes, so
|
||||
// this trips the grapheme cap without reaching 20000 bytes (2001 * 4
|
||||
// = 8004 bytes).
|
||||
const result = SectionsSchema.safeParse({ faq: "😀".repeat(2001) });
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((i) => i.message.includes("grapheme"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts an inline section exactly at the grapheme cap", () => {
|
||||
// 2000 ASCII chars: 2000 graphemes (at the cap) and 2000 bytes (under
|
||||
// the byte cap). Both limits satisfied.
|
||||
const result = SectionsSchema.safeParse({ changelog: "a".repeat(2000) });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an inline section one grapheme over the cap", () => {
|
||||
const result = SectionsSchema.safeParse({ changelog: "a".repeat(2001) });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ManifestSchema (full document)", () => {
|
||||
const minimal = {
|
||||
slug: "my-plugin",
|
||||
version: "0.1.0",
|
||||
publisher: "example.com",
|
||||
license: "MIT",
|
||||
author: { name: "Jane Doe" },
|
||||
security: { email: "security@example.com" },
|
||||
};
|
||||
|
||||
it("accepts the minimal required shape", () => {
|
||||
const result = ManifestSchema.safeParse(minimal);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a manifest with a release.artifacts block", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
release: {
|
||||
artifacts: {
|
||||
icon: { file: "./icon.png" },
|
||||
banner: { file: "./banner.png" },
|
||||
screenshots: [{ file: "./s1.png" }, { file: "./s2.png" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a top-level sections block (inline + file refs)", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
sections: {
|
||||
description: "Long description.",
|
||||
installation: { file: "./docs/install.md" },
|
||||
},
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects an unknown section key at the top level", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
sections: { changelog: "ok", bogus: "nope" },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an unknown key inside release", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
release: { artifacts: { icon: { file: "./icon.png" } }, bogus: true },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a manifest with $schema for IDE completion", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
$schema: "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts the multi-author/multi-contact form", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
slug: "my-plugin",
|
||||
version: "0.1.0",
|
||||
publisher: "example.com",
|
||||
license: "MIT",
|
||||
authors: [{ name: "Alice" }, { name: "Bob" }],
|
||||
securityContacts: [{ email: "alice@example.com" }, { url: "https://example.com/security" }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects mixing `author` and `authors`", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
authors: [{ name: "Bob" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) => i.message.includes("both `author` and `authors`")),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects mixing `security` and `securityContacts`", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
securityContacts: [{ email: "b@example.com" }],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) =>
|
||||
i.message.includes("both `security` and `securityContacts`"),
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires either `author` or `authors`", () => {
|
||||
const { author: _author, ...withoutAuthor } = minimal;
|
||||
const result = ManifestSchema.safeParse(withoutAuthor);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((i) => i.message.includes("`author: { ... }`"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("requires either `security` or `securityContacts`", () => {
|
||||
const { security: _security, ...withoutSecurity } = minimal;
|
||||
const result = ManifestSchema.safeParse(withoutSecurity);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((i) => i.message.includes("`security: { ... }`"))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects unknown top-level keys (strict mode catches typos)", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
licens: "MIT", // typo
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty authors array (lexicon requires >= 1)", () => {
|
||||
const { author: _author, ...rest } = minimal;
|
||||
const result = ManifestSchema.safeParse({
|
||||
...rest,
|
||||
authors: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects more than 32 authors (lexicon cap)", () => {
|
||||
const authors = Array.from({ length: 33 }, (_, i) => ({ name: `Author ${i}` }));
|
||||
const { author: _author, ...rest } = minimal;
|
||||
const result = ManifestSchema.safeParse({
|
||||
...rest,
|
||||
authors,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects more than 5 keywords (FAIR convention)", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
keywords: ["a", "b", "c", "d", "e", "f"],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a full populated manifest", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
$schema: "./node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json",
|
||||
slug: "gallery",
|
||||
version: "0.1.0",
|
||||
publisher: "example.com",
|
||||
license: "MIT",
|
||||
author: {
|
||||
name: "Jane Doe",
|
||||
url: "https://example.com",
|
||||
email: "jane@example.com",
|
||||
},
|
||||
security: {
|
||||
email: "security@example.com",
|
||||
url: "https://example.com/security",
|
||||
},
|
||||
name: "Gallery",
|
||||
description: "Image gallery block for EmDash.",
|
||||
keywords: ["gallery", "images", "media"],
|
||||
repo: "https://github.com/emdash-cms/plugin-gallery",
|
||||
release: { requires: { "env:emdash": ">=1.0.0", "env:astro": ">=4.16" } },
|
||||
capabilities: ["content:read"],
|
||||
storage: { events: { indexes: ["timestamp"] } },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a manifest with release-level requires", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
release: { requires: { "env:astro": ">=4.16" } },
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a manifest with an invalid requires range", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...minimal,
|
||||
release: { requires: { "env:astro": "not-a-range" } },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Coverage for the manifest -> publish translation layer.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
manifestToProfileBootstrap,
|
||||
manifestToProfileInput,
|
||||
normaliseManifest,
|
||||
type NormalisedManifest,
|
||||
resolveSections,
|
||||
SectionError,
|
||||
} from "../src/manifest/translate.js";
|
||||
|
||||
describe("normaliseManifest", () => {
|
||||
it("collapses single-author into authors[]", () => {
|
||||
const normalised = normaliseManifest({
|
||||
version: "0.1.0",
|
||||
license: "MIT",
|
||||
author: { name: "Jane" },
|
||||
security: { email: "s@example.com" },
|
||||
});
|
||||
expect(normalised.authors).toEqual([{ name: "Jane" }]);
|
||||
// Single security contact normalised to array.
|
||||
expect(normalised.securityContacts).toEqual([{ email: "s@example.com" }]);
|
||||
});
|
||||
|
||||
it("passes the multi-author array through unchanged", () => {
|
||||
const normalised = normaliseManifest({
|
||||
version: "0.1.0",
|
||||
license: "MIT",
|
||||
authors: [{ name: "A" }, { name: "B" }],
|
||||
securityContacts: [{ email: "s@example.com" }],
|
||||
});
|
||||
expect(normalised.authors).toEqual([{ name: "A" }, { name: "B" }]);
|
||||
});
|
||||
|
||||
it("propagates publisher when set", () => {
|
||||
const normalised = normaliseManifest({
|
||||
version: "0.1.0",
|
||||
license: "MIT",
|
||||
publisher: "did:plc:abc",
|
||||
author: { name: "Jane" },
|
||||
security: { email: "s@example.com" },
|
||||
});
|
||||
expect(normalised.publisher).toBe("did:plc:abc");
|
||||
});
|
||||
|
||||
it("uses package.json version when manifest omits it", () => {
|
||||
const normalised = normaliseManifest(
|
||||
{
|
||||
license: "MIT",
|
||||
author: { name: "Jane" },
|
||||
security: { email: "s@example.com" },
|
||||
},
|
||||
"1.2.3",
|
||||
);
|
||||
expect(normalised.version).toBe("1.2.3");
|
||||
});
|
||||
|
||||
it("uses manifest version when no package.json version is provided", () => {
|
||||
const normalised = normaliseManifest({
|
||||
version: "0.9.0",
|
||||
license: "MIT",
|
||||
author: { name: "Jane" },
|
||||
security: { email: "s@example.com" },
|
||||
});
|
||||
expect(normalised.version).toBe("0.9.0");
|
||||
});
|
||||
|
||||
it("accepts matching versions from both sources", () => {
|
||||
const normalised = normaliseManifest(
|
||||
{
|
||||
version: "2.0.0",
|
||||
license: "MIT",
|
||||
author: { name: "Jane" },
|
||||
security: { email: "s@example.com" },
|
||||
},
|
||||
"2.0.0",
|
||||
);
|
||||
expect(normalised.version).toBe("2.0.0");
|
||||
});
|
||||
|
||||
it("throws on mismatched versions", () => {
|
||||
expect(() =>
|
||||
normaliseManifest(
|
||||
{
|
||||
version: "1.0.0",
|
||||
license: "MIT",
|
||||
author: { name: "Jane" },
|
||||
security: { email: "s@example.com" },
|
||||
},
|
||||
"2.0.0",
|
||||
),
|
||||
).toThrow(/disagrees/);
|
||||
});
|
||||
|
||||
it("passes release-level requires through unchanged", () => {
|
||||
const normalised = normaliseManifest({
|
||||
version: "0.1.0",
|
||||
license: "MIT",
|
||||
author: { name: "Jane" },
|
||||
security: { email: "s@example.com" },
|
||||
release: { requires: { "env:emdash": ">=1.0.0", "env:astro": ">=4.16" } },
|
||||
});
|
||||
expect(normalised.requires).toEqual({ "env:emdash": ">=1.0.0", "env:astro": ">=4.16" });
|
||||
});
|
||||
|
||||
it("leaves requires undefined when the manifest omits it", () => {
|
||||
const normalised = normaliseManifest({
|
||||
version: "0.1.0",
|
||||
license: "MIT",
|
||||
author: { name: "Jane" },
|
||||
security: { email: "s@example.com" },
|
||||
});
|
||||
expect(normalised.requires).toBeUndefined();
|
||||
});
|
||||
|
||||
it("throws when no version is available anywhere", () => {
|
||||
expect(() =>
|
||||
normaliseManifest({
|
||||
license: "MIT",
|
||||
author: { name: "Jane" },
|
||||
security: { email: "s@example.com" },
|
||||
}),
|
||||
).toThrow(/not set/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("manifestToProfileBootstrap", () => {
|
||||
it("maps the publish-relevant subset of fields", () => {
|
||||
const normalised: NormalisedManifest = {
|
||||
slug: "test",
|
||||
version: "0.1.0",
|
||||
license: "MIT",
|
||||
publisher: "did:plc:abc",
|
||||
authors: [{ name: "Jane", url: "https://example.com" }],
|
||||
securityContacts: [{ email: "s@example.com" }],
|
||||
name: "Test",
|
||||
description: "desc",
|
||||
keywords: ["k"],
|
||||
repo: "https://github.com/example/p",
|
||||
requires: undefined,
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: { pages: [], widgets: [] },
|
||||
};
|
||||
const bootstrap = manifestToProfileBootstrap(normalised);
|
||||
expect(bootstrap.license).toBe("MIT");
|
||||
expect(bootstrap.authorName).toBe("Jane");
|
||||
expect(bootstrap.authorUrl).toBe("https://example.com");
|
||||
expect(bootstrap.securityEmail).toBe("s@example.com");
|
||||
});
|
||||
|
||||
it("uses the first author when multiple are provided", () => {
|
||||
const normalised: NormalisedManifest = {
|
||||
slug: "test",
|
||||
version: "0.1.0",
|
||||
license: "MIT",
|
||||
publisher: "did:plc:abc",
|
||||
authors: [{ name: "First" }, { name: "Second" }],
|
||||
securityContacts: [{ email: "s@example.com" }],
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
keywords: undefined,
|
||||
repo: undefined,
|
||||
requires: undefined,
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: { pages: [], widgets: [] },
|
||||
};
|
||||
const bootstrap = manifestToProfileBootstrap(normalised);
|
||||
expect(bootstrap.authorName).toBe("First");
|
||||
});
|
||||
});
|
||||
|
||||
describe("manifestToProfileInput", () => {
|
||||
it("carries the full lexicon profile block (multi-author, multi-security)", () => {
|
||||
const normalised: NormalisedManifest = {
|
||||
slug: "test",
|
||||
version: "0.1.0",
|
||||
license: "Apache-2.0",
|
||||
publisher: "did:plc:abc",
|
||||
authors: [
|
||||
{ name: "Jane", url: "https://example.com" },
|
||||
{ name: "Bob", email: "bob@example.com" },
|
||||
],
|
||||
securityContacts: [{ email: "s@example.com" }, { url: "https://example.com/sec" }],
|
||||
name: "Test",
|
||||
description: "desc",
|
||||
keywords: ["k1", "k2"],
|
||||
repo: "https://github.com/example/p",
|
||||
requires: { "env:astro": ">=4.16" },
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: { pages: [], widgets: [] },
|
||||
};
|
||||
const input = manifestToProfileInput(normalised);
|
||||
expect(input.license).toBe("Apache-2.0");
|
||||
expect(input.authors).toEqual([
|
||||
{ name: "Jane", url: "https://example.com" },
|
||||
{ name: "Bob", email: "bob@example.com" },
|
||||
]);
|
||||
expect(input.security).toEqual([
|
||||
{ email: "s@example.com" },
|
||||
{ url: "https://example.com/sec" },
|
||||
]);
|
||||
expect(input.name).toBe("Test");
|
||||
expect(input.description).toBe("desc");
|
||||
expect(input.keywords).toEqual(["k1", "k2"]);
|
||||
// requires is release-level, never folded into the profile input.
|
||||
expect("requires" in input).toBe(false);
|
||||
});
|
||||
|
||||
it("omits name, description and keywords when the manifest doesn't set them", () => {
|
||||
const normalised: NormalisedManifest = {
|
||||
slug: "test",
|
||||
version: "0.1.0",
|
||||
license: "MIT",
|
||||
publisher: "did:plc:abc",
|
||||
authors: [{ name: "Solo" }],
|
||||
securityContacts: [{ email: "s@example.com" }],
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
keywords: undefined,
|
||||
repo: undefined,
|
||||
requires: undefined,
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: { pages: [], widgets: [] },
|
||||
};
|
||||
const input = manifestToProfileInput(normalised);
|
||||
expect("name" in input).toBe(false);
|
||||
expect("description" in input).toBe(false);
|
||||
expect("keywords" in input).toBe(false);
|
||||
expect("sections" in input).toBe(false);
|
||||
});
|
||||
|
||||
it("carries resolved sections into the profile input", () => {
|
||||
const normalised: NormalisedManifest = {
|
||||
slug: "test",
|
||||
version: "0.1.0",
|
||||
license: "MIT",
|
||||
publisher: "did:plc:abc",
|
||||
authors: [{ name: "Solo" }],
|
||||
securityContacts: [{ email: "s@example.com" }],
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
keywords: undefined,
|
||||
repo: undefined,
|
||||
requires: undefined,
|
||||
sections: { description: "Long.", installation: "Run install." },
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: { pages: [], widgets: [] },
|
||||
};
|
||||
const input = manifestToProfileInput(normalised);
|
||||
expect(input.sections).toEqual({ description: "Long.", installation: "Run install." });
|
||||
});
|
||||
|
||||
it("omits sections when the resolved map is empty or undefined", () => {
|
||||
const base: NormalisedManifest = {
|
||||
slug: "test",
|
||||
version: "0.1.0",
|
||||
license: "MIT",
|
||||
publisher: "did:plc:abc",
|
||||
authors: [{ name: "Solo" }],
|
||||
securityContacts: [{ email: "s@example.com" }],
|
||||
name: undefined,
|
||||
description: undefined,
|
||||
keywords: undefined,
|
||||
repo: undefined,
|
||||
requires: undefined,
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
admin: { pages: [], widgets: [] },
|
||||
};
|
||||
expect("sections" in manifestToProfileInput(base)).toBe(false);
|
||||
expect("sections" in manifestToProfileInput({ ...base, sections: {} })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSections", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "emdash-sections-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns undefined when the manifest declared no sections", async () => {
|
||||
expect(await resolveSections(undefined, dir)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("passes inline strings through unchanged", async () => {
|
||||
const resolved = await resolveSections({ description: "Inline desc.", faq: "Q&A." }, dir);
|
||||
expect(resolved).toEqual({ description: "Inline desc.", faq: "Q&A." });
|
||||
});
|
||||
|
||||
it("reads a { file } ref's content into the section", async () => {
|
||||
await writeFile(join(dir, "install.md"), "# Install\n\nRun `pnpm add`.");
|
||||
const resolved = await resolveSections({ installation: { file: "./install.md" } }, dir);
|
||||
expect(resolved).toEqual({ installation: "# Install\n\nRun `pnpm add`." });
|
||||
});
|
||||
|
||||
it("leaves unset keys out of the resolved map", async () => {
|
||||
const resolved = await resolveSections({ description: "Only this." }, dir);
|
||||
expect(resolved).toEqual({ description: "Only this." });
|
||||
expect(resolved && "faq" in resolved).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a file ref that escapes the manifest directory", async () => {
|
||||
await expect(
|
||||
resolveSections({ security: { file: "../secrets.md" } }, dir),
|
||||
).rejects.toMatchObject({ code: "SECTION_PATH_ESCAPE" });
|
||||
});
|
||||
|
||||
it("rejects an absolute file ref", async () => {
|
||||
await expect(
|
||||
resolveSections({ security: { file: "/etc/passwd" } }, dir),
|
||||
).rejects.toBeInstanceOf(SectionError);
|
||||
});
|
||||
|
||||
it("rejects an unreadable file ref", async () => {
|
||||
await expect(
|
||||
resolveSections({ changelog: { file: "./missing.md" } }, dir),
|
||||
).rejects.toMatchObject({ code: "SECTION_FILE_UNREADABLE" });
|
||||
});
|
||||
|
||||
it("rejects a file whose content exceeds the byte cap", async () => {
|
||||
await writeFile(join(dir, "big.md"), "a".repeat(20001));
|
||||
await expect(resolveSections({ description: { file: "./big.md" } }, dir)).rejects.toMatchObject(
|
||||
{ code: "SECTION_TOO_LARGE" },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a file whose content exceeds the grapheme cap", async () => {
|
||||
await writeFile(join(dir, "emoji.md"), "😀".repeat(2001));
|
||||
await expect(resolveSections({ faq: { file: "./emoji.md" } }, dir)).rejects.toMatchObject({
|
||||
code: "SECTION_TOO_LARGE",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* Coverage for the manifest's identity (`slug`, `version`) and
|
||||
* trust-contract fields (`capabilities`, `allowedHosts`, `storage`).
|
||||
*
|
||||
* The trust contract is the manifest's most security-sensitive surface.
|
||||
* Capability vocabulary, deprecated-name rejection, and the cross-field
|
||||
* `network:request` / `allowedHosts` rules all need explicit coverage so
|
||||
* a future schema edit can't silently relax the validation.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
AllowedHostsSchema,
|
||||
CapabilitiesSchema,
|
||||
CapabilitySchema,
|
||||
ManifestSchema,
|
||||
SlugSchema,
|
||||
StorageSchema,
|
||||
VersionSchema,
|
||||
} from "../src/manifest/schema.js";
|
||||
|
||||
describe("SlugSchema", () => {
|
||||
it("accepts the canonical form", () => {
|
||||
expect(SlugSchema.parse("gallery")).toBe("gallery");
|
||||
expect(SlugSchema.parse("my-plugin")).toBe("my-plugin");
|
||||
expect(SlugSchema.parse("plugin_v2")).toBe("plugin_v2");
|
||||
});
|
||||
|
||||
it("rejects leading digit", () => {
|
||||
const result = SlugSchema.safeParse("1-plugin");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects leading punctuation", () => {
|
||||
expect(SlugSchema.safeParse("-plugin").success).toBe(false);
|
||||
expect(SlugSchema.safeParse("_plugin").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects uppercase", () => {
|
||||
const result = SlugSchema.safeParse("MyPlugin");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty", () => {
|
||||
expect(SlugSchema.safeParse("").success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects over 64 chars", () => {
|
||||
const result = SlugSchema.safeParse("a".repeat(65));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("VersionSchema", () => {
|
||||
it("accepts the canonical form", () => {
|
||||
expect(VersionSchema.parse("0.1.0")).toBe("0.1.0");
|
||||
expect(VersionSchema.parse("1.2.3")).toBe("1.2.3");
|
||||
expect(VersionSchema.parse("1.0.0-rc.1")).toBe("1.0.0-rc.1");
|
||||
});
|
||||
|
||||
it("rejects build metadata (atproto rkey constraint)", () => {
|
||||
// The atproto record-key alphabet has no `+`, so a semver
|
||||
// build-metadata suffix can't survive into the publish path.
|
||||
const result = VersionSchema.safeParse("1.0.0+build.1");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects malformed semver", () => {
|
||||
expect(VersionSchema.safeParse("1").success).toBe(false);
|
||||
expect(VersionSchema.safeParse("1.0").success).toBe(false);
|
||||
expect(VersionSchema.safeParse("v1.0.0").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CapabilitySchema", () => {
|
||||
it("accepts a current capability", () => {
|
||||
expect(CapabilitySchema.parse("content:read")).toBe("content:read");
|
||||
expect(CapabilitySchema.parse("network:request")).toBe("network:request");
|
||||
expect(CapabilitySchema.parse("email:send")).toBe("email:send");
|
||||
});
|
||||
|
||||
it("rejects a deprecated capability with a hint at the replacement", () => {
|
||||
const result = CapabilitySchema.safeParse("read:content");
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0]?.message).toContain("deprecated");
|
||||
expect(result.error.issues[0]?.message).toContain("content:read");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an unknown capability", () => {
|
||||
const result = CapabilitySchema.safeParse("filesystem:write");
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0]?.message).toContain("not a recognised name");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects empty string", () => {
|
||||
expect(CapabilitySchema.safeParse("").success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CapabilitiesSchema (array-level)", () => {
|
||||
it("accepts an empty list (no privileges beyond defaults)", () => {
|
||||
expect(CapabilitiesSchema.parse([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects more than 32 entries", () => {
|
||||
// All entries are valid capabilities but the count alone should
|
||||
// trip the array max. Repeat a valid one rather than constructing
|
||||
// 33 distinct names that would also each fail individually.
|
||||
const result = CapabilitiesSchema.safeParse(Array.from({ length: 33 }).fill("content:read"));
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AllowedHostsSchema", () => {
|
||||
it("accepts hostnames and wildcard-subdomain patterns", () => {
|
||||
expect(AllowedHostsSchema.parse(["api.example.com"])).toEqual(["api.example.com"]);
|
||||
expect(AllowedHostsSchema.parse(["*.cdn.example.com"])).toEqual(["*.cdn.example.com"]);
|
||||
});
|
||||
|
||||
it("rejects URLs (scheme present)", () => {
|
||||
const result = AllowedHostsSchema.safeParse(["https://api.example.com"]);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects host:port", () => {
|
||||
// Port must be carried separately; the lexicon's grammar doesn't
|
||||
// include ports in the pattern.
|
||||
const result = AllowedHostsSchema.safeParse(["api.example.com:8080"]);
|
||||
// `:` is allowed-but-we-don't-validate at this layer (no
|
||||
// scheme/path/whitespace is the only structural test); a port
|
||||
// is technically passes the loose check. Document as intentional
|
||||
// — the lexicon's host-pattern grammar is the strict validator.
|
||||
// Update this test if we tighten the regex.
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects paths", () => {
|
||||
const result = AllowedHostsSchema.safeParse(["api.example.com/some/path"]);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects whitespace", () => {
|
||||
const result = AllowedHostsSchema.safeParse(["api.example.com "]);
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("StorageSchema", () => {
|
||||
it("accepts a simple single-field index", () => {
|
||||
const result = StorageSchema.parse({
|
||||
events: { indexes: ["timestamp"] },
|
||||
});
|
||||
expect(result).toEqual({ events: { indexes: ["timestamp"] } });
|
||||
});
|
||||
|
||||
it("accepts composite indexes", () => {
|
||||
const result = StorageSchema.parse({
|
||||
events: { indexes: [["collection", "timestamp"]] },
|
||||
});
|
||||
expect(result.events?.indexes).toEqual([["collection", "timestamp"]]);
|
||||
});
|
||||
|
||||
it("accepts uniqueIndexes alongside indexes", () => {
|
||||
const result = StorageSchema.parse({
|
||||
users: {
|
||||
indexes: ["createdAt"],
|
||||
uniqueIndexes: ["email"],
|
||||
},
|
||||
});
|
||||
expect(result.users?.uniqueIndexes).toEqual(["email"]);
|
||||
});
|
||||
|
||||
it("rejects an invalid collection name", () => {
|
||||
const result = StorageSchema.safeParse({
|
||||
"Bad-Name": { indexes: [] },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects an empty composite index", () => {
|
||||
const result = StorageSchema.safeParse({
|
||||
events: { indexes: [[]] },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects unknown keys on a collection config", () => {
|
||||
const result = StorageSchema.safeParse({
|
||||
events: { indexes: [], orderBy: "timestamp" },
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ManifestSchema cross-field rules", () => {
|
||||
const base = {
|
||||
slug: "my-plugin",
|
||||
version: "0.1.0",
|
||||
publisher: "example.com",
|
||||
license: "MIT",
|
||||
author: { name: "Jane Doe" },
|
||||
security: { email: "security@example.com" },
|
||||
};
|
||||
|
||||
it("network:request requires non-empty allowedHosts", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...base,
|
||||
capabilities: ["network:request"],
|
||||
allowedHosts: [],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues.some((i) => i.message.includes("non-empty `allowedHosts`"))).toBe(
|
||||
true,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("network:request with at least one allowed host passes", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...base,
|
||||
capabilities: ["network:request"],
|
||||
allowedHosts: ["api.example.com"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("network:request:unrestricted forbids allowedHosts", () => {
|
||||
// The lexicon's invariant: allowedHosts MUST NOT appear when
|
||||
// unrestricted is declared. The unrestricted capability already
|
||||
// grants any host.
|
||||
const result = ManifestSchema.safeParse({
|
||||
...base,
|
||||
capabilities: ["network:request:unrestricted"],
|
||||
allowedHosts: ["api.example.com"],
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(
|
||||
result.error.issues.some((i) => i.message.includes("`allowedHosts` must be empty")),
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("network:request:unrestricted with empty allowedHosts passes", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...base,
|
||||
capabilities: ["network:request:unrestricted"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("non-network capabilities don't require allowedHosts", () => {
|
||||
const result = ManifestSchema.safeParse({
|
||||
...base,
|
||||
capabilities: ["content:read"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ManifestSchema with the trust contract", () => {
|
||||
const base = {
|
||||
slug: "my-plugin",
|
||||
version: "0.1.0",
|
||||
publisher: "example.com",
|
||||
license: "MIT",
|
||||
author: { name: "Jane Doe" },
|
||||
security: { email: "security@example.com" },
|
||||
};
|
||||
|
||||
it("defaults capabilities/allowedHosts/storage to empty when omitted", () => {
|
||||
const result = ManifestSchema.parse(base);
|
||||
expect(result.capabilities).toEqual([]);
|
||||
expect(result.allowedHosts).toEqual([]);
|
||||
expect(result.storage).toEqual({});
|
||||
});
|
||||
|
||||
it("accepts a full trust contract", () => {
|
||||
const result = ManifestSchema.parse({
|
||||
...base,
|
||||
capabilities: ["content:read", "content:write", "network:request"],
|
||||
allowedHosts: ["api.example.com", "*.cdn.example.com"],
|
||||
storage: {
|
||||
events: { indexes: ["timestamp"] },
|
||||
users: { indexes: ["createdAt"], uniqueIndexes: ["email"] },
|
||||
},
|
||||
});
|
||||
expect(result.capabilities).toEqual(["content:read", "content:write", "network:request"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
/**
|
||||
* Mock atproto PDS for tests.
|
||||
*
|
||||
* Implements just enough of `com.atproto.repo.{getRecord,putRecord,listRecords,applyWrites}`
|
||||
* to drive `PublishingClient`-shaped tests without booting a real PDS or
|
||||
* going through OAuth.
|
||||
*
|
||||
* State is held in-memory keyed by AT URI (`at://<did>/<collection>/<rkey>`).
|
||||
* Test helpers expose the underlying record map so individual tests can seed
|
||||
* existing records, assert what was written, or verify call counts.
|
||||
*
|
||||
* Returns realistic atproto error payloads (`RecordNotFound`,
|
||||
* `InvalidRequest`, `RepoMismatch`) so the publish flow's error handling --
|
||||
* which keys off `ClientResponseError.error === "RecordNotFound"` -- runs
|
||||
* the same paths a real PDS would trigger.
|
||||
*
|
||||
* What this mock does NOT model:
|
||||
*
|
||||
* - Atproto MST signing or repo commit chain.
|
||||
* - DPoP or OAuth token validation.
|
||||
* - Lexicon-level record validation. (We don't run `mainSchema.safeParse`
|
||||
* against `validate: true` requests; tests that need that should hit a
|
||||
* real PDS.)
|
||||
*
|
||||
* What it DOES enforce:
|
||||
*
|
||||
* - The `repo` field on every write must match the mock's DID. A real PDS
|
||||
* ties auth to repo; ours has no auth, so we cross-check that callers
|
||||
* are writing where they think they are.
|
||||
* - rkey must match atproto's record-key alphabet (`[a-zA-Z0-9_~.:-]+`).
|
||||
* Used to verify that the registry CLI never sends rkeys that a real
|
||||
* PDS would reject.
|
||||
* - CIDs are derived from the record bytes so an overwrite with identical
|
||||
* bytes returns an identical CID (matching real PDS behaviour). Tests
|
||||
* that want to detect "did this actually rewrite the record?" should
|
||||
* compare bytes, not CIDs.
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { FetchHandlerObject } from "@atcute/client";
|
||||
|
||||
interface StoredRecord {
|
||||
uri: string;
|
||||
cid: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
interface MockPdsCall {
|
||||
method: string;
|
||||
pathname: string;
|
||||
body?: unknown;
|
||||
}
|
||||
|
||||
export interface MockPdsOptions {
|
||||
/**
|
||||
* The DID this mock pretends to host. Defaults to a fixed test DID; tests
|
||||
* that need a different one can override.
|
||||
*/
|
||||
did?: `did:${string}:${string}`;
|
||||
}
|
||||
|
||||
const RKEY_RE = /^[a-zA-Z0-9._~:-]+$/;
|
||||
|
||||
/**
|
||||
* In-memory mock PDS implementing the `FetchHandlerObject` contract that
|
||||
* `PublishingClient.fromHandler` accepts.
|
||||
*/
|
||||
export class MockPds implements FetchHandlerObject {
|
||||
readonly did: `did:${string}:${string}`;
|
||||
readonly records = new Map<string, StoredRecord>();
|
||||
readonly calls: MockPdsCall[] = [];
|
||||
|
||||
constructor(options: MockPdsOptions = {}) {
|
||||
this.did = options.did ?? "did:plc:test123";
|
||||
}
|
||||
|
||||
async handle(pathname: string, init: RequestInit): Promise<Response> {
|
||||
const url = new URL(pathname, "http://mock.test");
|
||||
const method = init.method?.toLowerCase() ?? "get";
|
||||
|
||||
const body = await readJsonBody(init.body);
|
||||
this.calls.push({ method, pathname, ...(body !== undefined ? { body } : {}) });
|
||||
|
||||
switch (url.pathname) {
|
||||
case "/xrpc/com.atproto.repo.getRecord":
|
||||
return this.#getRecord(url);
|
||||
case "/xrpc/com.atproto.repo.putRecord":
|
||||
return this.#putRecord(body);
|
||||
case "/xrpc/com.atproto.repo.listRecords":
|
||||
return this.#listRecords(url);
|
||||
case "/xrpc/com.atproto.repo.applyWrites":
|
||||
return this.#applyWrites(body);
|
||||
default:
|
||||
return jsonResponse(404, {
|
||||
error: "MethodNotFound",
|
||||
message: `mock-pds does not implement ${url.pathname}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Pre-seed a record under this DID. Helper for tests. */
|
||||
seedRecord(collection: string, rkey: string, value: unknown): StoredRecord {
|
||||
const uri = `at://${this.did}/${collection}/${rkey}`;
|
||||
const stored: StoredRecord = {
|
||||
uri,
|
||||
cid: cidOf(value),
|
||||
value,
|
||||
};
|
||||
this.records.set(uri, stored);
|
||||
return stored;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns calls matching the given XRPC method name, in order. Useful for
|
||||
* asserting that the publish flow made the expected XRPC sequence.
|
||||
*/
|
||||
callsTo(nsid: string): MockPdsCall[] {
|
||||
return this.calls.filter((c) => c.pathname.startsWith(`/xrpc/${nsid}`));
|
||||
}
|
||||
|
||||
#getRecord(url: URL): Response {
|
||||
const repo = url.searchParams.get("repo") ?? this.did;
|
||||
const collection = url.searchParams.get("collection");
|
||||
const rkey = url.searchParams.get("rkey");
|
||||
if (!collection || !rkey) {
|
||||
return jsonResponse(400, {
|
||||
error: "InvalidRequest",
|
||||
message: "missing collection or rkey",
|
||||
});
|
||||
}
|
||||
if (repo !== this.did) {
|
||||
return jsonResponse(400, {
|
||||
error: "RepoNotFound",
|
||||
message: `mock-pds hosts ${this.did}, not ${repo}`,
|
||||
});
|
||||
}
|
||||
const uri = `at://${repo}/${collection}/${rkey}`;
|
||||
const record = this.records.get(uri);
|
||||
if (!record) {
|
||||
return jsonResponse(400, {
|
||||
error: "RecordNotFound",
|
||||
message: `Could not locate record: ${uri}`,
|
||||
});
|
||||
}
|
||||
return jsonResponse(200, {
|
||||
uri: record.uri,
|
||||
cid: record.cid,
|
||||
value: record.value,
|
||||
});
|
||||
}
|
||||
|
||||
#putRecord(body: unknown): Response {
|
||||
if (!body || typeof body !== "object") {
|
||||
return jsonResponse(400, { error: "InvalidRequest", message: "missing body" });
|
||||
}
|
||||
const input = body as {
|
||||
repo: string;
|
||||
collection: string;
|
||||
rkey: string;
|
||||
record: unknown;
|
||||
swapRecord?: string;
|
||||
};
|
||||
const guard = this.#validateWriteTarget(input.repo, input.collection, input.rkey);
|
||||
if (guard) return guard;
|
||||
const uri = `at://${input.repo}/${input.collection}/${input.rkey}`;
|
||||
// swapRecord is atproto's CID-based CAS precondition. Enforce it so
|
||||
// tests can exercise the stale-write race a real PDS would catch.
|
||||
if (input.swapRecord !== undefined) {
|
||||
const current = this.records.get(uri);
|
||||
const currentCid = current?.cid ?? "";
|
||||
if (currentCid !== input.swapRecord) {
|
||||
return jsonResponse(400, {
|
||||
error: "InvalidSwap",
|
||||
message: `swapRecord mismatch: expected ${input.swapRecord}, current ${currentCid}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const stored: StoredRecord = {
|
||||
uri,
|
||||
cid: cidOf(input.record),
|
||||
value: input.record,
|
||||
};
|
||||
this.records.set(uri, stored);
|
||||
return jsonResponse(200, { uri: stored.uri, cid: stored.cid });
|
||||
}
|
||||
|
||||
#listRecords(url: URL): Response {
|
||||
const repo = url.searchParams.get("repo") ?? this.did;
|
||||
const collection = url.searchParams.get("collection");
|
||||
if (!collection) {
|
||||
return jsonResponse(400, {
|
||||
error: "InvalidRequest",
|
||||
message: "missing collection",
|
||||
});
|
||||
}
|
||||
if (repo !== this.did) {
|
||||
return jsonResponse(400, {
|
||||
error: "RepoNotFound",
|
||||
message: `mock-pds hosts ${this.did}, not ${repo}`,
|
||||
});
|
||||
}
|
||||
const prefix = `at://${repo}/${collection}/`;
|
||||
const records = [...this.records.values()].filter((r) => r.uri.startsWith(prefix));
|
||||
return jsonResponse(200, { records });
|
||||
}
|
||||
|
||||
#applyWrites(body: unknown): Response {
|
||||
if (!body || typeof body !== "object") {
|
||||
return jsonResponse(400, { error: "InvalidRequest", message: "missing body" });
|
||||
}
|
||||
const input = body as {
|
||||
repo: string;
|
||||
writes: Array<{
|
||||
$type: string;
|
||||
collection: string;
|
||||
rkey?: string;
|
||||
value?: unknown;
|
||||
}>;
|
||||
};
|
||||
if (input.repo !== this.did) {
|
||||
return jsonResponse(400, {
|
||||
error: "RepoNotFound",
|
||||
message: `mock-pds hosts ${this.did}, not ${input.repo}`,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate every operation up front; either all-or-nothing, like a real
|
||||
// applyWrites would be (atomic commit). Validation includes:
|
||||
// - rkey present
|
||||
// - rkey shape and repo match
|
||||
// - create ops must not collide with an existing record
|
||||
// - update ops must target an existing record
|
||||
//
|
||||
// CAVEAT: this mock evaluates create/update existence against the
|
||||
// pre-batch state. Whether a real PDS allows within-batch
|
||||
// dependencies (e.g. `create A; update A` in one applyWrites) is
|
||||
// not crisply documented in the atproto spec, and behaviour may
|
||||
// vary between PDS implementations. The publish flow doesn't
|
||||
// depend on within-batch dependencies (profile and release have
|
||||
// different rkeys), so the divergence (if any) doesn't affect
|
||||
// coverage today. If you change the publish flow to issue a
|
||||
// dependent batch, validate the assumption against an actual PDS
|
||||
// before relying on it.
|
||||
for (const op of input.writes ?? []) {
|
||||
if (!op.rkey) {
|
||||
return jsonResponse(400, {
|
||||
error: "InvalidRequest",
|
||||
message: `applyWrites op missing rkey: ${JSON.stringify(op)}`,
|
||||
});
|
||||
}
|
||||
const guard = this.#validateWriteTarget(input.repo, op.collection, op.rkey);
|
||||
if (guard) return guard;
|
||||
const uri = `at://${input.repo}/${op.collection}/${op.rkey}`;
|
||||
if (op.$type === "com.atproto.repo.applyWrites#create" && this.records.has(uri)) {
|
||||
return jsonResponse(400, {
|
||||
error: "RecordAlreadyExists",
|
||||
message: `cannot create ${uri}: a record with that key already exists`,
|
||||
});
|
||||
}
|
||||
if (op.$type === "com.atproto.repo.applyWrites#update" && !this.records.has(uri)) {
|
||||
return jsonResponse(400, {
|
||||
error: "RecordNotFound",
|
||||
message: `cannot update ${uri}: no record with that key exists`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Apply atomically: collect results, then commit.
|
||||
const results: Array<Record<string, unknown>> = [];
|
||||
for (const op of input.writes ?? []) {
|
||||
const uri = `at://${input.repo}/${op.collection}/${op.rkey}`;
|
||||
switch (op.$type) {
|
||||
case "com.atproto.repo.applyWrites#create":
|
||||
case "com.atproto.repo.applyWrites#update": {
|
||||
const stored: StoredRecord = {
|
||||
uri,
|
||||
cid: cidOf(op.value),
|
||||
value: op.value,
|
||||
};
|
||||
this.records.set(uri, stored);
|
||||
const resultType =
|
||||
op.$type === "com.atproto.repo.applyWrites#create"
|
||||
? "com.atproto.repo.applyWrites#createResult"
|
||||
: "com.atproto.repo.applyWrites#updateResult";
|
||||
results.push({
|
||||
$type: resultType,
|
||||
uri: stored.uri,
|
||||
cid: stored.cid,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "com.atproto.repo.applyWrites#delete":
|
||||
this.records.delete(uri);
|
||||
results.push({ $type: "com.atproto.repo.applyWrites#deleteResult" });
|
||||
break;
|
||||
default:
|
||||
return jsonResponse(400, {
|
||||
error: "InvalidRequest",
|
||||
message: `unknown applyWrites op $type: ${op.$type}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return jsonResponse(200, { results });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-check that every write targets this mock's repo and that the rkey
|
||||
* matches atproto's record-key alphabet. Real PDSes enforce both; without
|
||||
* these guards a test could write malformed records that a real PDS would
|
||||
* have rejected.
|
||||
*/
|
||||
#validateWriteTarget(repo: string, collection: string, rkey: string): Response | null {
|
||||
if (repo !== this.did) {
|
||||
return jsonResponse(400, {
|
||||
error: "RepoNotFound",
|
||||
message: `mock-pds hosts ${this.did}, not ${repo}`,
|
||||
});
|
||||
}
|
||||
if (!collection) {
|
||||
return jsonResponse(400, {
|
||||
error: "InvalidRequest",
|
||||
message: "missing collection",
|
||||
});
|
||||
}
|
||||
if (!RKEY_RE.test(rkey) || rkey.length === 0 || rkey.length > 512) {
|
||||
return jsonResponse(400, {
|
||||
error: "InvalidRequest",
|
||||
message: `rkey ${JSON.stringify(rkey)} is not a valid record key`,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock CID derivation: sha256 of the JSON-stringified value, prefixed with `b`
|
||||
* (base32 multibase) and shaped to look like a CIDv1. Crucially, identical
|
||||
* record values produce identical CIDs -- matching real PDS behaviour where
|
||||
* the CID is content-addressed.
|
||||
*/
|
||||
function cidOf(value: unknown): string {
|
||||
const hash = createHash("sha256")
|
||||
.update(JSON.stringify(value ?? null))
|
||||
.digest("hex");
|
||||
return `bafyreig${hash.slice(0, 52)}`;
|
||||
}
|
||||
|
||||
async function readJsonBody(body: BodyInit | null | undefined): Promise<unknown> {
|
||||
if (body === null || body === undefined) return undefined;
|
||||
if (typeof body === "string") {
|
||||
try {
|
||||
return JSON.parse(body) as unknown;
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
if (body instanceof Uint8Array || body instanceof ArrayBuffer) {
|
||||
const text = new TextDecoder().decode(
|
||||
body instanceof ArrayBuffer ? new Uint8Array(body) : body,
|
||||
);
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
// Streams, FormData, Blob, etc. -- not used in our tests.
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sha256Multihash } from "../src/multihash.js";
|
||||
|
||||
describe("sha256Multihash", () => {
|
||||
it("matches an independently-verified vector for sha2-256('hello world')", () => {
|
||||
// Cross-checked against the multiformats reference encoding:
|
||||
// sha256("hello world") = b94d27b9...e2efcde9
|
||||
// multihash bytes = 0x12 0x20 || digest
|
||||
// multibase('b' = base32) = bciqlstjhxgju2pqiuuxffv62pwv7vree57rxuu4a52iir55m4lx432i
|
||||
// If this drifts, the multibase prefix or the base32 alphabet changed.
|
||||
const out = sha256Multihash(new TextEncoder().encode("hello world"));
|
||||
expect(out).toBe("bciqlstjhxgju2pqiuuxffv62pwv7vree57rxuu4a52iir55m4lx432i");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 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({});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ArtifactError, buildArtifactRecord, measureImage } from "../src/publish/artifacts.js";
|
||||
|
||||
/**
|
||||
* A 1x1 transparent PNG. `image-size` reads the IHDR chunk from the header,
|
||||
* so the full image isn't needed — but this is a real, decodable PNG.
|
||||
*/
|
||||
const PNG_1x1 = Uint8Array.from(
|
||||
Buffer.from(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100ffff03000006000557bf0a8a0000000049454e44ae426082",
|
||||
"hex",
|
||||
),
|
||||
);
|
||||
|
||||
/** A 3x5 GIF87a. The logical-screen descriptor at bytes 6-9 carries the size. */
|
||||
const GIF_3x5 = Uint8Array.from(Buffer.from("4749463837610300050080000000000000ffffff", "hex"));
|
||||
|
||||
/**
|
||||
* A minimal ISOBMFF/AVIF header (`ftyp` brand `avif` + `meta>iprp>ipco>ispe`).
|
||||
* `image-size` reads the brand as the type and the `ispe` box as the
|
||||
* dimensions (64x48); no full image data is needed.
|
||||
*/
|
||||
function box(name: string, payload: Buffer): Buffer {
|
||||
const buf = Buffer.alloc(8 + payload.length);
|
||||
buf.writeUInt32BE(buf.length, 0);
|
||||
buf.write(name, 4, "ascii");
|
||||
payload.copy(buf, 8);
|
||||
return buf;
|
||||
}
|
||||
const AVIF_64x48 = (() => {
|
||||
const ftyp = box(
|
||||
"ftyp",
|
||||
Buffer.concat([
|
||||
Buffer.from("avif", "ascii"),
|
||||
Buffer.from([0, 0, 0, 0]),
|
||||
Buffer.from("avifmif1", "ascii"),
|
||||
]),
|
||||
);
|
||||
const ispePayload = Buffer.alloc(12);
|
||||
ispePayload.writeUInt32BE(64, 4);
|
||||
ispePayload.writeUInt32BE(48, 8);
|
||||
const ispe = box("ispe", ispePayload);
|
||||
const meta = box(
|
||||
"meta",
|
||||
Buffer.concat([Buffer.from([0, 0, 0, 0]), box("iprp", box("ipco", ispe))]),
|
||||
);
|
||||
return new Uint8Array(Buffer.concat([ftyp, meta]));
|
||||
})();
|
||||
|
||||
/** A minimal SVG with explicit width/height attributes — no longer an accepted type. */
|
||||
const SVG_12x8 = new TextEncoder().encode(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="12" height="8"></svg>',
|
||||
);
|
||||
|
||||
describe("measureImage", () => {
|
||||
it("reads PNG dimensions and content type from header bytes", () => {
|
||||
expect(measureImage(PNG_1x1)).toEqual({
|
||||
contentType: "image/png",
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads GIF dimensions and content type", () => {
|
||||
expect(measureImage(GIF_3x5)).toEqual({
|
||||
contentType: "image/gif",
|
||||
width: 3,
|
||||
height: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads AVIF dimensions and maps to image/avif", () => {
|
||||
expect(measureImage(AVIF_64x48)).toEqual({
|
||||
contentType: "image/avif",
|
||||
width: 64,
|
||||
height: 48,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects SVG (removed from the allowlist)", () => {
|
||||
try {
|
||||
measureImage(SVG_12x8);
|
||||
throw new Error("expected measureImage to throw");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ArtifactError);
|
||||
expect((error as ArtifactError).code).toBe("ARTIFACT_UNSUPPORTED");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects bytes that are not a recognised image", () => {
|
||||
const garbage = new TextEncoder().encode("this is not an image");
|
||||
expect(() => measureImage(garbage)).toThrow(ArtifactError);
|
||||
});
|
||||
|
||||
it("rejects an image whose format isn't in the allowlist", () => {
|
||||
// A BMP header — image-size recognises it, but it's not an allowed type.
|
||||
const bmp = Uint8Array.from(
|
||||
Buffer.from("424d3a0000000000000036000000280000000100000001000000", "hex"),
|
||||
);
|
||||
try {
|
||||
measureImage(bmp);
|
||||
throw new Error("expected measureImage to throw");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(ArtifactError);
|
||||
expect((error as ArtifactError).code).toBe("ARTIFACT_UNSUPPORTED");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildArtifactRecord", () => {
|
||||
it("writes url, checksum, contentType, and dimensions", () => {
|
||||
const record = buildArtifactRecord({
|
||||
bytes: PNG_1x1,
|
||||
url: "https://cdn.example.com/gallery/1.0.0/icon.png",
|
||||
});
|
||||
expect(record).toMatchObject({
|
||||
url: "https://cdn.example.com/gallery/1.0.0/icon.png",
|
||||
contentType: "image/png",
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
// Multibase-multihash sha2-256: base32 prefix `b`, 56 chars total.
|
||||
expect(record.checksum).toMatch(/^b[a-z2-7]+$/);
|
||||
expect(record.checksum).toHaveLength(56);
|
||||
});
|
||||
|
||||
it("carries lang through when set", () => {
|
||||
const record = buildArtifactRecord({
|
||||
bytes: PNG_1x1,
|
||||
url: "https://cdn.example.com/gallery/1.0.0/icon-fr.png",
|
||||
lang: "fr",
|
||||
});
|
||||
expect(record.lang).toBe("fr");
|
||||
});
|
||||
|
||||
it("omits lang when not set", () => {
|
||||
const record = buildArtifactRecord({
|
||||
bytes: PNG_1x1,
|
||||
url: "https://cdn.example.com/gallery/1.0.0/icon.png",
|
||||
});
|
||||
expect(record).not.toHaveProperty("lang");
|
||||
});
|
||||
|
||||
it("derives the same checksum the consumer would compute over the bytes", () => {
|
||||
const a = buildArtifactRecord({ bytes: PNG_1x1, url: "https://x/a.png" });
|
||||
const b = buildArtifactRecord({ bytes: PNG_1x1, url: "https://x/b.png" });
|
||||
expect(a.checksum).toBe(b.checksum);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTarball, MAX_FILE_SIZE } from "../src/bundle/utils.js";
|
||||
import { extractManifestFromTarballForTest } from "../src/commands/publish.js";
|
||||
|
||||
/**
|
||||
* Round-trip the tarball-extraction step from the publish CLI: build a real
|
||||
* gzipped tarball on disk, read its bytes, and feed them through the same
|
||||
* function the publish flow calls. This exercises the bundle-size-cap wiring
|
||||
* end-to-end (decompression + per-file accounting + total accounting) which
|
||||
* the pure `validateBundleSize` unit tests cannot.
|
||||
*/
|
||||
describe("extractManifestFromTarball (publish CLI)", () => {
|
||||
let stagingDir: string;
|
||||
let outDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
stagingDir = await mkdtemp(join(tmpdir(), "emdash-tar-staging-"));
|
||||
outDir = await mkdtemp(join(tmpdir(), "emdash-tar-out-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(stagingDir, { recursive: true, force: true });
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function buildTarball(files: Record<string, string | Uint8Array>): Promise<Uint8Array> {
|
||||
for (const [name, body] of Object.entries(files)) {
|
||||
const fullPath = join(stagingDir, name);
|
||||
await mkdir(dirname(fullPath), { recursive: true });
|
||||
await writeFile(fullPath, body);
|
||||
}
|
||||
const tarballPath = join(outDir, "test.tar.gz");
|
||||
await createTarball(stagingDir, tarballPath);
|
||||
return new Uint8Array(await readFile(tarballPath));
|
||||
}
|
||||
|
||||
const minimalManifest = JSON.stringify({
|
||||
id: "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: [],
|
||||
routes: [],
|
||||
admin: {},
|
||||
});
|
||||
|
||||
it("returns the manifest for a tarball within all caps", async () => {
|
||||
const bytes = await buildTarball({
|
||||
"manifest.json": minimalManifest,
|
||||
"backend.js": "export default {};\n",
|
||||
});
|
||||
const manifest = await extractManifestFromTarballForTest(bytes);
|
||||
expect(manifest.id).toBe("test-plugin");
|
||||
expect(manifest.version).toBe("1.0.0");
|
||||
});
|
||||
|
||||
it("rejects a tarball with a single file over the per-file cap", async () => {
|
||||
const bytes = await buildTarball({
|
||||
"manifest.json": minimalManifest,
|
||||
"backend.js": "x".repeat(MAX_FILE_SIZE + 1),
|
||||
});
|
||||
await expect(extractManifestFromTarballForTest(bytes)).rejects.toThrow(
|
||||
/violates bundle size caps[\s\S]*backend\.js/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a tarball whose total decompressed size exceeds the bundle cap", async () => {
|
||||
// Three files at exactly MAX_FILE_SIZE — each within per-file cap, but
|
||||
// total (3 × 128 KB = 384 KB) exceeds the 256 KB total cap.
|
||||
const bytes = await buildTarball({
|
||||
"manifest.json": minimalManifest,
|
||||
"a.js": "a".repeat(MAX_FILE_SIZE),
|
||||
"b.js": "b".repeat(MAX_FILE_SIZE),
|
||||
"c.js": "c".repeat(MAX_FILE_SIZE),
|
||||
});
|
||||
await expect(extractManifestFromTarballForTest(bytes)).rejects.toThrow(
|
||||
/violates bundle size caps[\s\S]*Bundle size/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a tarball with too many files even when each is tiny", async () => {
|
||||
const files: Record<string, string> = { "manifest.json": minimalManifest };
|
||||
for (let i = 0; i < 25; i++) files[`f-${String(i).padStart(2, "0")}.js`] = "x";
|
||||
const bytes = await buildTarball(files);
|
||||
await expect(extractManifestFromTarballForTest(bytes)).rejects.toThrow(
|
||||
/violates bundle size caps[\s\S]*contains \d+ files/,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not count non-file tar entries (symlinks etc.) toward the file cap", async () => {
|
||||
// Hand-build a tarball with 25 symlink entries plus one real file.
|
||||
// Symlinks have type "2" and size 0 in USTAR. The file cap is 20, so
|
||||
// counting symlinks would reject this; the filter should only see one
|
||||
// real file (manifest.json) and accept it.
|
||||
const { packTar } = await import("modern-tar");
|
||||
const { gzipSync } = await import("node:zlib");
|
||||
const symlinkEntries = Array.from({ length: 25 }, (_, i) => ({
|
||||
header: {
|
||||
name: `link-${i}`,
|
||||
size: 0,
|
||||
type: "symlink" as const,
|
||||
linkname: "manifest.json",
|
||||
},
|
||||
body: new Uint8Array(0),
|
||||
}));
|
||||
const tarBytes = await packTar([
|
||||
{
|
||||
header: { name: "manifest.json", size: minimalManifest.length, type: "file" as const },
|
||||
body: new TextEncoder().encode(minimalManifest),
|
||||
},
|
||||
...symlinkEntries,
|
||||
]);
|
||||
const gzipped = gzipSync(tarBytes);
|
||||
const manifest = await extractManifestFromTarballForTest(new Uint8Array(gzipped));
|
||||
expect(manifest.id).toBe("test-plugin");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
ArtifactUploadError,
|
||||
resolveReleaseArtifacts,
|
||||
type ArtifactUploader,
|
||||
} from "../src/publish/upload-artifacts.js";
|
||||
|
||||
const PNG_1x1 = Uint8Array.from(
|
||||
Buffer.from(
|
||||
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4890000000a49444154789c6360000002000100ffff03000006000557bf0a8a0000000049454e44ae426082",
|
||||
"hex",
|
||||
),
|
||||
);
|
||||
|
||||
interface Uploaded {
|
||||
url: string;
|
||||
contentType: string;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
function recordingUploader(): { uploader: ArtifactUploader; uploads: Uploaded[] } {
|
||||
const uploads: Uploaded[] = [];
|
||||
const uploader: ArtifactUploader = async ({ url, contentType, bytes }) => {
|
||||
uploads.push({ url, contentType, bytes: bytes.length });
|
||||
};
|
||||
return { uploader, uploads };
|
||||
}
|
||||
|
||||
describe("resolveReleaseArtifacts", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "emdash-artifacts-"));
|
||||
await writeFile(join(dir, "icon.png"), PNG_1x1);
|
||||
await writeFile(join(dir, "banner.png"), PNG_1x1);
|
||||
await writeFile(join(dir, "s1.png"), PNG_1x1);
|
||||
await writeFile(join(dir, "s2.png"), PNG_1x1);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns undefined when no artifacts are declared", async () => {
|
||||
const result = await resolveReleaseArtifacts({
|
||||
artifacts: undefined,
|
||||
manifestDir: dir,
|
||||
baseUrl: "https://cdn.example.com",
|
||||
slug: "gallery",
|
||||
version: "1.0.0",
|
||||
upload: recordingUploader().uploader,
|
||||
});
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uploads and records icon, banner, and a screenshot gallery", async () => {
|
||||
const { uploader, uploads } = recordingUploader();
|
||||
const result = await resolveReleaseArtifacts({
|
||||
artifacts: {
|
||||
icon: { file: "./icon.png" },
|
||||
banner: { file: "./banner.png" },
|
||||
screenshots: [{ file: "./s1.png" }, { file: "./s2.png", lang: "de" }],
|
||||
},
|
||||
manifestDir: dir,
|
||||
baseUrl: "https://cdn.example.com/",
|
||||
slug: "gallery",
|
||||
version: "1.0.0",
|
||||
upload: uploader,
|
||||
});
|
||||
|
||||
expect(result?.icon).toMatchObject({
|
||||
url: "https://cdn.example.com/gallery/1.0.0/icon-icon.png",
|
||||
contentType: "image/png",
|
||||
width: 1,
|
||||
height: 1,
|
||||
});
|
||||
expect(result?.banner?.url).toBe("https://cdn.example.com/gallery/1.0.0/banner-banner.png");
|
||||
expect(result?.screenshots).toHaveLength(2);
|
||||
expect(result?.screenshots?.[0]?.url).toBe(
|
||||
"https://cdn.example.com/gallery/1.0.0/screenshot-1-s1.png",
|
||||
);
|
||||
expect(result?.screenshots?.[1]?.lang).toBe("de");
|
||||
|
||||
// One PUT per artifact, with the measured content type.
|
||||
expect(uploads).toHaveLength(4);
|
||||
expect(uploads.every((u) => u.contentType === "image/png")).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves screenshot order", async () => {
|
||||
const { uploader } = recordingUploader();
|
||||
const result = await resolveReleaseArtifacts({
|
||||
artifacts: { screenshots: [{ file: "./s2.png" }, { file: "./s1.png" }] },
|
||||
manifestDir: dir,
|
||||
baseUrl: "https://cdn.example.com",
|
||||
slug: "gallery",
|
||||
version: "1.0.0",
|
||||
upload: uploader,
|
||||
});
|
||||
expect(result?.screenshots?.map((s) => s.url)).toEqual([
|
||||
"https://cdn.example.com/gallery/1.0.0/screenshot-1-s2.png",
|
||||
"https://cdn.example.com/gallery/1.0.0/screenshot-2-s1.png",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives same-basename screenshots in different dirs distinct upload URLs", async () => {
|
||||
await mkdir(join(dir, "light"));
|
||||
await mkdir(join(dir, "dark"));
|
||||
await writeFile(join(dir, "light", "shot.png"), PNG_1x1);
|
||||
await writeFile(join(dir, "dark", "shot.png"), PNG_1x1);
|
||||
|
||||
const { uploader, uploads } = recordingUploader();
|
||||
const result = await resolveReleaseArtifacts({
|
||||
artifacts: { screenshots: [{ file: "./light/shot.png" }, { file: "./dark/shot.png" }] },
|
||||
manifestDir: dir,
|
||||
baseUrl: "https://cdn.example.com",
|
||||
slug: "gallery",
|
||||
version: "1.0.0",
|
||||
upload: uploader,
|
||||
});
|
||||
|
||||
const urls = result?.screenshots?.map((s) => s.url) ?? [];
|
||||
expect(urls).toEqual([
|
||||
"https://cdn.example.com/gallery/1.0.0/screenshot-1-shot.png",
|
||||
"https://cdn.example.com/gallery/1.0.0/screenshot-2-shot.png",
|
||||
]);
|
||||
expect(new Set(urls).size).toBe(2);
|
||||
expect(new Set(uploads.map((u) => u.url)).size).toBe(2);
|
||||
});
|
||||
|
||||
it("gives an icon and a same-basename screenshot distinct upload URLs", async () => {
|
||||
await writeFile(join(dir, "image.png"), PNG_1x1);
|
||||
|
||||
const result = await resolveReleaseArtifacts({
|
||||
artifacts: { icon: { file: "./image.png" }, screenshots: [{ file: "./image.png" }] },
|
||||
manifestDir: dir,
|
||||
baseUrl: "https://cdn.example.com",
|
||||
slug: "gallery",
|
||||
version: "1.0.0",
|
||||
upload: recordingUploader().uploader,
|
||||
});
|
||||
|
||||
expect(result?.icon?.url).toBe("https://cdn.example.com/gallery/1.0.0/icon-image.png");
|
||||
expect(result?.screenshots?.[0]?.url).toBe(
|
||||
"https://cdn.example.com/gallery/1.0.0/screenshot-1-image.png",
|
||||
);
|
||||
expect(result?.icon?.url).not.toBe(result?.screenshots?.[0]?.url);
|
||||
});
|
||||
|
||||
it("accepts a filename that begins with two dots", async () => {
|
||||
await writeFile(join(dir, "..config.png"), PNG_1x1);
|
||||
|
||||
const result = await resolveReleaseArtifacts({
|
||||
artifacts: { icon: { file: "./..config.png" } },
|
||||
manifestDir: dir,
|
||||
baseUrl: "https://cdn.example.com",
|
||||
slug: "gallery",
|
||||
version: "1.0.0",
|
||||
upload: recordingUploader().uploader,
|
||||
});
|
||||
|
||||
expect(result?.icon?.url).toBe("https://cdn.example.com/gallery/1.0.0/icon-..config.png");
|
||||
});
|
||||
|
||||
it("rejects a file path that escapes the manifest directory", async () => {
|
||||
await expect(
|
||||
resolveReleaseArtifacts({
|
||||
artifacts: { icon: { file: "../secret.png" } },
|
||||
manifestDir: dir,
|
||||
baseUrl: "https://cdn.example.com",
|
||||
slug: "gallery",
|
||||
version: "1.0.0",
|
||||
upload: recordingUploader().uploader,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "ArtifactUploadError", code: "ARTIFACT_PATH_ESCAPE" });
|
||||
});
|
||||
|
||||
it("surfaces an unreadable file as a typed error", async () => {
|
||||
await expect(
|
||||
resolveReleaseArtifacts({
|
||||
artifacts: { icon: { file: "./missing.png" } },
|
||||
manifestDir: dir,
|
||||
baseUrl: "https://cdn.example.com",
|
||||
slug: "gallery",
|
||||
version: "1.0.0",
|
||||
upload: recordingUploader().uploader,
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "ArtifactUploadError", code: "ARTIFACT_FILE_UNREADABLE" });
|
||||
});
|
||||
|
||||
it("surfaces an upload failure as a typed error", async () => {
|
||||
const failing: ArtifactUploader = async () => {
|
||||
throw new Error("503 from CDN");
|
||||
};
|
||||
await expect(
|
||||
resolveReleaseArtifacts({
|
||||
artifacts: { icon: { file: "./icon.png" } },
|
||||
manifestDir: dir,
|
||||
baseUrl: "https://cdn.example.com",
|
||||
slug: "gallery",
|
||||
version: "1.0.0",
|
||||
upload: failing,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ArtifactUploadError);
|
||||
});
|
||||
|
||||
it("rejects a non-image file", async () => {
|
||||
await writeFile(join(dir, "notimage.png"), new TextEncoder().encode("nope"));
|
||||
await expect(
|
||||
resolveReleaseArtifacts({
|
||||
artifacts: { icon: { file: "./notimage.png" } },
|
||||
manifestDir: dir,
|
||||
baseUrl: "https://cdn.example.com",
|
||||
slug: "gallery",
|
||||
version: "1.0.0",
|
||||
upload: recordingUploader().uploader,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(ArtifactUploadError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,784 @@
|
||||
import type { PluginManifest } from "@emdash-cms/plugin-types";
|
||||
import { PublishingClient } from "@emdash-cms/registry-client";
|
||||
import type { Did } from "@emdash-cms/registry-client";
|
||||
import { NSID } from "@emdash-cms/registry-lexicons";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
PublishError,
|
||||
publishRelease,
|
||||
type ProfileBootstrap,
|
||||
type PublishOptions,
|
||||
} from "../src/publish/api.js";
|
||||
import { MockPds } from "./mock-pds.js";
|
||||
|
||||
const TEST_DID: Did = "did:plc:test123";
|
||||
|
||||
function buildManifest(overrides: Partial<PluginManifest> = {}): PluginManifest {
|
||||
return {
|
||||
id: "test-plugin",
|
||||
version: "1.0.0",
|
||||
capabilities: [],
|
||||
allowedHosts: [],
|
||||
storage: {},
|
||||
hooks: [],
|
||||
routes: [],
|
||||
admin: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPublisher(pds: MockPds): PublishingClient {
|
||||
return PublishingClient.fromHandler({
|
||||
handler: pds,
|
||||
did: pds.did,
|
||||
pds: "http://mock.test",
|
||||
});
|
||||
}
|
||||
|
||||
const validProfile: ProfileBootstrap = {
|
||||
license: "MIT",
|
||||
authorName: "Alice",
|
||||
securityEmail: "security@example.com",
|
||||
};
|
||||
|
||||
function buildOptions(pds: MockPds, overrides: Partial<PublishOptions> = {}): PublishOptions {
|
||||
return {
|
||||
publisher: buildPublisher(pds),
|
||||
did: pds.did,
|
||||
manifest: buildManifest(),
|
||||
checksum: "bciqtestchecksum",
|
||||
url: "https://example.com/test-plugin-1.0.0.tar.gz",
|
||||
profile: validProfile,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("publishRelease", () => {
|
||||
describe("first publish for a new slug", () => {
|
||||
it("creates the profile record and the release record", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const result = await publishRelease(buildOptions(pds));
|
||||
|
||||
expect(result.profileCreated).toBe(true);
|
||||
expect(result.releaseOverwritten).toBe(false);
|
||||
expect(result.slug).toBe("test-plugin");
|
||||
expect(result.profileUri).toBe(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
||||
expect(result.releaseUri).toBe(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
||||
|
||||
// Both records should be in the mock PDS.
|
||||
expect(pds.records.size).toBe(2);
|
||||
expect(pds.records.has(result.profileUri)).toBe(true);
|
||||
expect(pds.records.has(result.releaseUri)).toBe(true);
|
||||
});
|
||||
|
||||
it("commits both records in a single applyWrites batch (atomic)", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(buildOptions(pds));
|
||||
|
||||
// Atomicity contract: ONE applyWrites call, with both records as
|
||||
// writes. Two separate putRecord calls would mean an interrupted
|
||||
// publish could leave a profile without a release.
|
||||
const applyWrites = pds.callsTo("com.atproto.repo.applyWrites");
|
||||
expect(applyWrites).toHaveLength(1);
|
||||
const body = applyWrites[0]!.body as { writes: unknown[] };
|
||||
expect(body.writes).toHaveLength(2);
|
||||
|
||||
// putRecord must NOT be used for the publish path.
|
||||
expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("populates the profile record from ProfileBootstrap fields", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(
|
||||
buildOptions(pds, {
|
||||
profile: {
|
||||
license: "Apache-2.0",
|
||||
authorName: "Acme",
|
||||
authorUrl: "https://acme.example.com",
|
||||
authorEmail: "hi@acme.example.com",
|
||||
securityEmail: "security@acme.example.com",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
||||
expect(profile).toBeDefined();
|
||||
const value = profile!.value as {
|
||||
license: string;
|
||||
authors: Array<{ name: string; url?: string; email?: string }>;
|
||||
security: Array<{ email?: string; url?: string }>;
|
||||
slug: string;
|
||||
type: string;
|
||||
};
|
||||
expect(value.license).toBe("Apache-2.0");
|
||||
expect(value.authors[0]).toMatchObject({
|
||||
name: "Acme",
|
||||
url: "https://acme.example.com",
|
||||
email: "hi@acme.example.com",
|
||||
});
|
||||
expect(value.security[0]).toMatchObject({
|
||||
email: "security@acme.example.com",
|
||||
});
|
||||
expect(value.slug).toBe("test-plugin");
|
||||
expect(value.type).toBe("emdash-plugin");
|
||||
});
|
||||
|
||||
it("populates the release record with the artifact URL and checksum", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(buildOptions(pds));
|
||||
|
||||
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
||||
expect(release).toBeDefined();
|
||||
const value = release!.value as {
|
||||
package: string;
|
||||
version: string;
|
||||
artifacts: { package: { url: string; checksum: string; contentType?: string } };
|
||||
};
|
||||
expect(value.package).toBe("test-plugin");
|
||||
expect(value.version).toBe("1.0.0");
|
||||
expect(value.artifacts.package.url).toBe("https://example.com/test-plugin-1.0.0.tar.gz");
|
||||
expect(value.artifacts.package.checksum).toBe("bciqtestchecksum");
|
||||
expect(value.artifacts.package.contentType).toBe("application/gzip");
|
||||
});
|
||||
|
||||
it("writes release-level requires into the release record when provided", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(
|
||||
buildOptions(pds, {
|
||||
requires: { "env:emdash": ">=1.0.0", "env:astro": ">=4.16" },
|
||||
}),
|
||||
);
|
||||
|
||||
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
||||
const value = release!.value as { requires?: Record<string, string> };
|
||||
expect(value.requires).toEqual({ "env:emdash": ">=1.0.0", "env:astro": ">=4.16" });
|
||||
});
|
||||
|
||||
it("omits requires from the release record when empty or absent", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(buildOptions(pds, { requires: {} }));
|
||||
|
||||
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
||||
const value = release!.value as Record<string, unknown>;
|
||||
expect("requires" in value).toBe(false);
|
||||
});
|
||||
|
||||
it("hard-fails when license is missing, with no records written", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const opts = buildOptions(pds, {
|
||||
profile: { securityEmail: "security@example.com" },
|
||||
});
|
||||
await expect(publishRelease(opts)).rejects.toMatchObject({
|
||||
name: "PublishError",
|
||||
code: "PROFILE_BOOTSTRAP_MISSING_FIELD",
|
||||
});
|
||||
expect(pds.records.size).toBe(0);
|
||||
// applyWrites must not have been called -- a partial write would be
|
||||
// catastrophic for an atomic-publish promise.
|
||||
expect(pds.callsTo("com.atproto.repo.applyWrites")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("hard-fails when both securityEmail and securityUrl are missing", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const opts = buildOptions(pds, { profile: { license: "MIT" } });
|
||||
await expect(publishRelease(opts)).rejects.toMatchObject({
|
||||
name: "PublishError",
|
||||
code: "PROFILE_BOOTSTRAP_MISSING_FIELD",
|
||||
});
|
||||
expect(pds.records.size).toBe(0);
|
||||
expect(pds.callsTo("com.atproto.repo.applyWrites")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("accepts securityUrl as an alternative to securityEmail", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(
|
||||
buildOptions(pds, {
|
||||
profile: { license: "MIT", securityUrl: "https://example.com/security" },
|
||||
}),
|
||||
);
|
||||
const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
||||
const value = profile!.value as { security: Array<{ url?: string }> };
|
||||
expect(value.security[0]?.url).toBe("https://example.com/security");
|
||||
});
|
||||
});
|
||||
|
||||
describe("subsequent release for an existing slug", () => {
|
||||
const wellShapedProfile = {
|
||||
$type: NSID.packageProfile,
|
||||
id: `at://${TEST_DID}/${NSID.packageProfile}/test-plugin`,
|
||||
type: "emdash-plugin",
|
||||
license: "GPL-3.0-only",
|
||||
authors: [{ name: "Original Author" }],
|
||||
security: [{ email: "old-security@example.com" }],
|
||||
slug: "test-plugin",
|
||||
lastUpdated: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
it("preserves the existing profile's identity fields and bumps lastUpdated", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
pds.seedRecord(NSID.packageProfile, "test-plugin", wellShapedProfile);
|
||||
|
||||
const result = await publishRelease(
|
||||
buildOptions(pds, {
|
||||
manifest: buildManifest({ version: "1.1.0" }),
|
||||
url: "https://example.com/test-plugin-1.1.0.tar.gz",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.profileCreated).toBe(false);
|
||||
expect(result.releaseOverwritten).toBe(false);
|
||||
expect(result.releaseUri).toBe(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.1.0`);
|
||||
|
||||
// Identity fields preserved -- the existing profile owns these.
|
||||
const profileNow = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
||||
const value = profileNow!.value as Record<string, unknown> & { lastUpdated: string };
|
||||
expect(value.license).toBe(wellShapedProfile.license);
|
||||
expect(value.authors).toEqual(wellShapedProfile.authors);
|
||||
expect(value.security).toEqual(wellShapedProfile.security);
|
||||
// But lastUpdated has been bumped to a fresher timestamp.
|
||||
expect(value.lastUpdated).not.toBe(wellShapedProfile.lastUpdated);
|
||||
expect(new Date(value.lastUpdated).getTime()).toBeGreaterThan(
|
||||
new Date(wellShapedProfile.lastUpdated).getTime(),
|
||||
);
|
||||
|
||||
// applyWrites batch contains exactly TWO writes: profile update +
|
||||
// release create.
|
||||
const applyWrites = pds.callsTo("com.atproto.repo.applyWrites");
|
||||
expect(applyWrites).toHaveLength(1);
|
||||
const body = applyWrites[0]!.body as {
|
||||
writes: Array<{ $type: string; collection: string }>;
|
||||
};
|
||||
expect(body.writes).toHaveLength(2);
|
||||
const profileOp = body.writes.find((w) => w.collection === NSID.packageProfile);
|
||||
expect(profileOp?.$type).toBe("com.atproto.repo.applyWrites#update");
|
||||
});
|
||||
|
||||
it("does not touch a malformed existing profile (just writes the release)", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
// Existing profile is missing required fields. We refuse to update
|
||||
// it (overwriting bad bytes with slightly-different bad bytes is
|
||||
// worse than leaving it alone) and only write the release.
|
||||
pds.seedRecord(NSID.packageProfile, "test-plugin", { incomplete: true });
|
||||
|
||||
const result = await publishRelease(buildOptions(pds));
|
||||
expect(result.profileCreated).toBe(false);
|
||||
|
||||
const applyWrites = pds.callsTo("com.atproto.repo.applyWrites");
|
||||
const body = applyWrites[0]!.body as {
|
||||
writes: Array<{ collection: string }>;
|
||||
};
|
||||
expect(body.writes).toHaveLength(1);
|
||||
expect(body.writes[0]?.collection).toBe(NSID.packageRelease);
|
||||
});
|
||||
|
||||
it("reads existing profile and release in parallel before deciding", async () => {
|
||||
// Verifies the API issues both lookups; the actual order is
|
||||
// implementation detail (we use Promise.all). Two getRecord calls.
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
pds.seedRecord(NSID.packageProfile, "test-plugin", wellShapedProfile);
|
||||
await publishRelease(buildOptions(pds));
|
||||
const reads = pds.callsTo("com.atproto.repo.getRecord");
|
||||
expect(reads).toHaveLength(2);
|
||||
// Reads check both rkeys: slug (profile) and slug:version (release).
|
||||
const rkeys = reads
|
||||
.map((c) => new URL(c.pathname, "http://mock.test").searchParams.get("rkey"))
|
||||
.toSorted((a, b) => (a ?? "").localeCompare(b ?? ""));
|
||||
expect(rkeys).toEqual(["test-plugin", "test-plugin:1.0.0"]);
|
||||
});
|
||||
|
||||
it("reports profile fields that were ignored when reusing an existing profile", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
||||
|
||||
const result = await publishRelease(
|
||||
buildOptions(pds, {
|
||||
profile: {
|
||||
license: "Apache-2.0",
|
||||
authorName: "New Name",
|
||||
securityEmail: "new-security@example.com",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.profileCreated).toBe(false);
|
||||
expect(result.ignoredProfileFields.toSorted()).toEqual([
|
||||
"authorName",
|
||||
"license",
|
||||
"securityEmail",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports an empty ignoredProfileFields when profile is undefined", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
||||
|
||||
const result = await publishRelease(buildOptions(pds, { profile: undefined }));
|
||||
expect(result.profileCreated).toBe(false);
|
||||
expect(result.ignoredProfileFields).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("re-publishing an existing version", () => {
|
||||
it("refuses by default and preserves the original record bytes", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
||||
const original = pds.seedRecord(NSID.packageRelease, "test-plugin:1.0.0", {
|
||||
artifacts: { package: { url: "https://old.example.com/old.tar.gz" } },
|
||||
});
|
||||
|
||||
await expect(publishRelease(buildOptions(pds))).rejects.toMatchObject({
|
||||
name: "PublishError",
|
||||
code: "RELEASE_ALREADY_PUBLISHED",
|
||||
});
|
||||
|
||||
// Original bytes preserved (content-derived CID is identical for
|
||||
// identical bytes).
|
||||
const releaseNow = pds.records.get(original.uri);
|
||||
expect(releaseNow?.cid).toBe(original.cid);
|
||||
expect(releaseNow?.value).toEqual(original.value);
|
||||
// applyWrites must not have been called -- the refusal is upstream.
|
||||
expect(pds.callsTo("com.atproto.repo.applyWrites")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes slug and version in the error detail", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
||||
pds.seedRecord(NSID.packageRelease, "test-plugin:1.0.0", {});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await publishRelease(buildOptions(pds));
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PublishError);
|
||||
expect((caught as PublishError).detail).toEqual({
|
||||
slug: "test-plugin",
|
||||
version: "1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("overwrites the release record when allowOverwrite is true", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
||||
const original = pds.seedRecord(NSID.packageRelease, "test-plugin:1.0.0", {
|
||||
artifacts: { package: { url: "https://old.example.com/old.tar.gz" } },
|
||||
});
|
||||
|
||||
const result = await publishRelease(
|
||||
buildOptions(pds, {
|
||||
allowOverwrite: true,
|
||||
url: "https://example.com/new.tar.gz",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.releaseOverwritten).toBe(true);
|
||||
|
||||
// Compare content directly. Mock CIDs are content-derived, so we
|
||||
// don't lean on counter increments.
|
||||
const releaseNow = pds.records.get(original.uri);
|
||||
expect(releaseNow?.value).not.toEqual(original.value);
|
||||
const value = releaseNow!.value as { artifacts: { package: { url: string } } };
|
||||
expect(value.artifacts.package.url).toBe("https://example.com/new.tar.gz");
|
||||
});
|
||||
|
||||
it("issues an update operation (not create) when overwriting", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
||||
pds.seedRecord(NSID.packageRelease, "test-plugin:1.0.0", {});
|
||||
|
||||
await publishRelease(buildOptions(pds, { allowOverwrite: true }));
|
||||
|
||||
const applyWrites = pds.callsTo("com.atproto.repo.applyWrites");
|
||||
const body = applyWrites[0]!.body as {
|
||||
writes: Array<{ $type: string; collection: string }>;
|
||||
};
|
||||
const releaseOp = body.writes.find((w) => w.collection === NSID.packageRelease);
|
||||
expect(releaseOp?.$type).toBe("com.atproto.repo.applyWrites#update");
|
||||
});
|
||||
});
|
||||
|
||||
describe("synchronous validation runs before any network round-trip", () => {
|
||||
it("hard-fails on deprecated capabilities", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const opts = buildOptions(pds, {
|
||||
manifest: buildManifest({
|
||||
capabilities: ["network:fetch", "read:content"],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(publishRelease(opts)).rejects.toMatchObject({
|
||||
name: "PublishError",
|
||||
code: "DEPRECATED_CAPABILITY",
|
||||
});
|
||||
|
||||
expect(pds.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("hard-fails on a slug that doesn't match the lexicon constraint", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const opts = buildOptions(pds, {
|
||||
manifest: buildManifest({ id: "Bad Plugin Name" }),
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await publishRelease(opts);
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(PublishError);
|
||||
expect((caught as PublishError).code).toBe("INVALID_SLUG");
|
||||
expect(pds.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("hard-fails on a version with build-metadata suffix", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const opts = buildOptions(pds, {
|
||||
manifest: buildManifest({ version: "1.0.0+build.1" }),
|
||||
});
|
||||
|
||||
await expect(publishRelease(opts)).rejects.toMatchObject({
|
||||
name: "PublishError",
|
||||
code: "INVALID_VERSION",
|
||||
});
|
||||
expect(pds.calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("hard-fails on a version with path-traversal characters", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const opts = buildOptions(pds, {
|
||||
manifest: buildManifest({ version: "../etc/passwd" }),
|
||||
});
|
||||
|
||||
await expect(publishRelease(opts)).rejects.toMatchObject({
|
||||
name: "PublishError",
|
||||
code: "INVALID_VERSION",
|
||||
});
|
||||
expect(pds.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("release extension declaredAccess (install-consent contract)", () => {
|
||||
function getDeclaredAccess(pds: MockPds): Record<string, unknown> {
|
||||
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
||||
const ext = (
|
||||
release!.value as {
|
||||
extensions: Record<string, { declaredAccess: Record<string, unknown> }>;
|
||||
}
|
||||
).extensions[NSID.packageReleaseExtension];
|
||||
return ext!.declaredAccess;
|
||||
}
|
||||
|
||||
it("carries every facet, including hook registrations, when derived from a legacy bundle", async () => {
|
||||
// A bundle with no declaredAccess (a pre-migration tarball) whose hook
|
||||
// capabilities must still reach the record so the consent dialog can
|
||||
// show them.
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(
|
||||
buildOptions(pds, {
|
||||
manifest: buildManifest({
|
||||
capabilities: [
|
||||
"hooks.email-transport:register",
|
||||
"network:request",
|
||||
"hooks.email-events:register",
|
||||
],
|
||||
allowedHosts: ["api.cloudflare.com"],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(getDeclaredAccess(pds)).toEqual({
|
||||
network: { request: { allowedHosts: ["api.cloudflare.com"] } },
|
||||
email: { transport: {}, events: {} },
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the bundle manifest's declaredAccess verbatim when present", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const declaredAccess = { content: { read: {} }, email: { transport: {} } };
|
||||
await publishRelease(
|
||||
buildOptions(pds, {
|
||||
manifest: buildManifest({
|
||||
capabilities: ["content:read", "hooks.email-transport:register"],
|
||||
allowedHosts: [],
|
||||
declaredAccess,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(getDeclaredAccess(pds)).toEqual(declaredAccess);
|
||||
});
|
||||
});
|
||||
|
||||
describe("slug derivation", () => {
|
||||
it("strips a leading @ and replaces / with - for scoped npm names", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const result = await publishRelease(
|
||||
buildOptions(pds, { manifest: buildManifest({ id: "@acme/plugin" }) }),
|
||||
);
|
||||
expect(result.slug).toBe("acme-plugin");
|
||||
expect(result.releaseUri).toContain("/acme-plugin:");
|
||||
});
|
||||
|
||||
it("rejects scoped names whose translated slug starts with a non-letter", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const opts = buildOptions(pds, {
|
||||
// `@/plugin` translates to `-plugin`, which doesn't start with a letter.
|
||||
manifest: buildManifest({ id: "@/plugin" }),
|
||||
});
|
||||
await expect(publishRelease(opts)).rejects.toMatchObject({
|
||||
name: "PublishError",
|
||||
code: "INVALID_SLUG",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("structured profileInput (manifest package block)", () => {
|
||||
it("writes name, description, keywords, multi-author and multi-security on first publish", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(
|
||||
buildOptions(pds, {
|
||||
profile: undefined,
|
||||
profileInput: {
|
||||
license: "Apache-2.0",
|
||||
name: "Acme Forms",
|
||||
description: "Contact forms for EmDash.",
|
||||
keywords: ["forms", "contact"],
|
||||
authors: [
|
||||
{ name: "Acme Co.", url: "https://acme.example" },
|
||||
{ name: "Jane Doe", email: "jane@acme.example" },
|
||||
],
|
||||
security: [
|
||||
{ email: "security@acme.example" },
|
||||
{ url: "https://acme.example/security" },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
||||
const value = profile!.value as {
|
||||
license: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
keywords?: string[];
|
||||
authors: Array<{ name: string; url?: string; email?: string }>;
|
||||
security: Array<{ url?: string; email?: string }>;
|
||||
};
|
||||
expect(value.license).toBe("Apache-2.0");
|
||||
expect(value.name).toBe("Acme Forms");
|
||||
expect(value.description).toBe("Contact forms for EmDash.");
|
||||
expect(value.keywords).toEqual(["forms", "contact"]);
|
||||
expect(value.authors).toHaveLength(2);
|
||||
expect(value.authors[1]).toMatchObject({ name: "Jane Doe", email: "jane@acme.example" });
|
||||
expect(value.security).toHaveLength(2);
|
||||
expect(value.security[1]).toMatchObject({ url: "https://acme.example/security" });
|
||||
});
|
||||
|
||||
it("omits name, description and keywords when not provided", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(
|
||||
buildOptions(pds, {
|
||||
profile: undefined,
|
||||
profileInput: {
|
||||
license: "MIT",
|
||||
authors: [{ name: "Solo" }],
|
||||
security: [{ email: "s@example.com" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
||||
const value = profile!.value as Record<string, unknown>;
|
||||
expect("name" in value).toBe(false);
|
||||
expect("description" in value).toBe(false);
|
||||
expect("keywords" in value).toBe(false);
|
||||
});
|
||||
|
||||
it("writes resolved sections into the profile record on first publish", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(
|
||||
buildOptions(pds, {
|
||||
profile: undefined,
|
||||
profileInput: {
|
||||
license: "MIT",
|
||||
authors: [{ name: "Solo" }],
|
||||
security: [{ email: "s@example.com" }],
|
||||
sections: {
|
||||
description: "# About\n\nA great plugin.",
|
||||
installation: "Run `pnpm add`.",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
||||
const value = profile!.value as { sections?: Record<string, string> };
|
||||
expect(value.sections).toEqual({
|
||||
description: "# About\n\nA great plugin.",
|
||||
installation: "Run `pnpm add`.",
|
||||
});
|
||||
});
|
||||
|
||||
it("omits sections when none are provided or the map is empty", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(
|
||||
buildOptions(pds, {
|
||||
profile: undefined,
|
||||
profileInput: {
|
||||
license: "MIT",
|
||||
authors: [{ name: "Solo" }],
|
||||
security: [{ email: "s@example.com" }],
|
||||
sections: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const profile = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/test-plugin`);
|
||||
const value = profile!.value as Record<string, unknown>;
|
||||
expect("sections" in value).toBe(false);
|
||||
});
|
||||
|
||||
it("hard-fails when no security contact is provided", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await expect(
|
||||
publishRelease(
|
||||
buildOptions(pds, {
|
||||
profile: undefined,
|
||||
profileInput: { license: "MIT", authors: [{ name: "A" }], security: [] },
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({ name: "PublishError", code: "PROFILE_BOOTSTRAP_MISSING_FIELD" });
|
||||
expect(pds.records.size).toBe(0);
|
||||
});
|
||||
|
||||
it("reports structured field names as ignored on a subsequent publish", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
pds.seedRecord(NSID.packageProfile, "test-plugin", {});
|
||||
const result = await publishRelease(
|
||||
buildOptions(pds, {
|
||||
profile: undefined,
|
||||
profileInput: {
|
||||
license: "MIT",
|
||||
name: "Renamed",
|
||||
authors: [{ name: "A" }],
|
||||
security: [{ email: "s@example.com" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(result.profileCreated).toBe(false);
|
||||
expect(result.ignoredProfileFields.toSorted()).toEqual([
|
||||
"authors",
|
||||
"license",
|
||||
"name",
|
||||
"security",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("release repo", () => {
|
||||
it("writes the repo URL into the release record when provided", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(
|
||||
buildOptions(pds, { repo: "https://github.com/acme/emdash-forms/tree/v1.0.0" }),
|
||||
);
|
||||
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
||||
const value = release!.value as { repo?: string };
|
||||
expect(value.repo).toBe("https://github.com/acme/emdash-forms/tree/v1.0.0");
|
||||
});
|
||||
|
||||
it("omits repo from the release record when not provided", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(buildOptions(pds));
|
||||
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
||||
expect("repo" in (release!.value as Record<string, unknown>)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("release artifacts", () => {
|
||||
const icon = {
|
||||
url: "https://cdn.example.com/test-plugin/1.0.0/icon.png",
|
||||
checksum: "bciqiconchecksum",
|
||||
contentType: "image/png",
|
||||
width: 256,
|
||||
height: 256,
|
||||
};
|
||||
const banner = {
|
||||
url: "https://cdn.example.com/test-plugin/1.0.0/banner.png",
|
||||
checksum: "bciqbannerchecksum",
|
||||
contentType: "image/png",
|
||||
width: 1280,
|
||||
height: 320,
|
||||
};
|
||||
|
||||
interface ReleaseArtifactsMap {
|
||||
package?: { url: string; checksum: string };
|
||||
icon?: { url: string; checksum: string; width?: number; height?: number };
|
||||
banner?: { url: string; checksum: string; width?: number; height?: number };
|
||||
screenshots?: Array<{ url: string; checksum: string; width?: number; height?: number }>;
|
||||
}
|
||||
|
||||
function readArtifacts(pds: MockPds): ReleaseArtifactsMap {
|
||||
const release = pds.records.get(`at://${TEST_DID}/${NSID.packageRelease}/test-plugin:1.0.0`);
|
||||
return (release!.value as { artifacts: ReleaseArtifactsMap }).artifacts;
|
||||
}
|
||||
|
||||
it("writes icon and banner artifacts into the release record", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(buildOptions(pds, { artifacts: { icon, banner } }));
|
||||
const artifacts = readArtifacts(pds);
|
||||
expect(artifacts.icon).toMatchObject({
|
||||
url: icon.url,
|
||||
checksum: icon.checksum,
|
||||
contentType: "image/png",
|
||||
width: 256,
|
||||
height: 256,
|
||||
});
|
||||
expect(artifacts.banner).toMatchObject({ url: banner.url, width: 1280, height: 320 });
|
||||
});
|
||||
|
||||
it("writes a single screenshot as a one-element screenshots array", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const shot = {
|
||||
url: "https://cdn.example.com/test-plugin/1.0.0/s1.png",
|
||||
checksum: "bciqs1",
|
||||
contentType: "image/png",
|
||||
width: 800,
|
||||
height: 600,
|
||||
};
|
||||
await publishRelease(buildOptions(pds, { artifacts: { screenshots: [shot] } }));
|
||||
const artifacts = readArtifacts(pds);
|
||||
expect(artifacts.screenshots).toHaveLength(1);
|
||||
expect(artifacts.screenshots?.[0]).toMatchObject({
|
||||
url: shot.url,
|
||||
width: 800,
|
||||
height: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it("writes the full screenshot gallery as an ordered array", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const shots = [0, 1, 2].map((i) => ({
|
||||
url: `https://cdn.example.com/test-plugin/1.0.0/s${i}.png`,
|
||||
checksum: `bciqs${i}`,
|
||||
contentType: "image/png",
|
||||
width: 800,
|
||||
height: 600,
|
||||
}));
|
||||
await publishRelease(buildOptions(pds, { artifacts: { screenshots: shots } }));
|
||||
const artifacts = readArtifacts(pds);
|
||||
expect(artifacts.screenshots?.map((s) => s.url)).toEqual(shots.map((s) => s.url));
|
||||
});
|
||||
|
||||
it("keeps the package artifact when media artifacts are present", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(buildOptions(pds, { artifacts: { icon } }));
|
||||
const artifacts = readArtifacts(pds);
|
||||
expect(artifacts.package?.url).toBe("https://example.com/test-plugin-1.0.0.tar.gz");
|
||||
});
|
||||
|
||||
it("leaves the artifacts map at just the package when none are supplied", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await publishRelease(buildOptions(pds));
|
||||
const artifacts = readArtifacts(pds);
|
||||
expect(Object.keys(artifacts)).toEqual(["package"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Guard against drift between the Zod source of truth and the committed
|
||||
* JSON Schema at `schemas/emdash-plugin.schema.json`.
|
||||
*
|
||||
* The committed JSON Schema is shipped to users via
|
||||
* `node_modules/@emdash-cms/plugin-cli/schemas/emdash-plugin.schema.json`
|
||||
* so editors can offer completion and validation without running our CLI.
|
||||
* If a contributor changes the Zod schema and forgets to regenerate, this
|
||||
* test fails with a clear "run pnpm gen-schema" instruction.
|
||||
*
|
||||
* We assert byte-for-byte equality after re-running the same `toJSONSchema`
|
||||
* call the generator script uses. The generator's wrapping fields (`$id`,
|
||||
* `title`, `description`) are added on top so we replicate them here.
|
||||
*/
|
||||
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ManifestSchema } from "../src/manifest/schema.js";
|
||||
|
||||
const HERE = fileURLToPath(new URL(".", import.meta.url));
|
||||
const COMMITTED_SCHEMA_PATH = resolve(HERE, "..", "schemas", "emdash-plugin.schema.json");
|
||||
|
||||
describe("JSON Schema drift", () => {
|
||||
it("matches the output of z.toJSONSchema(ManifestSchema)", async () => {
|
||||
const committed = await readFile(COMMITTED_SCHEMA_PATH, "utf8");
|
||||
|
||||
// Reproduce the generator script's emit. If this diverges from
|
||||
// `scripts/gen-schema.ts`, update both (the script is the
|
||||
// canonical version users run; this is its mirror).
|
||||
const jsonSchema = z.toJSONSchema(ManifestSchema, {
|
||||
target: "draft-2020-12",
|
||||
reused: "ref",
|
||||
});
|
||||
const document = {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "https://emdashcms.com/schemas/emdash-plugin.schema.json",
|
||||
title: "EmDash plugin manifest (emdash-plugin.jsonc)",
|
||||
description:
|
||||
"Authoring format for publishing plugins to the EmDash plugin registry. Translated to the on-wire atproto record format at publish time. See https://github.com/emdash-cms/emdash/issues/1028.",
|
||||
...jsonSchema,
|
||||
};
|
||||
const regenerated = `${JSON.stringify(document, null, "\t")}\n`;
|
||||
|
||||
// On failure, the diff is enormous and unreadable. Surface a
|
||||
// pointer to the fix command instead.
|
||||
if (committed !== regenerated) {
|
||||
throw new Error(
|
||||
"schemas/emdash-plugin.schema.json is out of date with the Zod schema.\n" +
|
||||
"Run: pnpm --filter @emdash-cms/plugin-cli gen-schema\n" +
|
||||
"Then commit the result.",
|
||||
);
|
||||
}
|
||||
expect(committed).toBe(regenerated);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,538 @@
|
||||
/**
|
||||
* Coverage for the programmatic `updatePackage` API.
|
||||
*
|
||||
* Runs against the in-memory `MockPds` rather than a real PDS so the
|
||||
* publish/update boundary is exercised against the same atproto contract
|
||||
* the publish tests use.
|
||||
*/
|
||||
|
||||
import { PublishingClient } from "@emdash-cms/registry-client";
|
||||
import type { Did } from "@emdash-cms/registry-client";
|
||||
import { NSID } from "@emdash-cms/registry-lexicons";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildPackageCandidate,
|
||||
updatePackage,
|
||||
UpdatePackageError,
|
||||
type PackageUpdateInput,
|
||||
} from "../src/update-package/api.js";
|
||||
import { MockPds } from "./mock-pds.js";
|
||||
|
||||
const TEST_DID: Did = "did:plc:test123";
|
||||
const SLUG = "test-plugin";
|
||||
|
||||
function buildPublisher(pds: MockPds): PublishingClient {
|
||||
return PublishingClient.fromHandler({
|
||||
handler: pds,
|
||||
did: pds.did,
|
||||
pds: "http://mock.test",
|
||||
});
|
||||
}
|
||||
|
||||
function seedProfile(
|
||||
pds: MockPds,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
const record: Record<string, unknown> = {
|
||||
$type: NSID.packageProfile,
|
||||
id: `at://${TEST_DID}/${NSID.packageProfile}/${SLUG}`,
|
||||
type: "emdash-plugin",
|
||||
license: "MIT",
|
||||
authors: [{ name: "Alice" }],
|
||||
security: [{ email: "security@example.com" }],
|
||||
slug: SLUG,
|
||||
lastUpdated: "2024-01-01T00:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
pds.seedRecord(NSID.packageProfile, SLUG, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
function input(overrides: Partial<PackageUpdateInput> = {}): PackageUpdateInput {
|
||||
return {
|
||||
license: "MIT",
|
||||
authors: [{ name: "Alice" }],
|
||||
security: [{ email: "security@example.com" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const FIXED_NOW = new Date("2026-05-20T12:00:00.000Z");
|
||||
const now = () => FIXED_NOW;
|
||||
|
||||
describe("updatePackage", () => {
|
||||
describe("dry-run", () => {
|
||||
it("returns an empty diff when manifest matches the existing profile", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
seedProfile(pds);
|
||||
|
||||
const result = await updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input(),
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.diffs).toEqual([]);
|
||||
expect(result.written).toBe(false);
|
||||
expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects a license change without writing", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
seedProfile(pds);
|
||||
|
||||
const result = await updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input({ license: "Apache-2.0" }),
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.written).toBe(false);
|
||||
expect(result.diffs).toEqual([{ field: "license", before: "MIT", after: "Apache-2.0" }]);
|
||||
expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("detects a name-only change", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
seedProfile(pds, { name: "Old Name" });
|
||||
|
||||
const result = await updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input({ name: "New Name" }),
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.diffs).toEqual([{ field: "name", before: "Old Name", after: "New Name" }]);
|
||||
});
|
||||
|
||||
it("treats keywords reordering as a diff (arrays are ordered)", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
seedProfile(pds, { keywords: ["a", "b"] });
|
||||
|
||||
const result = await updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input({ keywords: ["b", "a"] }),
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.diffs).toEqual([{ field: "keywords", before: ["a", "b"], after: ["b", "a"] }]);
|
||||
});
|
||||
|
||||
it("detects multi-field changes (description, keywords, authors)", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
seedProfile(pds, {
|
||||
description: "old description",
|
||||
keywords: ["one"],
|
||||
});
|
||||
|
||||
const result = await updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input({
|
||||
description: "new description",
|
||||
keywords: ["one", "two"],
|
||||
authors: [{ name: "Alice", url: "https://alice.example.com" }, { name: "Bob" }],
|
||||
}),
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.written).toBe(false);
|
||||
const fields = result.diffs.map((d) => d.field).toSorted();
|
||||
expect(fields).toEqual(["authors", "description", "keywords"]);
|
||||
});
|
||||
|
||||
it("preserves an optional field that the manifest omits (no silent deletion)", async () => {
|
||||
// Mirrors publish semantics: a missing-from-manifest key isn't a
|
||||
// request to delete. Without this, accidentally removing the
|
||||
// `description` line in emdash-plugin.jsonc would silently wipe
|
||||
// a value the publisher put on the record.
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
seedProfile(pds, { description: "old description" });
|
||||
|
||||
const result = await updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input(),
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.diffs).toEqual([]);
|
||||
expect((result.candidate as { description?: string }).description).toBe("old description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("apply", () => {
|
||||
it("writes the candidate via putRecord and bumps lastUpdated when there are diffs", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
seedProfile(pds);
|
||||
|
||||
const result = await updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input({ license: "Apache-2.0" }),
|
||||
apply: true,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.written).toBe(true);
|
||||
const puts = pds.callsTo("com.atproto.repo.putRecord");
|
||||
expect(puts).toHaveLength(1);
|
||||
const body = puts[0]!.body as {
|
||||
repo: string;
|
||||
collection: string;
|
||||
rkey: string;
|
||||
record: Record<string, unknown>;
|
||||
validate?: boolean;
|
||||
swapRecord?: string;
|
||||
};
|
||||
expect(body.repo).toBe(TEST_DID);
|
||||
expect(body.collection).toBe(NSID.packageProfile);
|
||||
expect(body.rkey).toBe(SLUG);
|
||||
expect(body.validate).toBe(false);
|
||||
// Optimistic concurrency: the write carries the CID we read,
|
||||
// so a concurrent edit between read and write surfaces as
|
||||
// STALE_RECORD instead of silently winning.
|
||||
expect(typeof body.swapRecord).toBe("string");
|
||||
expect(body.swapRecord).not.toBe("");
|
||||
expect(body.record.license).toBe("Apache-2.0");
|
||||
expect(body.record.lastUpdated).toBe(FIXED_NOW.toISOString());
|
||||
// Identity fields preserved verbatim.
|
||||
expect(body.record.$type).toBe(NSID.packageProfile);
|
||||
expect(body.record.id).toBe(`at://${TEST_DID}/${NSID.packageProfile}/${SLUG}`);
|
||||
expect(body.record.slug).toBe(SLUG);
|
||||
expect(body.record.type).toBe("emdash-plugin");
|
||||
});
|
||||
|
||||
it("does NOT write when there are no diffs, even with apply:true", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
seedProfile(pds);
|
||||
|
||||
const result = await updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input(),
|
||||
apply: true,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.written).toBe(false);
|
||||
expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves unknown forward-compatible fields on the existing record", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
seedProfile(pds, {
|
||||
sections: { description: "long-form text" },
|
||||
someFutureField: { nested: true },
|
||||
});
|
||||
|
||||
await updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input({ license: "Apache-2.0" }),
|
||||
apply: true,
|
||||
now,
|
||||
});
|
||||
|
||||
const stored = pds.records.get(`at://${TEST_DID}/${NSID.packageProfile}/${SLUG}`);
|
||||
const value = stored!.value as Record<string, unknown>;
|
||||
expect(value.sections).toEqual({ description: "long-form text" });
|
||||
expect(value.someFutureField).toEqual({ nested: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("refusals", () => {
|
||||
it("throws PACKAGE_NOT_FOUND when no record exists at the slug and no other profile is found", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
await expect(
|
||||
updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input(),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "UpdatePackageError",
|
||||
code: "PACKAGE_NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws POSSIBLE_RENAME listing every other package when the slug is missing", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
// Publisher already has THREE packages under other slugs. The
|
||||
// diagnostic should list all of them — a publisher with multiple
|
||||
// plugins shouldn't see a misleading "you might have renamed X"
|
||||
// pointer at an unrelated package.
|
||||
for (const slug of ["alpha", "beta", "gamma"]) {
|
||||
pds.seedRecord(NSID.packageProfile, slug, {
|
||||
$type: NSID.packageProfile,
|
||||
id: `at://${TEST_DID}/${NSID.packageProfile}/${slug}`,
|
||||
type: "emdash-plugin",
|
||||
license: "MIT",
|
||||
authors: [{ name: "Alice" }],
|
||||
security: [{ email: "security@example.com" }],
|
||||
slug,
|
||||
lastUpdated: "2024-01-01T00:00:00.000Z",
|
||||
});
|
||||
}
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: "new-slug",
|
||||
input: input(),
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(UpdatePackageError);
|
||||
const err = caught as UpdatePackageError;
|
||||
expect(err.code).toBe("POSSIBLE_RENAME");
|
||||
expect(err.message).toContain("alpha");
|
||||
expect(err.message).toContain("beta");
|
||||
expect(err.message).toContain("gamma");
|
||||
expect(err.detail).toMatchObject({
|
||||
existingSlugs: expect.arrayContaining(["alpha", "beta", "gamma"]),
|
||||
});
|
||||
expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("rethrows auth failures from the sibling scan instead of degrading to PACKAGE_NOT_FOUND", async () => {
|
||||
// Mock a PDS that returns AuthRequired on listRecords. The
|
||||
// rename-detection path should surface the real cause so the
|
||||
// user is prompted to re-login rather than seeing a confusing
|
||||
// PACKAGE_NOT_FOUND that didn't actually check.
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
const wrappedHandle = pds.handle.bind(pds);
|
||||
pds.handle = async (pathname, init) => {
|
||||
if (pathname.includes("listRecords")) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "AuthRequired", message: "session expired" }),
|
||||
{ status: 401, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return wrappedHandle(pathname, init);
|
||||
};
|
||||
|
||||
await expect(
|
||||
updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input(),
|
||||
}),
|
||||
).rejects.toMatchObject({ error: "AuthRequired" });
|
||||
});
|
||||
|
||||
it("throws STALE_RECORD when the record changes between read and write", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
seedProfile(pds);
|
||||
// Intercept putRecord to simulate a concurrent edit having
|
||||
// changed the record's CID. A real PDS returns InvalidSwap in
|
||||
// this case.
|
||||
const wrappedHandle = pds.handle.bind(pds);
|
||||
pds.handle = async (pathname, init) => {
|
||||
if (pathname.includes("putRecord")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "InvalidSwap",
|
||||
message: "Record was modified",
|
||||
}),
|
||||
{ status: 400, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
return wrappedHandle(pathname, init);
|
||||
};
|
||||
|
||||
await expect(
|
||||
updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input({ license: "Apache-2.0" }),
|
||||
apply: true,
|
||||
now,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "UpdatePackageError",
|
||||
code: "STALE_RECORD",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws INVALID_INPUT when authors is empty", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
seedProfile(pds);
|
||||
|
||||
await expect(
|
||||
updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input({ authors: [] }),
|
||||
apply: true,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "UpdatePackageError",
|
||||
code: "INVALID_INPUT",
|
||||
});
|
||||
|
||||
// Fails before any network access — no read, no write.
|
||||
expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0);
|
||||
expect(pds.callsTo("com.atproto.repo.getRecord")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("throws INVALID_INPUT when a security entry has neither url nor email", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
seedProfile(pds);
|
||||
|
||||
await expect(
|
||||
updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input({ security: [{}] }),
|
||||
apply: true,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "UpdatePackageError",
|
||||
code: "INVALID_INPUT",
|
||||
});
|
||||
});
|
||||
|
||||
it("throws PACKAGE_INVALID when the existing record fails lexicon validation", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
pds.seedRecord(NSID.packageProfile, SLUG, { incomplete: true });
|
||||
|
||||
await expect(
|
||||
updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input(),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "UpdatePackageError",
|
||||
code: "PACKAGE_INVALID",
|
||||
});
|
||||
|
||||
// And nothing was written.
|
||||
expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("throws SLUG_MISMATCH when the existing record's slug disagrees with the manifest's", async () => {
|
||||
const pds = new MockPds({ did: TEST_DID });
|
||||
// Seed at the manifest's rkey but with a different slug field. A
|
||||
// real aggregator would reject this; we refuse to make it worse.
|
||||
seedProfile(pds, { slug: "different-slug" });
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await updatePackage({
|
||||
publisher: buildPublisher(pds),
|
||||
slug: SLUG,
|
||||
input: input({ license: "Apache-2.0" }),
|
||||
apply: true,
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(UpdatePackageError);
|
||||
expect((caught as UpdatePackageError).code).toBe("SLUG_MISMATCH");
|
||||
expect(pds.callsTo("com.atproto.repo.putRecord")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildPackageCandidate", () => {
|
||||
it("does not bump lastUpdated when there are no diffs", () => {
|
||||
const existing = {
|
||||
$type: NSID.packageProfile,
|
||||
license: "MIT",
|
||||
authors: [{ name: "Alice" }],
|
||||
security: [{ email: "security@example.com" }],
|
||||
slug: SLUG,
|
||||
type: "emdash-plugin",
|
||||
lastUpdated: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
const { candidate, diffs } = buildPackageCandidate({
|
||||
existing,
|
||||
input: input(),
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
expect(diffs).toEqual([]);
|
||||
expect(candidate.lastUpdated).toBe("2024-01-01T00:00:00.000Z");
|
||||
});
|
||||
|
||||
it("bumps lastUpdated only when there are diffs", () => {
|
||||
const existing = {
|
||||
$type: NSID.packageProfile,
|
||||
license: "MIT",
|
||||
authors: [{ name: "Alice" }],
|
||||
security: [{ email: "security@example.com" }],
|
||||
slug: SLUG,
|
||||
type: "emdash-plugin",
|
||||
lastUpdated: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
const { candidate, diffs } = buildPackageCandidate({
|
||||
existing,
|
||||
input: input({ license: "Apache-2.0" }),
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
expect(diffs).toHaveLength(1);
|
||||
expect(candidate.lastUpdated).toBe(FIXED_NOW.toISOString());
|
||||
});
|
||||
|
||||
it("treats deeply-equal author lists as no change", () => {
|
||||
const existing = {
|
||||
$type: NSID.packageProfile,
|
||||
license: "MIT",
|
||||
authors: [{ name: "Alice", url: "https://alice.example.com" }],
|
||||
security: [{ email: "security@example.com" }],
|
||||
slug: SLUG,
|
||||
type: "emdash-plugin",
|
||||
lastUpdated: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
const { diffs } = buildPackageCandidate({
|
||||
existing,
|
||||
input: input({
|
||||
authors: [{ name: "Alice", url: "https://alice.example.com" }],
|
||||
}),
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
expect(diffs).toEqual([]);
|
||||
});
|
||||
|
||||
it("writes changed sections and preserves them when the manifest omits them", () => {
|
||||
const existing = {
|
||||
$type: NSID.packageProfile,
|
||||
license: "MIT",
|
||||
authors: [{ name: "Alice" }],
|
||||
security: [{ email: "security@example.com" }],
|
||||
slug: SLUG,
|
||||
type: "emdash-plugin",
|
||||
sections: { description: "Old description." },
|
||||
lastUpdated: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
const updated = buildPackageCandidate({
|
||||
existing,
|
||||
input: input({ sections: { description: "New description.", faq: "Q & A." } }),
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
expect(updated.candidate.sections).toEqual({
|
||||
description: "New description.",
|
||||
faq: "Q & A.",
|
||||
});
|
||||
expect(updated.diffs.map((d) => d.field)).toContain("sections");
|
||||
|
||||
const preserved = buildPackageCandidate({
|
||||
existing,
|
||||
input: input(),
|
||||
now: FIXED_NOW,
|
||||
});
|
||||
expect(preserved.candidate.sections).toEqual({ description: "Old description." });
|
||||
expect(preserved.diffs.map((d) => d.field)).not.toContain("sections");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Coverage for `validatePublishUrl`. The function is the syntactic SSRF
|
||||
* guard in publish: the FIRST line of defence before the redirect-loop
|
||||
* DNS check. Bugs here have historically produced silent SSRF (see
|
||||
* round-3 -> round-4 review notes), so the test net is dense around
|
||||
* IPv6-literal, IPv4-mapped, and bracketed-host edge cases.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { validatePublishUrlForTest as validate } from "../src/commands/publish.js";
|
||||
|
||||
describe("validatePublishUrl", () => {
|
||||
describe("rejects non-public IPv4 literals", () => {
|
||||
it.each([
|
||||
"https://127.0.0.1/x",
|
||||
"https://10.0.0.1/x",
|
||||
"https://192.168.1.1/x",
|
||||
"https://172.16.0.1/x",
|
||||
"https://172.31.255.255/x",
|
||||
"https://169.254.169.254/x", // AWS metadata
|
||||
"https://0.0.0.0/x",
|
||||
"https://100.64.0.1/x", // CGNAT
|
||||
])("blocks %s", (url) => {
|
||||
expect(validate(url)).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each(["https://172.15.0.1/x", "https://172.32.0.1/x"])(
|
||||
"allows %s (just outside private range)",
|
||||
(url) => {
|
||||
expect(validate(url)).toBeNull();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("rejects non-public IPv6 literals", () => {
|
||||
// These are the cases the round-3 fix claimed to handle but which
|
||||
// silently passed because Node's URL parser keeps the brackets and
|
||||
// normalises any embedded IPv4 to two hex groups.
|
||||
it.each([
|
||||
"https://[::1]/x", // loopback
|
||||
"https://[fc00::1]/x", // ULA
|
||||
"https://[fd00::1]/x", // ULA
|
||||
"https://[fe80::1]/x", // link-local
|
||||
"https://[::ffff:169.254.169.254]/x", // IPv4-mapped to AWS metadata
|
||||
"https://[::ffff:127.0.0.1]/x", // IPv4-mapped loopback
|
||||
"https://[::ffff:10.0.0.1]/x", // IPv4-mapped RFC1918
|
||||
"https://[::169.254.169.254]/x", // IPv4-compatible (deprecated)
|
||||
"https://[64:ff9b::169.254.169.254]/x", // NAT64 well-known prefix
|
||||
])("blocks %s", (url) => {
|
||||
expect(validate(url)).not.toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://[2001:db8::1]/x", // documentation prefix; not on the deny list
|
||||
"https://[2606:4700::1]/x", // public Cloudflare
|
||||
])("allows %s", (url) => {
|
||||
expect(validate(url)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects localhost / .local hostnames", () => {
|
||||
expect(validate("https://localhost/x")).not.toBeNull();
|
||||
expect(validate("https://my-machine.local/x")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects FQDN trailing-dot variants of denied hostnames", () => {
|
||||
// Round-5 finding M-1: mDNS resolvers respond to both `foo.local`
|
||||
// and `foo.local.`; the syntactic guard has to canonicalise.
|
||||
expect(validate("https://localhost./x")).not.toBeNull();
|
||||
expect(validate("https://my-machine.local./x")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not over-block public IPv6 with private-looking suffix", () => {
|
||||
// Round-5 finding M-2: a generic "decode last two hex groups as v4"
|
||||
// fallback would false-positive on `2001:db8::a00:1` (last 32 bits
|
||||
// decode to 10.0.0.1). The fix restricts the embedded-v4 check to
|
||||
// known v4-carrying prefixes (NAT64, 6to4).
|
||||
expect(validate("https://[2001:db8::a00:1]/x")).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects 6to4 with embedded private v4", () => {
|
||||
// 6to4 prefix 2002:: encodes the v4 in groups 2-3 (not the suffix).
|
||||
// `2002:0a00:0001::1` -> v4 10.0.0.1 -> private.
|
||||
expect(validate("https://[2002:a00:1::1]/x")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects RFC 8215 local-use NAT64 prefix with embedded private v4", () => {
|
||||
// `64:ff9b:1::/48` is the local-use NAT64 prefix; embedded v4 is in
|
||||
// the last 32 bits. `64:ff9b:1:0:0:0:a9fe:a9fe` -> 169.254.169.254.
|
||||
expect(validate("https://[64:ff9b:1:0:0:0:a9fe:a9fe]/x")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects http://", () => {
|
||||
expect(validate("http://example.com/x")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects file://, ftp://", () => {
|
||||
expect(validate("file:///etc/passwd")).not.toBeNull();
|
||||
expect(validate("ftp://example.com/x")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects malformed URLs", () => {
|
||||
expect(validate("not a url")).not.toBeNull();
|
||||
expect(validate("")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("allows ordinary public https URLs", () => {
|
||||
expect(validate("https://example.com/file.tar.gz")).toBeNull();
|
||||
expect(validate("https://github.com/owner/repo/releases/download/v1/x.tar.gz")).toBeNull();
|
||||
expect(validate("https://cdn.example.com:8443/path?query=1#frag")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user