diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 0746dcb40a..8fdb5b6732 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -497,6 +497,12 @@ comments and unrelated settings. It resolves that file from `HERMES_HOME`, else from the platform default Hermes itself uses — `%LOCALAPPDATA%\hermes` on Windows, `~/.hermes` elsewhere — including any `HERMES_DATA_DIR_SUFFIX`. Hermes connects an entry unless `enabled` is false, so setup states `enabled: true`. +Aliases whose anchor is defined in the `rea` entry retain their original resolved +values when setup refreshes or uninstall removes that entry. Setup does not +redirect another server's alias to REA's new launcher or policy. +This also covers an anchor on the removed registration key. References to +definitions outside the entry keep sharing the original value, including when +another definition later reuses the same anchor name. Hermes personal skills install under the active Hermes home's `skills` directory. Setup, doctor and uninstall share REA's sticky-profile resolver. @@ -734,10 +740,6 @@ can override an inherited disable. Lower files and other server entries remain unchanged; a legacy server named `servers` that conflicts with the native table requires manual repair before setup can write. -When Hermes aliases refer to an anchored REA entry, refreshing or removing REA -retains the aliases' original resolved values. Setup does not redirect another -server's alias to REA's new launcher or policy. - With no client selected, the interactive skill choice targets the shared `~/.agents/skills` copy. Selecting a client targets its personal destination when supported. Use `rea setup --skill-client shared --skill` for the shared diff --git a/src/application/HermesConfigurationAliases.ts b/src/application/HermesConfigurationAliases.ts new file mode 100644 index 0000000000..f52719cad2 --- /dev/null +++ b/src/application/HermesConfigurationAliases.ts @@ -0,0 +1,192 @@ +import { + Alias, + isAlias, + isMap, + isNode, + isSeq, + visit, + type Document, + type Node, +} from "yaml"; + +interface AliasCopies { + readonly targets: ReadonlyMap; + readonly removed: ReadonlySet; + readonly retained: Map; + readonly survivingNames: Map; + readonly visiting: Set; + readonly validated: Set; + readonly names: Set; + readonly reusedNames: ReadonlySet; +} + +const copyAliasComments = (source: Alias, copy: Node): Node => { + for (const key of ["commentBefore", "comment"] as const) { + const comment = source[key]; + if (comment === undefined) continue; + copy[key] = copy[key] === undefined ? comment : `${copy[key]}\n${comment}`; + } + return copy; +}; + +const aliasTarget = (source: Alias, context: AliasCopies): Node => { + const target = context.targets.get(source); + if (target === undefined) + throw new TypeError("Cannot preserve an unresolved Hermes YAML alias"); + return target; +}; + +/** Check copied dependencies even when their definitions will stay in place. */ +const validateAliasValue = (source: Node, context: AliasCopies): void => { + if (context.validated.has(source)) return; + if (context.visiting.has(source)) + throw new TypeError("Cannot preserve cyclic Hermes YAML aliases"); + context.visiting.add(source); + visit(source, { + Alias: (_key, node) => + validateAliasValue(aliasTarget(node, context), context), + }); + context.visiting.delete(source); + context.validated.add(source); +}; + +const newAnchorName = (anchor: string, context: AliasCopies): string => { + let index = 1; + let name = `${anchor}_rea_preserved`; + while (context.names.has(name)) name = `${anchor}_rea_preserved_${index++}`; + context.names.add(name); + return name; +}; + +/** Reuse the surviving owner; a later same-name definition must not capture it. */ +const survivingAlias = (target: Node, context: AliasCopies): Alias => { + if (isAlias(target) || target.anchor === undefined) + throw new TypeError("Invalid Hermes YAML alias target"); + if (!context.reusedNames.has(target.anchor)) return new Alias(target.anchor); + let name = context.survivingNames.get(target); + if (name === undefined) { + name = newAnchorName(target.anchor, context); + context.survivingNames.set(target, name); + } + return new Alias(name); +}; + +/** Keep source bindings, including nested aliases whose names were later reused. */ +const copyAliasValue = ( + source: Node, + context: AliasCopies, + cloned?: Node, +): Node => { + if (isAlias(source)) { + const target = aliasTarget(source, context); + return copyAliasComments( + source, + context.removed.has(target) + ? copyAliasValue(target, context) + : survivingAlias(target, context), + ); + } + if (context.visiting.has(source)) + throw new TypeError("Cannot preserve cyclic Hermes YAML aliases"); + const retained = context.retained.get(source); + if ( + retained !== undefined && + !isAlias(retained) && + retained.anchor !== undefined + ) + return new Alias(retained.anchor); + const copy = cloned ?? source.clone(); + if (!isNode(copy) || isAlias(copy)) + throw new TypeError("Invalid Hermes YAML value copy"); + if (source.anchor !== undefined) { + copy.anchor = newAnchorName(source.anchor, context); + context.retained.set(source, copy); + } + context.visiting.add(source); + const child = (value: unknown, copied: unknown): unknown => + isNode(value) + ? copyAliasValue(value, context, isNode(copied) ? copied : undefined) + : copied; + if (isMap(source) && isMap(copy)) { + for (const [index, pair] of source.items.entries()) { + const copied = copy.items[index]; + if (copied === undefined) continue; + copied.key = child(pair.key, copied.key); + copied.value = child(pair.value, copied.value); + } + } else if (isSeq(source) && isSeq(copy)) { + copy.items = source.items.map((value, index) => + child(value, copy.items[index]), + ); + } + context.visiting.delete(source); + return copy; +}; + +/** Preserve other consumers before removing anchored configuration nodes. */ +export const preserveHermesAliasValues = ( + document: Document, + previous: readonly unknown[], +): void => { + const roots = previous.filter(isNode); + const removed = new Set(); + for (const root of roots) + visit(root, { + Value: (_key, node) => { + if (node.anchor !== undefined) removed.add(node); + }, + }); + if (removed.size === 0) return; + const targets = new Map(); + const names = new Set(); + const reusedNames = new Set(); + visit(document, { + Alias: (_key, node) => { + targets.set(node, node.resolve(document)); + }, + Value: (_key, node) => { + if (node.anchor === undefined) return; + if (names.has(node.anchor)) reusedNames.add(node.anchor); + names.add(node.anchor); + }, + }); + const context: AliasCopies = { + targets, + removed, + retained: new Map(), + survivingNames: new Map(), + visiting: new Set(), + validated: new Set(), + names, + reusedNames, + }; + const replacements = new Map(); + visit(document, { + Alias: (_key, node, path) => { + if (!roots.some((root) => path.includes(root))) { + const target = targets.get(node); + if (target === undefined || !removed.has(target)) return; + validateAliasValue(target, context); + replacements.set( + node, + copyAliasComments(node, copyAliasValue(target, context)), + ); + } + }, + }); + // Plan every binding against the original document before moving any anchor. + for (const [target, name] of context.survivingNames) + if (!isAlias(target)) target.anchor = name; + visit(document, { + Alias: (_key, node) => { + const replacement = replacements.get(node); + if (replacement !== undefined) return replacement; + const target = targets.get(node); + const name = + target === undefined ? undefined : context.survivingNames.get(target); + return name === undefined + ? undefined + : copyAliasComments(node, new Alias(name)); + }, + }); +}; diff --git a/src/application/HermesConfigurationYaml.test.ts b/src/application/HermesConfigurationYaml.test.ts index 35e5edfc18..4e60c4f9ba 100644 --- a/src/application/HermesConfigurationYaml.test.ts +++ b/src/application/HermesConfigurationYaml.test.ts @@ -7,6 +7,254 @@ import { stringifyHermesConfigurationYaml, } from "./HermesConfigurationYaml.js"; +it("refreshes an anchored entry without changing another server's resolved value", () => { + const source = + "mcp_servers:\n rea: &server\n command: old\n enabled: false\n other: *server # Independent server\n"; + const updated = editHermesConfigurationYaml(source, ["mcp_servers", "rea"], { + command: "rea", + enabled: true, + }); + expect(parseHermesConfigurationYaml(updated)).toEqual({ + mcp_servers: { + rea: { command: "rea", enabled: true }, + other: { command: "old", enabled: false }, + }, + }); + expect(updated).toContain("# Independent server"); +}); + +describe("Hermes surviving anchor definitions", () => { + for (const shadowed of [false, true]) { + it.each([undefined, { command: "rea" }])( + `retains surviving shared target identity with shadowed=${shadowed} when writing %j`, + (entry) => { + const source = + "defaults: &settings {MODE: 'off'}\nmcp_servers:\n rea: &server\n command: old\n env: *settings\n" + + (shadowed ? "later: &settings {MODE: 'later'}\n" : "") + + "observer: *server\npeer: *settings\n"; + const result = parse( + editHermesConfigurationYaml(source, ["mcp_servers", "rea"], entry), + ); + expect(result.defaults).toEqual({ MODE: "off" }); + expect(result.observer.env).toBe(result.defaults); + expect(result.peer).toBe(shadowed ? result.later : result.defaults); + if (shadowed) expect(result.peer).not.toBe(result.defaults); + }, + ); + } + + it.each(["settings", "missing"])( + "still refuses a surviving %s dependency that cannot be retained", + (reference) => { + const source = `defaults: &settings {dependency: *${reference}}\nmcp_servers:\n rea: &server {env: *settings}\nobserver: *server\n`; + expect(() => + editHermesConfigurationYaml(source, ["mcp_servers", "rea"], undefined), + ).toThrow(); + }, + ); +}); + +describe("Hermes copied anchor comments", () => { + it("retains the removed anchor definition comment at its surviving consumer", () => { + const source = + "mcp_servers:\n rea: &server {command: old} # Keep definition explanation\n other: *server\n"; + const updated = editHermesConfigurationYaml( + source, + ["mcp_servers", "rea"], + undefined, + ); + expect(parseHermesConfigurationYaml(updated)).toEqual({ + mcp_servers: { other: { command: "old" } }, + }); + expect(updated).toContain("# Keep definition explanation"); + }); + + it.each([undefined, { command: "rea" }])( + "combines definition and consumer comments without copying shared definitions twice when writing %j", + (entry) => { + const source = + "mcp_servers:\n rea:\n # Definition leading note\n &server {command: old} # Definition trailing note\n other:\n # Consumer leading note\n *server # Consumer trailing note\nobserver: *server # Later consumer note\n"; + const updated = editHermesConfigurationYaml( + source, + ["mcp_servers", "rea"], + entry, + ); + const result = parse(updated); + expect(result).toEqual({ + mcp_servers: { + ...(entry === undefined ? {} : { rea: entry }), + other: { command: "old" }, + }, + observer: { command: "old" }, + }); + expect(result.observer).toBe(result.mcp_servers.other); + for (const comment of [ + "Definition leading note", + "Definition trailing note", + "Consumer leading note", + "Consumer trailing note", + "Later consumer note", + ]) + expect(updated.split(comment)).toHaveLength(2); + }, + ); +}); + +describe("Hermes anchored-entry consumers", () => { + it.each([undefined, { command: "rea" }])( + "preserves an anchored registration key when writing %j", + (entry) => { + const source = + "mcp_servers:\n &server_name rea: {command: old}\nobserver: *server_name # Keep registration name\n"; + const updated = editHermesConfigurationYaml( + source, + ["mcp_servers", "rea"], + entry, + ); + expect(parseHermesConfigurationYaml(updated)).toEqual({ + mcp_servers: entry === undefined ? {} : { rea: entry }, + observer: "rea", + }); + expect(updated).toContain("# Keep registration name"); + if (entry !== undefined) { + expect(updated).toContain("&server_name rea:"); + expect(updated).toContain("*server_name"); + } + }, + ); + + it("preserves both key and value consumers of a removed pair", () => { + const source = + "mcp_servers:\n &server_name rea: &server {command: old}\n other: *server\nobserver: *server_name\nlater: &server_name later\nlater_observer: *server_name\n"; + expect( + parseHermesConfigurationYaml( + editHermesConfigurationYaml(source, ["mcp_servers", "rea"], undefined), + ), + ).toEqual({ + mcp_servers: { other: { command: "old" } }, + observer: "rea", + later: "later", + later_observer: "later", + }); + }); + + it.each([undefined, { command: "rea" }])( + "preserves references to an anchored child when writing %j", + (entry) => { + const source = + "mcp_servers:\n rea:\n command: old\n env: &settings\n MODE: 'off'\n other:\n command: other\n env: *settings\n"; + const result = parseHermesConfigurationYaml( + editHermesConfigurationYaml(source, ["mcp_servers", "rea"], entry), + ); + expect(result).toEqual({ + mcp_servers: { + ...(entry === undefined ? {} : { rea: entry }), + other: { command: "other", env: { MODE: "off" } }, + }, + }); + }, + ); + + it.each([undefined, { command: "rea" }])( + "retains nested alias comments when writing %j", + (entry) => { + const source = + "defaults: &settings {MODE: 'old'}\nmcp_servers:\n rea: &entry\n command: old\n env:\n # Keep nested leading comment\n *settings # Keep nested reference comment\n other: *entry # Keep external reference comment\n"; + const updated = editHermesConfigurationYaml( + source, + ["mcp_servers", "rea"], + entry, + ); + expect(parseHermesConfigurationYaml(updated)).toEqual({ + defaults: { MODE: "old" }, + mcp_servers: { + ...(entry === undefined ? {} : { rea: entry }), + other: { command: "old", env: { MODE: "old" } }, + }, + }); + expect(updated).toContain("# Keep nested leading comment"); + expect(updated).toContain("# Keep nested reference comment"); + expect(updated).toContain("# Keep external reference comment"); + }, + ); + + it("binds copied nested aliases before a later anchor reuses their name", () => { + const source = + "defaults: &settings {MODE: 'old'}\nmcp_servers:\n rea: &entry\n command: old\n env: *settings\nlater: &settings {MODE: 'later'}\nobserver: *entry\n"; + const result = parseHermesConfigurationYaml( + editHermesConfigurationYaml(source, ["mcp_servers", "rea"], { + command: "rea", + }), + ); + expect(result).toEqual({ + defaults: { MODE: "old" }, + mcp_servers: { rea: { command: "rea" } }, + later: { MODE: "later" }, + observer: { command: "old", env: { MODE: "old" } }, + }); + }); + + it("changes only consumers of the removed definition when an anchor name repeats", () => { + const source = + "mcp_servers:\n rea: &server {command: old}\n other: *server\nlater: &server {command: later}\nobserver: *server\n"; + expect( + parseHermesConfigurationYaml( + editHermesConfigurationYaml(source, ["mcp_servers", "rea"], undefined), + ), + ).toEqual({ + mcp_servers: { other: { command: "old" } }, + later: { command: "later" }, + observer: { command: "later" }, + }); + }); + + it.each([true, false])( + "keeps shared child identity with child first=%s", + (childFirst) => { + const child = "child: *settings\n"; + const parent = "parent: *entry\n"; + const source = + "mcp_servers:\n rea: &entry\n command: old\n env: &settings {MODE: 'off'}\n" + + (childFirst ? child + parent : parent + child); + const result = parse( + editHermesConfigurationYaml(source, ["mcp_servers", "rea"], undefined), + ); + expect(result).toEqual({ + mcp_servers: {}, + child: { MODE: "off" }, + parent: { command: "old", env: { MODE: "off" } }, + }); + expect(result.child).toBe(result.parent.env); + }, + ); + + it("retains one source copy for repeated consumers instead of expanding every alias", () => { + const source = + "mcp_servers:\n rea: &server {command: old}\nobservers:\n" + + " - *server\n".repeat(50); + const updated = editHermesConfigurationYaml( + source, + ["mcp_servers", "rea"], + undefined, + ); + expect(parseHermesConfigurationYaml(updated)).toEqual({ + mcp_servers: {}, + observers: Array.from({ length: 50 }, () => ({ command: "old" })), + }); + expect(updated.match(/command: old/gu)).toHaveLength(1); + }); + + it.each(["server", "missing"])( + "still refuses a copied %s dependency that cannot be retained", + (reference) => { + const source = `mcp_servers:\n rea: &server\n command: old\n dependency: *${reference}\nobserver: *server\n`; + expect(() => + editHermesConfigurationYaml(source, ["mcp_servers", "rea"], undefined), + ).toThrow(); + }, + ); +}); + for (const version of ["1.1", "1.2"] as const) it.each(["refresh", "remove"] as const)( `preserves YAML ${version} consumers of a replaced REA anchor during %s`, diff --git a/src/application/HermesConfigurationYaml.ts b/src/application/HermesConfigurationYaml.ts index 416f528c0f..d3cd77ed10 100644 --- a/src/application/HermesConfigurationYaml.ts +++ b/src/application/HermesConfigurationYaml.ts @@ -7,12 +7,12 @@ import { parseDocument, Document, Schema, - visit, type ParsedNode, type Node, type ScalarTag, type Tags, } from "yaml"; +import { preserveHermesAliasValues } from "./HermesConfigurationAliases.js"; const SOURCE_SCALAR_TAGS = new Set([ "tag:yaml.org,2002:int", @@ -395,33 +395,6 @@ const copyHermesAliasSource = ( return copied; }; -/** External aliases keep their original bindings when their source is replaced. */ -const retainHermesAliasConsumers = ( - document: Document, - previous: unknown, -): void => { - if (!isNode(previous)) return; - const replaced = new Set(); - visit(previous, { - Node: (_key, node) => { - replaced.add(node); - }, - }); - visit(document, { - Alias: (_key, alias, path) => { - const source = alias.resolve(document); - if ( - source === undefined || - !replaced.has(source) || - path.some((parent) => isNode(parent) && replaced.has(parent)) - ) - return; - const copied = copyHermesAliasSource(alias, document); - return isNode(copied) ? copied : undefined; - }, - }); -}; - /** Set or delete one value in a Hermes `config.yaml`, keeping the rest as written. */ export const editHermesConfigurationYaml = ( text: string, @@ -453,11 +426,19 @@ export const editHermesConfigurationYaml = ( document.setIn(parent, document.createNode({})); } } - retainHermesAliasConsumers(document, document.getIn(path, true)); + const previous = document.getIn(path, true); + // Deletion removes the pair's key too; replacement retains that key node. + const parent = + value === undefined ? document.getIn(path.slice(0, -1), true) : undefined; + const removedKey = isMap(parent) + ? parent.items.find( + (pair) => isScalar(pair.key) && pair.key.value === path.at(-1), + )?.key + : undefined; + preserveHermesAliasValues(document, [previous, removedKey]); if (value === undefined) { if (!document.deleteIn(path)) return text; } else { - const previous = document.getIn(path, true); // Let YAML's scalar update preserve numeric formats for a direct edit. // Replacing containers needs explicit retention of unchanged child nodes. document.setIn( diff --git a/tests/acceptance/setup/hermesAnchoredEntrySetup.test.ts b/tests/acceptance/setup/hermesAnchoredEntrySetup.test.ts new file mode 100644 index 0000000000..22948632ae --- /dev/null +++ b/tests/acceptance/setup/hermesAnchoredEntrySetup.test.ts @@ -0,0 +1,156 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { parse } from "yaml"; +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +for (const version of ["1.1", "1.2"] as const) { + for (const style of [ + "entry", + "child", + "shadowed", + "key", + "external", + ] as const) { + cliTest( + `Hermes ${version} preserves ${style} anchor consumers through setup and uninstall`, + async ({ cli }) => { + const home = await createTestTempDirectory( + "rea-hermes-anchored-entry-", + ); + const profile = join(home, "hermes"); + const config = join(profile, "config.yaml"); + const server = { + command: process.execPath, + args: [resolve("scripts/rea.mjs"), "mcp"], + env: { KEEP: "off" }, + enabled: false, + }; + const directive = version === "1.2" ? "%YAML 1.2\n---\n" : ""; + const hasDefaults = style === "shadowed" || style === "external"; + const entry = + ` command: ${JSON.stringify(server.command)}\n` + + ` args: ${JSON.stringify(server.args)}\n` + + " enabled: false\n" + + (hasDefaults + ? " env: *settings # Keep nested reference comment\n" + : ` env: ${style === "child" ? "&settings " : ""}{KEEP: 'off'}\n`); + const original = + directive + + "# Keep this user setting\nmodel: custom\n" + + (hasDefaults ? "defaults: &settings {KEEP: 'off'}\n" : "") + + `mcp_servers:\n ${style === "key" ? "&server_name " : ""}rea:${style === "child" || style === "key" ? "" : " &server"}${style === "entry" ? " # Keep anchor definition comment" : ""}\n` + + entry + + (style === "child" + ? " other:\n command: other-server\n env: *settings # Shared environment\n" + : style === "key" + ? " other: {command: other-server}\nobserver: *server_name # Registration name\n" + : " other: *server # Independent server\n") + + (style === "shadowed" + ? "later: &settings {KEEP: later}\nobserver: *server\n" + : ""); + const other = + style === "child" + ? { command: "other-server", env: { KEEP: "off" } } + : style === "key" + ? { command: "other-server" } + : server; + await mkdir(profile); + await writeFile(config, original); + const run = (arguments_: readonly string[]) => + cli.run({ + arguments: arguments_, + cwd: home, + environment: { + HOME: home, + USERPROFILE: home, + HERMES_HOME: profile, + }, + timeoutMs: 20_000, + }); + const setup = [ + "setup", + "--client", + "hermes", + "--skill=false", + "--yes", + "--json", + ]; + const applied = await run(setup); + expect(applied.exitCode, JSON.stringify(applied)).toBe(0); + expect(applied.json).toMatchObject({ status: "ready" }); + const configured = await readFile(config, "utf8"); + const configuredValues = parse(configured, { version }); + expect(configuredValues).toMatchObject({ + model: "custom", + mcp_servers: { rea: { enabled: true, env: { KEEP: "off" } }, other }, + ...(style === "shadowed" + ? { observer: server, later: { KEEP: "later" } } + : {}), + ...(style === "key" ? { observer: "rea" } : {}), + }); + if (hasDefaults) + expect(configuredValues.mcp_servers.other.env).toBe( + configuredValues.defaults, + ); + expect(configured).toContain("# Keep this user setting"); + if (style === "entry") + expect(configured).toContain("# Keep anchor definition comment"); + expect(configured).toContain( + style === "child" + ? "# Shared environment" + : style === "key" + ? "# Registration name" + : "# Independent server", + ); + if (hasDefaults) + expect(configured).toContain("# Keep nested reference comment"); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + const doctor = await run(["doctor", "--client", "hermes", "--json"]); + expect(doctor.exitCode, JSON.stringify(doctor)).toBe(0); + expect(doctor.json).toMatchObject({ + healthy: true, + identity: { registrations: [{ client: "hermes", state: "aligned" }] }, + }); + const repeated = await run(setup); + expect(repeated.exitCode, JSON.stringify(repeated)).toBe(0); + expect(repeated.json).toMatchObject({ + plannedActions: [], + appliedActions: [], + }); + expect(await readFile(config, "utf8")).toBe(configured); + const removed = await run(["uninstall", "--json"]); + expect(removed.exitCode, JSON.stringify(removed)).toBe(0); + const removedValues = parse(await readFile(config, "utf8"), { + version, + }); + expect(removedValues).toEqual({ + model: "custom", + mcp_servers: { other }, + ...(hasDefaults ? { defaults: { KEEP: "off" } } : {}), + ...(style === "shadowed" + ? { + later: { KEEP: "later" }, + observer: server, + } + : {}), + ...(style === "key" ? { observer: "rea" } : {}), + }); + if (hasDefaults) + expect(removedValues.mcp_servers.other.env).toBe( + removedValues.defaults, + ); + expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original); + const remaining = await readFile(config, "utf8"); + if (style === "entry") + expect(remaining).toContain("# Keep anchor definition comment"); + if (hasDefaults) + expect(remaining).toContain("# Keep nested reference comment"); + expect((await run(["uninstall", "--json"])).exitCode).toBe(0); + expect(await readFile(config, "utf8")).toBe(remaining); + }, + ); + } +} diff --git a/tests/boundary/cli/nativePlistCli.test.ts b/tests/boundary/cli/nativePlistCli.test.ts index 3b10587291..43dc3a6bb7 100644 --- a/tests/boundary/cli/nativePlistCli.test.ts +++ b/tests/boundary/cli/nativePlistCli.test.ts @@ -289,6 +289,7 @@ describe.skipIf(process.platform !== "darwin")( { tool: "plutil", command: ["/usr/bin/plutil", "-lint", "--", "$PLIST"], + exit: { code: 0, signal: null }, }, ] : [{ tool: "file" }], diff --git a/tests/process-global/commandFinalizationCancellation.test.ts b/tests/process-global/commandFinalizationCancellation.test.ts index 3a927cb201..935971b63c 100644 --- a/tests/process-global/commandFinalizationCancellation.test.ts +++ b/tests/process-global/commandFinalizationCancellation.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { Cli } from "incur"; import { afterEach, expect, it, vi } from "vitest"; +import * as analysisViewService from "../../src/application/analysisView/AnalysisViewService.js"; import * as evidenceInputResolver from "../../src/application/EvidenceInputResolver.js"; import { WasmArtifactService } from "../../src/application/wasm/WasmArtifactService.js"; import { registerAnalysisViewCommands } from "../../src/cli/analysisViewCommands.js"; @@ -331,6 +332,43 @@ it.each([ }, ); +const referenceViewEvidence = async ( + request: Parameters[0], + evidenceBundlePath: string, + phase: string, +) => { + if (phase !== "projection") return undefined; + const result = await analysisViewService.inspectAnalysisView( + request, + undefined, + { evidenceBundlePath }, + ); + if (!result.ok) throw new Error("Expected completed reference Evidence"); + return result.value; +}; + +type PreparedView = ReturnType< + typeof analysisView.prepareAnalysisViewProjection +>; + +const expectRetainedProjection = ( + observation: unknown, + reference: Awaited>, + projected: ReturnType | undefined, + parent: ReturnType, +) => { + if (!projected?.ok || reference === undefined) + throw new Error("Expected a completed page and reference Evidence"); + const partial = parseEvidence(observation); + expect(partial).toEqual(reference); + expect(partial.normalized_result).toEqual(projected.value); + expect(partial).toMatchObject({ + operation: "inspect_analysis_view", + evidence_links: [parent.evidence_id], + subject: parent.subject, + }); +}; + it.each([ ["bundle", "control", "close"], ["bundle", "SIGINT", "close"], @@ -399,13 +437,17 @@ it.each([ }); }); }; + const expectedProjection = await referenceViewEvidence( + request, + path, + phase, + ); // Intercept the prepared owner, whose project method keeps its admission // budget and calls the domain projector lexically rather than by export. const originalPrepare = analysisView.prepareAnalysisViewProjection; - type Projection = ReturnType; - let projected: ReturnType | undefined; + let projected: ReturnType | undefined; const project = vi - .fn<(parent: Parameters[0]) => void>() + .fn<(parent: Parameters[0]) => void>() .mockName("preparedProjection.project"); vi.spyOn(analysisView, "prepareAnalysisViewProjection").mockImplementation( (...args) => { @@ -421,13 +463,13 @@ it.each([ }, ); const originalResolve = evidenceInputResolver.resolveEvidenceInput; - vi.spyOn(evidenceInputResolver, "resolveEvidenceInput").mockImplementation( - (...args) => { + const resolve = vi + .spyOn(evidenceInputResolver, "resolveEvidenceInput") + .mockImplementation((...args) => { const resolved = originalResolve(...args); if (phase === "source-resolution-failure") queueInterrupt(); return resolved; - }, - ); + }); const originalClose = OwnedFileHandle.prototype.close; const close = vi .spyOn(OwnedFileHandle.prototype, "close") @@ -477,15 +519,17 @@ it.each([ expect(project).toHaveBeenCalledTimes( Number(signal === "control" || phase === "projection"), ); + if (phase.startsWith("authentication")) + expect(resolve).not.toHaveBeenCalled(); + if (phase === "projection" || phase === "source-resolution-failure") + expect(resolve).toHaveBeenCalledOnce(); if (phase === "projection") { - if (!projected?.ok) throw new Error("Expected a completed page"); - const partial = parseEvidence(result.details.partial_observation); - expect(partial.normalized_result).toEqual(projected.value); - expect(partial).toMatchObject({ - operation: "inspect_analysis_view", - evidence_links: [parent.evidence_id], - subject: parent.subject, - }); + expectRetainedProjection( + result.details.partial_observation, + expectedProjection, + projected, + parent, + ); } } finally { await pendingInterrupt;