Files
suaveplan/packages/api-stack/api/api-manifest/src/dep-graph/dep-graph.test.ts
2026-05-27 23:29:53 +01:00

151 lines
4.9 KiB
TypeScript

import { describe, expect, it } from "@suaveplan/testing/runner";
import { MANIFEST_CODES, ManifestError } from "../error-codes/error-codes.js";
import type { PluginManifest } from "../schema/plugin-manifest.js";
import { MINIMAL_FIXTURE } from "../testing/conformance.js";
import { resolveInstallOrder } from "./dep-graph.js";
function mk(
name: string,
version: string,
deps: Record<string, string> = {}
): PluginManifest {
return { ...MINIMAL_FIXTURE, name, version, dependsOn: deps };
}
describe("resolveInstallOrder", () => {
it("orders dependencies before dependents", { timeout: 5000 }, () => {
const a = mk("a", "1.0.0");
const b = mk("b", "1.0.0", { a: "^1" });
const c = mk("c", "1.0.0", { b: "^1" });
const order = resolveInstallOrder([c, a, b]).map((m) => m.name);
expect(order).toEqual(["a", "b", "c"]);
});
it("order is deterministic across shuffles", { timeout: 5000 }, () => {
const a = mk("a", "1.0.0");
const b = mk("b", "1.0.0");
const c = mk("c", "1.0.0", { a: "^1", b: "^1" });
const d = mk("d", "1.0.0", { c: "^1" });
const sets = [
[a, b, c, d],
[d, c, b, a],
[b, a, d, c],
[c, a, d, b],
];
const first = resolveInstallOrder(sets[0] as PluginManifest[]).map(
(m) => m.name
);
for (const set of sets) {
const got = resolveInstallOrder(set as PluginManifest[]).map(
(m) => m.name
);
expect(got).toEqual(first);
}
});
it("reports cycle path in details", { timeout: 5000 }, () => {
const a = mk("a", "1.0.0", { b: "^1" });
const b = mk("b", "1.0.0", { a: "^1" });
let caught: unknown;
try {
resolveInstallOrder([a, b]);
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ManifestError);
const err = caught as ManifestError;
expect(err.code).toBe(MANIFEST_CODES.DEPENDENCY_CYCLE);
const path = err.context?.path as readonly string[];
expect(path[0]).toBe(path[path.length - 1]);
expect(path.length).toBeGreaterThanOrEqual(2);
});
it("reports missing dependency", { timeout: 5000 }, () => {
const a = mk("a", "1.0.0", { ghost: "^1" });
let caught: unknown;
try {
resolveInstallOrder([a]);
} catch (e) {
caught = e;
}
expect((caught as ManifestError).code).toBe(
MANIFEST_CODES.MISSING_DEPENDENCY
);
});
it("reports duplicate plugin name", { timeout: 5000 }, () => {
const a = mk("a", "1.0.0");
const a2 = mk("a", "1.0.1");
let caught: unknown;
try {
resolveInstallOrder([a, a2]);
} catch (e) {
caught = e;
}
expect((caught as ManifestError).code).toBe(
MANIFEST_CODES.MISSING_DEPENDENCY
);
});
it("reports version mismatch (range unsatisfied)", {
timeout: 5000,
}, () => {
const a = mk("a", "1.0.0");
const b = mk("b", "1.0.0", { a: "^2" });
let caught: unknown;
try {
resolveInstallOrder([a, b]);
} catch (e) {
caught = e;
}
expect((caught as ManifestError).code).toBe(
MANIFEST_CODES.DEPENDENCY_VERSION_MISMATCH
);
});
it("reports version mismatch (unparseable range)", {
timeout: 5000,
}, () => {
const a = mk("a", "1.0.0");
const b = mk("b", "1.0.0", { a: ">=1.0.0 <2.0.0" });
let caught: unknown;
try {
resolveInstallOrder([a, b]);
} catch (e) {
caught = e;
}
expect((caught as ManifestError).code).toBe(
MANIFEST_CODES.DEPENDENCY_VERSION_MISMATCH
);
});
it("empty input yields empty output", { timeout: 5000 }, () => {
expect(resolveInstallOrder([])).toEqual([]);
});
it("manifest without dependsOn field resolves as a root", {
timeout: 5000,
}, () => {
const noDeps: PluginManifest = { ...MINIMAL_FIXTURE, name: "lonely" };
// ensure dependsOn key is absent (MINIMAL_FIXTURE already omits it)
expect("dependsOn" in noDeps).toBe(false);
const ordered = resolveInstallOrder([noDeps]);
expect(ordered.map((m) => m.name)).toEqual(["lonely"]);
});
it("triangle cycle path includes all nodes", { timeout: 5000 }, () => {
const a = mk("a", "1.0.0", { b: "^1" });
const b = mk("b", "1.0.0", { c: "^1" });
const c = mk("c", "1.0.0", { a: "^1" });
let caught: unknown;
try {
resolveInstallOrder([a, b, c]);
} catch (e) {
caught = e;
}
const path = (caught as ManifestError).context
?.path as readonly string[];
expect(path[0]).toBe(path[path.length - 1]);
});
});