/*
RFC-1036: tests for ScopeManager — registry lifecycle, scope resolution order,
adopt for manual scopes, error codes, registry limit, disposal.
- Not a contract source of truth — the module under test owns the authoritative behavior; fixtures are minimal scaffolding only.
- RFC-1036: initial test suite for ScopeManager.
*/
import { describe, it, expect, afterEach } from "vitest";
import {
createScopeManager,
ScopeError,
resetDefaultScopeManager,
getDefaultScopeManager,
} from "../scope.ts";
import type {
ComponentDeclaration,
ComponentScope,
ScopeContext,
} from "@warpgogol/werkstatt-shared/component";
import { SCOPE_ERROR_CODES } from "@warpgogol/werkstatt-shared/component";
import type { Sha256Digest } from "@warpgogol/werkstatt-shared/fingerprint";
const VALID_SHA =
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" as Sha256Digest;
function makeManifest(
componentId: string,
scope: ComponentScope,
capability?: string,
): ComponentDeclaration {
return {
schema: "werkstatt/component-declaration@1",
componentId: componentId as `${string}/${string}`,
version: "1.0.0",
artifactHash: VALID_SHA,
scope,
provides: capability
? [
{
capability: capability as `${string}/${string}`,
version: "1.0.0",
schemaHash: "sha256:def",
},
]
: [],
requires: [],
requestedGrants: [],
effects: [],
isolation: { tier: 0, adapterId: null },
resources: [],
priority: 0,
active: true,
};
}
describe("ScopeManager", () => {
describe("registry lifecycle", () => {
it("creates a registry on getRegistry and returns the same instance for the same context", () => {
const mgr = createScopeManager();
const ctx: ScopeContext = { scope: "per-mission", missionId: "m001" };
const r1 = mgr.getRegistry(ctx);
const r2 = mgr.getRegistry(ctx);
expect(r1).toBe(r2);
});
it("creates distinct registries for different contexts", () => {
const mgr = createScopeManager();
const r1 = mgr.getRegistry({ scope: "per-mission", missionId: "m001" });
const r2 = mgr.getRegistry({ scope: "per-mission", missionId: "m002" });
expect(r1).not.toBe(r2);
});
it("disposeRegistry removes the registry", () => {
const mgr = createScopeManager();
const ctx: ScopeContext = { scope: "per-mission", missionId: "m001" };
const r = mgr.getRegistry(ctx);
r.register(makeManifest("comp/a", "per-mission", "cap/test"));
mgr.disposeRegistry(ctx);
const scopes = mgr.inspect();
expect(scopes).toHaveLength(0);
});
it("disposeRegistry is a no-op for non-existent registry", () => {
const mgr = createScopeManager();
expect(() =>
mgr.disposeRegistry({ scope: "per-mission", missionId: "nonexistent" }),
).not.toThrow();
});
});
describe("register and resolve", () => {
it("register adds a component to the registry", () => {
const mgr = createScopeManager();
const r = mgr.getRegistry({ scope: "per-workshop" });
r.register(makeManifest("comp/a", "per-workshop", "cap/test"));
expect(r.list()).toHaveLength(1);
});
it("resolve finds a component by capability", () => {
const mgr = createScopeManager();
const r = mgr.getRegistry({ scope: "per-workshop" });
r.register(makeManifest("comp/a", "per-workshop", "cap/test"));
const found = r.resolve("cap/test" as `${string}/${string}`);
expect(found).not.toBeNull();
expect(found!.componentId).toBe("comp/a");
});
it("resolve returns null for unknown capability", () => {
const mgr = createScopeManager();
const r = mgr.getRegistry({ scope: "per-workshop" });
r.register(makeManifest("comp/a", "per-workshop", "cap/test"));
expect(r.resolve("cap/unknown" as `${string}/${string}`)).toBeNull();
});
it("register throws SCOPE-01 on duplicate component", () => {
const mgr = createScopeManager();
const r = mgr.getRegistry({ scope: "per-workshop" });
r.register(makeManifest("comp/a", "per-workshop", "cap/test"));
expect(() => r.register(makeManifest("comp/a", "per-workshop", "cap/test"))).toThrow(
ScopeError,
);
try {
r.register(makeManifest("comp/a", "per-workshop", "cap/test"));
} catch (e) {
expect((e as ScopeError).code).toBe(SCOPE_ERROR_CODES.SCOPE_01);
}
});
it("register throws SCOPE-01 when manifest scope does not match registry scope", () => {
const mgr = createScopeManager();
const r = mgr.getRegistry({ scope: "per-workshop" });
expect(() => r.register(makeManifest("comp/a", "per-mission", "cap/test"))).toThrow(
ScopeError,
);
try {
r.register(makeManifest("comp/a", "per-mission", "cap/test"));
} catch (e) {
expect((e as ScopeError).code).toBe(SCOPE_ERROR_CODES.SCOPE_01);
}
});
it("register on disposed registry throws SCOPE-01", () => {
const mgr = createScopeManager();
const ctx: ScopeContext = { scope: "per-workshop" };
const r = mgr.getRegistry(ctx);
mgr.disposeRegistry(ctx);
expect(() => r.register(makeManifest("comp/a", "per-workshop", "cap/test"))).toThrow(
ScopeError,
);
});
it("AC-2: register throws SCOPE-03 when manifest is missing scope field", () => {
const mgr = createScopeManager();
const r = mgr.getRegistry({ scope: "per-workshop" });
const manifest: Record = {
...makeManifest("comp/a", "per-workshop", "cap/test"),
};
delete manifest.scope;
expect(() => r.register(manifest as unknown as ComponentDeclaration)).toThrow(ScopeError);
try {
r.register(manifest as unknown as ComponentDeclaration);
} catch (e) {
expect((e as ScopeError).code).toBe(SCOPE_ERROR_CODES.SCOPE_03);
}
});
});
describe("resolveAcrossScopes", () => {
it("searches innermost-first: per-command before per-mission", () => {
const mgr = createScopeManager();
const cmdRegistry = mgr.getRegistry({
scope: "per-command",
invocationId: "cmd-001",
});
cmdRegistry.register(makeManifest("comp/cmd", "per-command", "cap/test"));
const missionRegistry = mgr.getRegistry({
scope: "per-mission",
missionId: "m001",
});
missionRegistry.register(makeManifest("comp/mission", "per-mission", "cap/test"));
const ctx: ScopeContext = {
scope: "per-command",
invocationId: "cmd-001",
missionId: "m001",
};
const result = mgr.resolveAcrossScopes("cap/test" as `${string}/${string}`, ctx);
expect(result).not.toBeNull();
expect(result!.componentId).toBe("comp/cmd");
});
it("falls through to per-mission when per-command has no provider", () => {
const mgr = createScopeManager();
const missionRegistry = mgr.getRegistry({
scope: "per-mission",
missionId: "m001",
});
missionRegistry.register(makeManifest("comp/mission", "per-mission", "cap/test"));
const ctx: ScopeContext = {
scope: "per-command",
invocationId: "cmd-001",
missionId: "m001",
};
const result = mgr.resolveAcrossScopes("cap/test" as `${string}/${string}`, ctx);
expect(result).not.toBeNull();
expect(result!.componentId).toBe("comp/mission");
});
it("falls through to per-workshop when no inner scopes have providers", () => {
const mgr = createScopeManager();
const workshopRegistry = mgr.getRegistry({ scope: "per-workshop" });
workshopRegistry.register(makeManifest("comp/workshop", "per-workshop", "cap/test"));
const ctx: ScopeContext = {
scope: "per-command",
invocationId: "cmd-001",
missionId: "m001",
};
const result = mgr.resolveAcrossScopes("cap/test" as `${string}/${string}`, ctx);
expect(result).not.toBeNull();
expect(result!.componentId).toBe("comp/workshop");
});
it("returns null when no scope has a provider", () => {
const mgr = createScopeManager();
const ctx: ScopeContext = {
scope: "per-command",
invocationId: "cmd-001",
missionId: "m001",
};
expect(mgr.resolveAcrossScopes("cap/none" as `${string}/${string}`, ctx)).toBeNull();
});
it("resolves per-fleet when fleetId is provided", () => {
const mgr = createScopeManager();
const fleetRegistry = mgr.getRegistry({
scope: "per-fleet",
fleetId: "fleet-1",
});
fleetRegistry.register(makeManifest("comp/fleet", "per-fleet", "cap/test"));
const ctx: ScopeContext = {
scope: "per-fleet",
fleetId: "fleet-1",
};
const result = mgr.resolveAcrossScopes("cap/test" as `${string}/${string}`, ctx);
expect(result).not.toBeNull();
expect(result!.componentId).toBe("comp/fleet");
});
it("AC-6: per-workshop scope cannot see per-command or per-mission providers", () => {
const mgr = createScopeManager();
const cmdRegistry = mgr.getRegistry({
scope: "per-command",
invocationId: "cmd-001",
});
cmdRegistry.register(makeManifest("comp/cmd", "per-command", "cap/test"));
const missionRegistry = mgr.getRegistry({
scope: "per-mission",
missionId: "m001",
});
missionRegistry.register(makeManifest("comp/mission", "per-mission", "cap/test"));
// Caller in per-workshop scope should NOT find per-command or per-mission providers
const ctx: ScopeContext = { scope: "per-workshop" };
const result = mgr.resolveAcrossScopes("cap/test" as `${string}/${string}`, ctx);
expect(result).toBeNull();
});
it("AC-6: per-mission scope can see per-mission and outer but not per-command", () => {
const mgr = createScopeManager();
const cmdRegistry = mgr.getRegistry({
scope: "per-command",
invocationId: "cmd-001",
});
cmdRegistry.register(makeManifest("comp/cmd", "per-command", "cap/test"));
const missionRegistry = mgr.getRegistry({
scope: "per-mission",
missionId: "m001",
});
missionRegistry.register(makeManifest("comp/mission", "per-mission", "cap/test"));
// Caller in per-mission scope should find per-mission, not per-command
const ctx: ScopeContext = { scope: "per-mission", missionId: "m001" };
const result = mgr.resolveAcrossScopes("cap/test" as `${string}/${string}`, ctx);
expect(result).not.toBeNull();
expect(result!.componentId).toBe("comp/mission");
});
it("AC-5: concurrent missions with same component ID are isolated", () => {
const mgr = createScopeManager();
const r1 = mgr.getRegistry({ scope: "per-mission", missionId: "m001" });
const r2 = mgr.getRegistry({ scope: "per-mission", missionId: "m002" });
r1.register(makeManifest("comp/a", "per-mission", "cap/test"));
r2.register(makeManifest("comp/a", "per-mission", "cap/test"));
// Each mission resolves its own component
const result1 = mgr.resolveAcrossScopes("cap/test" as `${string}/${string}`, {
scope: "per-mission",
missionId: "m001",
});
const result2 = mgr.resolveAcrossScopes("cap/test" as `${string}/${string}`, {
scope: "per-mission",
missionId: "m002",
});
expect(result1).not.toBeNull();
expect(result2).not.toBeNull();
// Both have same component ID but in separate registries
expect(result1!.componentId).toBe("comp/a");
expect(result2!.componentId).toBe("comp/a");
// Disposing one does not affect the other
mgr.disposeRegistry({ scope: "per-mission", missionId: "m001" });
const result2After = mgr.resolveAcrossScopes("cap/test" as `${string}/${string}`, {
scope: "per-mission",
missionId: "m002",
});
expect(result2After).not.toBeNull();
expect(result2After!.componentId).toBe("comp/a");
});
});
describe("adopt", () => {
it("adopts a component into per-fleet scope", () => {
const mgr = createScopeManager();
const fleetRegistry = mgr.getRegistry({ scope: "per-fleet", fleetId: "fleet-origin" });
const manifest = makeManifest("comp/a", "per-fleet", "cap/test");
fleetRegistry.register(manifest);
mgr.adopt("comp/a", { scope: "per-fleet", fleetId: "fleet-1" });
const scope = mgr.getScope("comp/a");
expect(scope).toBe("per-fleet");
});
it("adopts a component into per-session scope", () => {
const mgr = createScopeManager();
const sessionRegistry = mgr.getRegistry({ scope: "per-session", sessionId: "sess-origin" });
sessionRegistry.register(makeManifest("comp/a", "per-session", "cap/test"));
mgr.adopt("comp/a", { scope: "per-session", sessionId: "sess-1" });
expect(mgr.getScope("comp/a")).toBe("per-session");
});
it("throws SCOPE-02 when adopting into a non-manual scope", () => {
const mgr = createScopeManager();
const missionRegistry = mgr.getRegistry({ scope: "per-mission", missionId: "m-origin" });
missionRegistry.register(makeManifest("comp/a", "per-mission", "cap/test"));
expect(() => mgr.adopt("comp/a", { scope: "per-mission", missionId: "m001" })).toThrow(
ScopeError,
);
try {
mgr.adopt("comp/a", { scope: "per-mission", missionId: "m001" });
} catch (e) {
expect((e as ScopeError).code).toBe(SCOPE_ERROR_CODES.SCOPE_02);
}
});
it("throws SCOPE-02 when manifest scope does not match target scope", () => {
const mgr = createScopeManager();
const workshopRegistry = mgr.getRegistry({ scope: "per-workshop" });
workshopRegistry.register(makeManifest("comp/a", "per-workshop", "cap/test"));
expect(() => mgr.adopt("comp/a", { scope: "per-fleet", fleetId: "fleet-1" })).toThrow(
ScopeError,
);
try {
mgr.adopt("comp/a", { scope: "per-fleet", fleetId: "fleet-1" });
} catch (e) {
expect((e as ScopeError).code).toBe(SCOPE_ERROR_CODES.SCOPE_02);
}
});
it("throws SCOPE-01 when component is not found in any scope", () => {
const mgr = createScopeManager();
expect(() => mgr.adopt("comp/missing", { scope: "per-fleet", fleetId: "fleet-1" })).toThrow(
ScopeError,
);
try {
mgr.adopt("comp/missing", { scope: "per-fleet", fleetId: "fleet-1" });
} catch (e) {
expect((e as ScopeError).code).toBe(SCOPE_ERROR_CODES.SCOPE_01);
}
});
});
describe("getScope", () => {
it("returns the scope of a registered component", () => {
const mgr = createScopeManager();
const r = mgr.getRegistry({ scope: "per-mission", missionId: "m001" });
r.register(makeManifest("comp/a", "per-mission", "cap/test"));
expect(mgr.getScope("comp/a")).toBe("per-mission");
});
it("returns null for unregistered component", () => {
const mgr = createScopeManager();
expect(mgr.getScope("comp/missing")).toBeNull();
});
});
describe("inspect", () => {
it("returns empty array when no registries exist", () => {
const mgr = createScopeManager();
expect(mgr.inspect()).toEqual([]);
});
it("returns all active registries with component counts", () => {
const mgr = createScopeManager();
const r1 = mgr.getRegistry({ scope: "per-mission", missionId: "m001" });
r1.register(makeManifest("comp/a", "per-mission", "cap/test"));
r1.register(makeManifest("comp/b", "per-mission", "cap/test2"));
const r2 = mgr.getRegistry({ scope: "per-workshop" });
r2.register(makeManifest("comp/c", "per-workshop", "cap/test3"));
const scopes = mgr.inspect();
expect(scopes).toHaveLength(2);
const missionScope = scopes.find((s) => s.context.scope === "per-mission");
expect(missionScope).toBeDefined();
expect(missionScope!.componentCount).toBe(2);
expect(missionScope!.componentIds).toContain("comp/a");
expect(missionScope!.componentIds).toContain("comp/b");
});
it("excludes disposed registries", () => {
const mgr = createScopeManager();
const r = mgr.getRegistry({ scope: "per-mission", missionId: "m001" });
r.register(makeManifest("comp/a", "per-mission", "cap/test"));
mgr.disposeRegistry({ scope: "per-mission", missionId: "m001" });
expect(mgr.inspect()).toEqual([]);
});
});
describe("registry limit", () => {
it("throws SCOPE-04 when registry limit is reached", () => {
const mgr = createScopeManager({ registryLimit: 2 });
mgr.getRegistry({ scope: "per-mission", missionId: "m001" });
mgr.getRegistry({ scope: "per-mission", missionId: "m002" });
expect(() => mgr.getRegistry({ scope: "per-mission", missionId: "m003" })).toThrow(
ScopeError,
);
try {
mgr.getRegistry({ scope: "per-mission", missionId: "m003" });
} catch (e) {
expect((e as ScopeError).code).toBe(SCOPE_ERROR_CODES.SCOPE_04);
}
});
it("allows new registries after disposed ones free slots", () => {
const mgr = createScopeManager({ registryLimit: 2 });
mgr.getRegistry({ scope: "per-mission", missionId: "m001" });
mgr.getRegistry({ scope: "per-mission", missionId: "m002" });
mgr.disposeRegistry({ scope: "per-mission", missionId: "m001" });
expect(() => mgr.getRegistry({ scope: "per-mission", missionId: "m003" })).not.toThrow();
});
});
describe("default manager", () => {
afterEach(() => {
resetDefaultScopeManager();
});
it("returns a singleton instance", () => {
const m1 = getDefaultScopeManager();
const m2 = getDefaultScopeManager();
expect(m1).toBe(m2);
});
it("resetDefaultScopeManager creates a new instance", () => {
const m1 = getDefaultScopeManager();
resetDefaultScopeManager();
const m2 = getDefaultScopeManager();
expect(m1).not.toBe(m2);
});
});
describe("ScopeError", () => {
it("has name ScopeError and includes code in message", () => {
const err = new ScopeError("SCOPE-01", "test message");
expect(err.name).toBe("ScopeError");
expect(err.code).toBe("SCOPE-01");
expect(err.message).toBe("SCOPE-01: test message");
expect(err instanceof Error).toBe(true);
});
});
});