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 = {} ): 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]); }); });