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

266 lines
9.1 KiB
TypeScript

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,
};
}