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
266 lines
9.1 KiB
TypeScript
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,
|
|
};
|
|
}
|