b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
304 lines
12 KiB
TypeScript
304 lines
12 KiB
TypeScript
/**
|
|
* 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;
|
|
}
|