import { afterEach, describe, expect, it } from "vitest"; import crypto from "node:crypto"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import * as tar from "tar"; import { parseBundledPlugins, loadBundledPlugins, fetchArtifact, extractArtifact, pinsFromIndex, buildBundledIndex, findLocalPluginBuilds, isLocalBuildStale, localSdkBuildCommand, rebuildLocalPlugins, copyLocalBuild, } from "./lib/bundled-plugins.cjs"; const cleanups: Array<() => void> = []; function tempDir(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-bundled-")); cleanups.push(() => fs.rmSync(dir, { recursive: true, force: true })); return dir; } afterEach(() => { while (cleanups.length) cleanups.pop()?.(); }); const SHA = "a".repeat(64); async function artifactFor(id: string): Promise { const src = tempDir(); fs.writeFileSync(path.join(src, "manifest.json"), JSON.stringify({ id })); fs.mkdirSync(path.join(src, "dist")); fs.writeFileSync(path.join(src, "dist", "backend.js"), "export {}\n"); const file = path.join(tempDir(), `${id}.tmxplug`); await tar.c({ gzip: true, file, cwd: src }, ["manifest.json", "dist"]); return fs.readFileSync(file); } const sha256 = (buffer: Buffer) => crypto.createHash("sha256").update(buffer).digest("hex"); describe("parseBundledPlugins", () => { it("accepts tmxplug entries", () => { const { plugins, problems } = parseBundledPlugins({ plugins: [ { id: "two", source: "tmxplug", url: "https://x/two.tmxplug", sha256: SHA, }, { id: "three", source: "tmxplug", path: "vendor/three.tmxplug", sha256: SHA, }, ], }); expect(problems).toEqual([]); expect(plugins.map((p: { id: string }) => p.id)).toEqual(["two", "three"]); }); it("reports bad entries", () => { const { problems } = parseBundledPlugins({ plugins: [ { id: "nohash", source: "tmxplug", url: "https://x" }, { id: "plain", source: "tmxplug", url: "http://x", sha256: SHA }, { id: "ok", source: "tmxplug", path: "a", sha256: SHA }, { id: "ok", source: "tmxplug", path: "a", sha256: SHA }, { id: "odd", source: "workspace" }, ], }); expect(problems).toEqual([ "nohash needs a lowercase hex sha256", "plain needs exactly one of an https url or a path", "ok is listed twice", 'odd has an unknown source "workspace"', ]); }); it("reads onboarding defaults", () => { const { plugins, problems } = parseBundledPlugins({ plugins: [ { id: "kept", source: "tmxplug", path: "a", sha256: SHA, onboarding: { recommended: true }, }, { id: "desk", source: "tmxplug", path: "a", sha256: SHA, onboarding: { recommended: "desktop" }, }, { id: "ask", source: "tmxplug", path: "a", sha256: SHA, onboarding: { recommended: true, consent: true }, }, { id: "env", source: "tmxplug", path: "a", sha256: SHA, onboarding: { enabledByEnv: ["OIDC_CLIENT_ID"] }, }, { id: "plain", source: "tmxplug", path: "a", sha256: SHA }, ], }); expect(problems).toEqual([]); expect(plugins.map((p: { onboarding: unknown }) => p.onboarding)).toEqual([ { recommended: true, consent: false }, { recommended: "desktop", consent: false }, { recommended: true, consent: true }, { recommended: false, consent: false, enabledByEnv: ["OIDC_CLIENT_ID"] }, null, ]); }); it("rejects bad onboarding defaults", () => { const entry = { source: "tmxplug", path: "a", sha256: SHA }; const { plugins, problems } = parseBundledPlugins({ plugins: [ { ...entry, id: "a", onboarding: "yes" }, { ...entry, id: "b", onboarding: { recommended: "web" } }, { ...entry, id: "c", onboarding: { consent: 1 } }, { ...entry, id: "d", onboarding: { pinned: true } }, { ...entry, id: "e", onboarding: { enabledByEnv: ["lower"] } }, { ...entry, id: "f", onboarding: { enabledByEnv: [] } }, ], }); expect(plugins).toEqual([]); expect(problems).toEqual([ "a has an onboarding field that is not an object", 'b onboarding.recommended must be true, false or "desktop"', "c onboarding.consent must be a boolean", 'd has an unknown onboarding field "pinned"', "e onboarding.enabledByEnv must be a list of environment variable names", "f onboarding.enabledByEnv must be a list of environment variable names", ]); }); it("reads the list in this repo", () => { const root = path.resolve(__dirname, ".."); expect(() => loadBundledPlugins(root)).not.toThrow(); }); }); describe("tmxplug entries", () => { it("unpacks an artifact that matches its pinned sha256", async () => { const root = tempDir(); const buffer = await artifactFor("demo"); fs.writeFileSync(path.join(root, "demo.tmxplug"), buffer); const fetched = await fetchArtifact( { id: "demo", path: "demo.tmxplug", sha256: sha256(buffer) }, root, ); const out = path.join(root, "dist", "plugins", "demo"); await extractArtifact(fetched, out, "demo"); expect(fs.existsSync(path.join(out, "dist", "backend.js"))).toBe(true); }); it("refuses an artifact whose sha256 differs", async () => { const root = tempDir(); fs.writeFileSync( path.join(root, "demo.tmxplug"), await artifactFor("demo"), ); await expect( fetchArtifact({ id: "demo", path: "demo.tmxplug", sha256: SHA }, root), ).rejects.toThrow(/bundled-plugins.json pins/); }); it("downloads once and reuses the cache", async () => { const root = tempDir(); const buffer = await artifactFor("demo"); let calls = 0; const fakeFetch = async () => { calls++; return new Response(buffer); }; const entry = { id: "demo", url: "https://example.test/demo.tmxplug", sha256: sha256(buffer), }; await fetchArtifact(entry, root, fakeFetch); await fetchArtifact(entry, root, fakeFetch); expect(calls).toBe(1); }); it("refuses an artifact for a different id", async () => { const out = path.join(tempDir(), "demo"); await expect( extractArtifact(await artifactFor("other"), out, "demo"), ).rejects.toThrow(/is for "other"/); }); }); describe("buildBundledIndex", () => { it("lists the onboarding defaults of staged plugins only", () => { const plugins = [ { id: "a", onboarding: { recommended: true, consent: false } }, { id: "b", onboarding: { recommended: "desktop", consent: false } }, { id: "c", onboarding: null }, ]; expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({ version: 1, plugins: { a: { onboarding: { recommended: true, consent: false } } }, }); }); }); describe("pinsFromIndex", () => { const index = { plugins: [ { id: "beta", versions: [ { version: "1.1.0", url: "https://example.test/beta-1.1.0.tmxplug", sha256: "b".repeat(64), }, { version: "1.0.0", url: "https://example.test/beta-1.0.0.tmxplug", sha256: SHA, }, ], }, { id: "alpha", versions: [ { version: "1.0.0", url: "https://example.test/alpha-1.0.0.tmxplug", sha256: "c".repeat(64), }, ], }, ], }; it("pins the newest version of every plugin, sorted by id", () => { const pins = pinsFromIndex(index, null); expect(pins.map((pin) => pin.id)).toEqual(["alpha", "beta"]); expect(pins[1]).toEqual({ id: "beta", source: "tmxplug", url: "https://example.test/beta-1.1.0.tmxplug", sha256: "b".repeat(64), }); expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]); }); it("keeps the onboarding defaults of an existing pin", () => { const current = [ { id: "beta", onboarding: { recommended: true, consent: false } }, ]; const pins = pinsFromIndex(index, null, current); expect(pins[1].onboarding).toEqual({ recommended: true, consent: false }); expect(pins[0]).not.toHaveProperty("onboarding"); }); it("pins only the ids asked for", () => { expect(pinsFromIndex(index, ["beta"]).map((pin) => pin.id)).toEqual([ "beta", ]); }); it("refuses an id the index does not list", () => { expect(() => pinsFromIndex(index, ["gamma"])).toThrow( /not in the registry/, ); }); }); describe("local plugin builds", () => { it("finds built repos by manifest id and skips unbuilt ones", () => { const dir = tempDir(); const built = path.join(dir, "Plugin-Files"); fs.mkdirSync(path.join(built, "dist"), { recursive: true }); fs.writeFileSync( path.join(built, "manifest.json"), JSON.stringify({ id: "file-manager" }), ); const unbuilt = path.join(dir, "Plugin-Other"); fs.mkdirSync(unbuilt); fs.writeFileSync( path.join(unbuilt, "manifest.json"), JSON.stringify({ id: "other" }), ); const builds = findLocalPluginBuilds(dir); expect([...builds.keys()]).toEqual(["file-manager"]); expect(findLocalPluginBuilds(null).size).toBe(0); }); it("copies only the files a bundled plugin needs", () => { const repo = tempDir(); fs.mkdirSync(path.join(repo, "dist")); fs.writeFileSync(path.join(repo, "dist", "frontend.js"), "x"); fs.writeFileSync(path.join(repo, "manifest.json"), "{}"); fs.mkdirSync(path.join(repo, "src")); const out = path.join(tempDir(), "file-manager"); copyLocalBuild(repo, out); expect(fs.existsSync(path.join(out, "dist", "frontend.js"))).toBe(true); expect(fs.existsSync(path.join(out, "manifest.json"))).toBe(true); expect(fs.existsSync(path.join(out, "src"))).toBe(false); }); }); describe("rebuilding local plugins", () => { function repo(dir: string, name: string, id: string): string { const root = path.join(dir, name); fs.mkdirSync(path.join(root, "src"), { recursive: true }); fs.mkdirSync(path.join(root, "node_modules")); fs.writeFileSync(path.join(root, "manifest.json"), JSON.stringify({ id })); fs.writeFileSync(path.join(root, "src", "index.ts"), "x"); return root; } function touch(file: string, secondsAgo: number) { const time = new Date(Date.now() - secondsAgo * 1000); fs.utimesSync(file, time, time); } it("is stale with no dist or with a source newer than the build", () => { const root = repo(tempDir(), "Plugin-A", "a"); expect(isLocalBuildStale(root)).toBe(true); fs.mkdirSync(path.join(root, "dist")); fs.writeFileSync(path.join(root, "dist", "frontend.js"), "x"); touch(path.join(root, "manifest.json"), 60); touch(path.join(root, "src", "index.ts"), 60); expect(isLocalBuildStale(root)).toBe(false); touch(path.join(root, "src", "index.ts"), 0); touch(path.join(root, "dist", "frontend.js"), 30); expect(isLocalBuildStale(root)).toBe(true); }); it("is stale when the SDK CLI changed after the build", () => { const root = repo(tempDir(), "Plugin-A", "a"); const cli = path.join(tempDir(), "cli"); fs.mkdirSync(cli); fs.writeFileSync(path.join(cli, "index.mjs"), "x"); fs.mkdirSync(path.join(root, "dist")); fs.writeFileSync(path.join(root, "dist", "frontend.js"), "x"); touch(path.join(root, "manifest.json"), 60); touch(path.join(root, "src", "index.ts"), 60); touch(path.join(cli, "index.mjs"), 60); expect(isLocalBuildStale(root, cli)).toBe(false); touch(path.join(root, "dist", "frontend.js"), 30); touch(path.join(cli, "index.mjs"), 0); expect(isLocalBuildStale(root, cli)).toBe(true); expect(isLocalBuildStale(root)).toBe(false); }); it("builds with this checkout's SDK CLI, not the plugin's own", () => { const [command, args] = localSdkBuildCommand(); expect(command).toBe(process.execPath); expect(args).toEqual([ path.resolve( __dirname, "..", "packages", "plugin-sdk", "cli", "index.mjs", ), "build", ]); }); it("builds only stale repos for bundled ids that have node_modules", () => { const dir = tempDir(); repo(dir, "Plugin-A", "a"); repo(dir, "Plugin-B", "b"); const noDeps = repo(dir, "Plugin-C", "c"); fs.rmSync(path.join(noDeps, "node_modules"), { recursive: true }); const run: string[] = []; const rebuilt = rebuildLocalPlugins( dir, new Set(["a", "c"]), (_repo: string, id: string) => run.push(id), ); expect(rebuilt).toEqual(["a"]); expect(run).toEqual(["a"]); expect(rebuildLocalPlugins(null, new Set(["a"]), () => {})).toEqual([]); }); it("builds every stale repo when no ids are given", () => { const dir = tempDir(); repo(dir, "Plugin-A", "a"); repo(dir, "Plugin-B", "b"); const run: string[] = []; rebuildLocalPlugins(dir, null, (_repo: string, id: string) => run.push(id)); expect(run.sort()).toEqual(["a", "b"]); }); });