mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-09 13:21:47 +00:00
419 lines
13 KiB
TypeScript
419 lines
13 KiB
TypeScript
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<Buffer> {
|
|
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"]);
|
|
});
|
|
});
|