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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:23:53 +08:00
commit b3a7f98e5a
3020 changed files with 935484 additions and 0 deletions
@@ -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");
});
});
+265
View File
@@ -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,
};
}
+37
View File
@@ -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"]);
});
});
+377
View File
@@ -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);
});
});
+784
View File
@@ -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();
});
});