From 257489738d72e68ca4bedb2f655f09b657f77ec6 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:34:26 +0200 Subject: [PATCH 01/12] test(cli): add the test doubles the framework survivors need Recording, choosing and checkbox prompters, a faulting file adapter, a stub asset provider and a stub AI tool, plus a save counter on the in-memory manifest repository and select and input recorders on the scripted prompter. Every double is consumed by the tests in the following commits. Framework mutation score before this series: 72.2. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../ports/checkbox-recording-prompter.ts | 53 +++++++++++++++++++ cli/tests/helpers/ports/choosing-prompter.ts | 48 +++++++++++++++++ .../ports/fake-native-plugin-activator.ts | 6 +++ .../helpers/ports/faulting-file-adapter.ts | 44 +++++++++++++++ .../ports/in-memory-manifest-repository.ts | 2 + cli/tests/helpers/ports/index.ts | 2 + cli/tests/helpers/ports/recording-prompter.ts | 31 +++++++++++ cli/tests/helpers/ports/scripted-prompter.ts | 4 ++ cli/tests/helpers/ports/stub-ai-tool.ts | 17 ++++++ .../helpers/ports/stub-asset-provider.ts | 25 +++++++++ 10 files changed, 232 insertions(+) create mode 100644 cli/tests/helpers/ports/checkbox-recording-prompter.ts create mode 100644 cli/tests/helpers/ports/choosing-prompter.ts create mode 100644 cli/tests/helpers/ports/faulting-file-adapter.ts create mode 100644 cli/tests/helpers/ports/recording-prompter.ts create mode 100644 cli/tests/helpers/ports/stub-ai-tool.ts create mode 100644 cli/tests/helpers/ports/stub-asset-provider.ts diff --git a/cli/tests/helpers/ports/checkbox-recording-prompter.ts b/cli/tests/helpers/ports/checkbox-recording-prompter.ts new file mode 100644 index 000000000..58b5ca493 --- /dev/null +++ b/cli/tests/helpers/ports/checkbox-recording-prompter.ts @@ -0,0 +1,53 @@ +import type { Prompter } from "../../../src/kernel/ports/prompter.js"; + +export interface CheckboxAsk { + message: string; + offered: string[]; +} + +export class CheckboxRecordingPrompter implements Prompter { + readonly asks: CheckboxAsk[] = []; + + constructor(private readonly selection: readonly string[] = []) {} + + async resolveConflict( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite"> { + return "overwrite"; + } + + async resolveConflictBulk( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { + return "overwrite"; + } + + async confirm(_message: string, defaultValue?: boolean): Promise { + return defaultValue ?? true; + } + + async input(_message: string, defaultValue?: string): Promise { + return defaultValue ?? ""; + } + + async select( + _message: string, + choices: Array<{ name: string; value: T; disabled?: boolean | string }> + ): Promise { + const first = choices.find((c) => !c.disabled); + if (first === undefined) throw new Error("No enabled choices available"); + return first.value; + } + + async checkbox( + message: string, + choices: Array<{ name: string; value: T; checked?: boolean; disabled?: boolean | string }> + ): Promise { + this.asks.push({ message, offered: choices.map((c) => String(c.value)) }); + return choices + .filter((c) => !c.disabled && this.selection.includes(String(c.value))) + .map((c) => c.value); + } +} diff --git a/cli/tests/helpers/ports/choosing-prompter.ts b/cli/tests/helpers/ports/choosing-prompter.ts new file mode 100644 index 000000000..ceaa4eb1f --- /dev/null +++ b/cli/tests/helpers/ports/choosing-prompter.ts @@ -0,0 +1,48 @@ +import type { Prompter } from "../../../src/kernel/ports/prompter.js"; + +export class ChoosingPrompter implements Prompter { + readonly selectCalls: Array<{ message: string; choiceNames: string[] }> = []; + + constructor(private readonly chosenName: string) {} + + async resolveConflict( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite"> { + return "overwrite"; + } + + async resolveConflictBulk( + _relativePath: string, + _reason: "deleted" | "modified" + ): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { + return "overwrite"; + } + + async confirm(_message: string, defaultValue?: boolean): Promise { + return defaultValue ?? true; + } + + async input(_message: string, defaultValue?: string): Promise { + return defaultValue ?? ""; + } + + async select( + message: string, + choices: Array<{ name: string; value: T; disabled?: boolean | string; description?: string }> + ): Promise { + this.selectCalls.push({ message, choiceNames: choices.map((c) => c.name) }); + const match = choices.find((c) => c.name === this.chosenName); + if (match === undefined) { + throw new Error(`ChoosingPrompter: no choice named "${this.chosenName}" for "${message}"`); + } + return match.value; + } + + async checkbox( + _message: string, + _choices: Array<{ name: string; value: T; checked?: boolean; disabled?: boolean | string }> + ): Promise { + return []; + } +} diff --git a/cli/tests/helpers/ports/fake-native-plugin-activator.ts b/cli/tests/helpers/ports/fake-native-plugin-activator.ts index 07e6ac70f..93340770a 100644 --- a/cli/tests/helpers/ports/fake-native-plugin-activator.ts +++ b/cli/tests/helpers/ports/fake-native-plugin-activator.ts @@ -22,6 +22,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { private readonly state: "live" | "dead" | "unknown"; private readonly failOnUninstall: ReadonlySet; private readonly crashOnAddMarketplace: boolean; + private readonly crashOnUninstall: boolean; private readonly installedAtScope: ReadonlyMap; constructor( @@ -36,6 +37,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { registrationState?: "live" | "dead" | "unknown"; failOnUninstall?: readonly string[]; crashOnAddMarketplace?: boolean; + crashOnUninstall?: boolean; installedAtScope?: ReadonlyMap; } = {} ) { @@ -47,6 +49,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { this.state = options.registrationState ?? "unknown"; this.failOnUninstall = new Set(options.failOnUninstall ?? []); this.crashOnAddMarketplace = options.crashOnAddMarketplace ?? false; + this.crashOnUninstall = options.crashOnUninstall ?? false; this.installedAtScope = options.installedAtScope ?? new Map(); } @@ -97,6 +100,9 @@ export class FakeNativePluginActivator implements NativePluginActivator { } uninstallPlugin(pluginRef: string, scope: MarketplaceScope = "project"): void { + if (this.crashOnUninstall) { + throw new Error("activator crashed uninstalling a plugin"); + } this.uninstalledPluginScopes.push(scope); if (this.failOnUninstall.has(pluginRef)) { throw new NativePluginCliError(`plugin \`${pluginRef}\` is not installed`); diff --git a/cli/tests/helpers/ports/faulting-file-adapter.ts b/cli/tests/helpers/ports/faulting-file-adapter.ts new file mode 100644 index 000000000..6dafc14e7 --- /dev/null +++ b/cli/tests/helpers/ports/faulting-file-adapter.ts @@ -0,0 +1,44 @@ +import { InMemoryFileAdapter } from "./in-memory-file-adapter.js"; + +type FaultableMethod = "readFile" | "realpath" | "listDirectory" | "deleteDirectory"; + +export class FaultingFileAdapter extends InMemoryFileAdapter { + private readonly faults = new Map(); + + failOn(method: FaultableMethod, path: string, error: Error): void { + this.faults.set(faultKey(method, path), error); + } + + override async readFile(path: string): Promise { + this.throwIfFaulted("readFile", path); + return super.readFile(path); + } + + override async realpath(path: string): Promise { + this.throwIfFaulted("realpath", path); + return super.realpath(path); + } + + override async listDirectory(dirPath: string): Promise { + this.throwIfFaulted("listDirectory", dirPath); + return super.listDirectory(dirPath); + } + + override async deleteDirectory(dirPath: string): Promise { + this.throwIfFaulted("deleteDirectory", dirPath); + return super.deleteDirectory(dirPath); + } + + private throwIfFaulted(method: FaultableMethod, path: string): void { + const error = this.faults.get(faultKey(method, path)); + if (error !== undefined) throw error; + } +} + +export function errnoError(code: string): NodeJS.ErrnoException { + return Object.assign(new Error(`${code}: planted by the test`), { code }); +} + +function faultKey(method: FaultableMethod, path: string): string { + return `${method}:${path.replaceAll("\\", "/")}`; +} diff --git a/cli/tests/helpers/ports/in-memory-manifest-repository.ts b/cli/tests/helpers/ports/in-memory-manifest-repository.ts index a88009176..4b1df8bfe 100644 --- a/cli/tests/helpers/ports/in-memory-manifest-repository.ts +++ b/cli/tests/helpers/ports/in-memory-manifest-repository.ts @@ -5,6 +5,7 @@ export class InMemoryManifestRepository implements ManifestRepository { /** Derived from the root the test drives the use case with, never a fixed literal: a * double naming a fictional file lets a diagnostic go wrong with every test still green. */ readonly path: string; + saveCount = 0; private manifest: Manifest | null; constructor(seed: Manifest | null = null, projectRoot = "/test-project") { @@ -17,6 +18,7 @@ export class InMemoryManifestRepository implements ManifestRepository { } async save(manifest: Manifest): Promise { + this.saveCount += 1; this.manifest = manifest; } diff --git a/cli/tests/helpers/ports/index.ts b/cli/tests/helpers/ports/index.ts index ef48c38e8..6e8f3b351 100644 --- a/cli/tests/helpers/ports/index.ts +++ b/cli/tests/helpers/ports/index.ts @@ -1,4 +1,5 @@ export { CapturingLogger } from "./capturing-logger.js"; +export { CheckboxRecordingPrompter } from "./checkbox-recording-prompter.js"; export { DeterministicHasher } from "./deterministic-hasher.js"; export { FakeAuthReader } from "./fake-auth-reader.js"; export { FakeCurrentVersion } from "./fake-current-version.js"; @@ -11,5 +12,6 @@ export { InMemoryManifestRepository } from "./in-memory-manifest-repository.js"; export { InMemoryMarketplaceCache } from "./in-memory-marketplace-cache.js"; export { InMemoryMarketplaceRegistry } from "./in-memory-marketplace-registry.js"; export { InMemoryMarketplaceTrustStore } from "./in-memory-marketplace-trust-store.js"; +export { RecordingPrompter } from "./recording-prompter.js"; export { KeepPrompter, OverwritePrompter, ScriptedPrompter } from "./scripted-prompter.js"; export { seedFromDirectory } from "./seed-from-directory.js"; diff --git a/cli/tests/helpers/ports/recording-prompter.ts b/cli/tests/helpers/ports/recording-prompter.ts new file mode 100644 index 000000000..983b00507 --- /dev/null +++ b/cli/tests/helpers/ports/recording-prompter.ts @@ -0,0 +1,31 @@ +import type { Prompter } from "../../../src/kernel/ports/prompter.js"; + +export class RecordingPrompter implements Prompter { + readonly confirmMessages: string[] = []; + + constructor(private readonly answer: boolean) {} + + get lastConfirmMessage(): string | undefined { + return this.confirmMessages.at(-1); + } + + async confirm(message: string): Promise { + this.confirmMessages.push(message); + return this.answer; + } + async resolveConflict(): Promise<"keep" | "overwrite"> { + return "keep"; + } + async resolveConflictBulk(): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { + return "keep"; + } + async input(): Promise { + return ""; + } + async select(): Promise { + throw new Error("not implemented"); + } + async checkbox(): Promise { + return []; + } +} diff --git a/cli/tests/helpers/ports/scripted-prompter.ts b/cli/tests/helpers/ports/scripted-prompter.ts index 3d477e439..6483f2347 100644 --- a/cli/tests/helpers/ports/scripted-prompter.ts +++ b/cli/tests/helpers/ports/scripted-prompter.ts @@ -10,6 +10,8 @@ type PromptAnswer = /** Returns pre-defined answers in order, throwing once the queue is exhausted. */ export class ScriptedPrompter implements Prompter { + readonly askedSelects: Array<{ message: string; names: string[] }> = []; + readonly askedInputs: Array<{ message: string; defaultValue: string | undefined }> = []; private readonly queue: PromptAnswer[]; private index = 0; @@ -42,6 +44,7 @@ export class ScriptedPrompter implements Prompter { } async input(message: string, defaultValue?: string): Promise { + this.askedInputs.push({ message, defaultValue }); if (this.index >= this.queue.length) { return defaultValue ?? ""; } @@ -53,6 +56,7 @@ export class ScriptedPrompter implements Prompter { message: string, choices: Array<{ name: string; value: T; disabled?: boolean | string; description?: string }> ): Promise { + this.askedSelects.push({ message, names: choices.map((c) => c.name) }); const answer = this.next("select", message); const stringValue = answer.value as string; const match = choices.find((c) => !c.disabled && String(c.value) === stringValue); diff --git a/cli/tests/helpers/ports/stub-ai-tool.ts b/cli/tests/helpers/ports/stub-ai-tool.ts new file mode 100644 index 000000000..91f8ccfb4 --- /dev/null +++ b/cli/tests/helpers/ports/stub-ai-tool.ts @@ -0,0 +1,17 @@ +import type { AiTool } from "../../../src/contexts/tools/domain/contracts.js"; +import type { AiToolId } from "../../../src/kernel/tool.js"; + +export function stubAiTool(toolId: AiToolId, capabilities: unknown): AiTool { + return { + kind: "ai", + toolId, + directory: `.${toolId}/`, + toolSuffix: `.${toolId}.md`, + signalDir: null, + displayName: toolId, + telemetryLocalRead: { kind: "unsupported", reason: "a stub reads nothing" }, + telemetryTaskAttributable: false, + capabilities, + rewriteContent: (content: string) => content, + }; +} diff --git a/cli/tests/helpers/ports/stub-asset-provider.ts b/cli/tests/helpers/ports/stub-asset-provider.ts new file mode 100644 index 000000000..f24b74945 --- /dev/null +++ b/cli/tests/helpers/ports/stub-asset-provider.ts @@ -0,0 +1,25 @@ +import type { + AssetProvider, + ConfigAsset, + SchemaName, +} from "../../../src/kernel/ports/asset-provider.js"; +import type { ToolId } from "../../../src/kernel/tool.js"; + +export class StubAssetProvider implements AssetProvider { + constructor( + private readonly assets: Readonly>, + private readonly fallback?: AssetProvider + ) {} + + loadConfigAsset(toolId: ToolId, fileName: string): ConfigAsset { + const asset = this.assets[`${toolId}/${fileName}`]; + if (asset !== undefined) return asset; + if (this.fallback === undefined) throw new Error(`no stub asset for ${toolId}/${fileName}`); + return this.fallback.loadConfigAsset(toolId, fileName); + } + + loadSchema(name: SchemaName): object { + if (this.fallback === undefined) throw new Error(`no stub schema for ${name}`); + return this.fallback.loadSchema(name); + } +} From 7fb9e437af9d62783261d7456df8d5edbbaad90c Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:34:34 +0200 Subject: [PATCH 02/12] test(cli): kill the surviving mutants of the framework domain Manifest, tool entries, tracked files, native registrations, mcp exclusions, serialization, setup flow, config capability, project context, tool recommendations, installed plugins and rules, markdown references, install scope, marketplace source drift and the plugin source resolver: 91 tests, each shown red first against the mutant it names. Framework mutation score: 72.2 before the series, 95.4 after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../domain/config-capability.unit.test.ts | 68 +++++++ .../formats/markdown-references.unit.test.ts | 18 ++ .../domain/install-scope.unit.test.ts | 30 ++- .../domain/installed-rule.unit.test.ts | 11 ++ .../domain/manifest-plugins.unit.test.ts | 46 ++++- .../manifest-serialization.unit.test.ts | 43 +++++ .../framework/domain/manifest.unit.test.ts | 180 ++++++++++++++++++ .../manifest/mcp-exclusions.unit.test.ts | 22 +++ .../native-registrations.unit.test.ts | 40 ++++ .../domain/manifest/tool-entry.unit.test.ts | 108 +++++++++++ .../manifest/tracked-files.unit.test.ts | 92 +++++++++ .../marketplace-source-drift.unit.test.ts | 28 +++ .../plugins/installed-plugin.unit.test.ts | 88 +++++++++ .../plugin-source-resolver.unit.test.ts | 26 +++ .../requested-version-policy.unit.test.ts | 8 + .../user-scope-containment.unit.test.ts | 4 + .../domain/project-context.unit.test.ts | 46 +++++ .../framework/domain/setup-flow.unit.test.ts | 49 +++++ .../domain/tool-recommendations.unit.test.ts | 30 +++ 19 files changed, 935 insertions(+), 2 deletions(-) create mode 100644 cli/tests/contexts/framework/domain/config-capability.unit.test.ts create mode 100644 cli/tests/contexts/framework/domain/manifest-serialization.unit.test.ts create mode 100644 cli/tests/contexts/framework/domain/manifest/mcp-exclusions.unit.test.ts create mode 100644 cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts create mode 100644 cli/tests/contexts/framework/domain/manifest/tool-entry.unit.test.ts create mode 100644 cli/tests/contexts/framework/domain/manifest/tracked-files.unit.test.ts create mode 100644 cli/tests/contexts/framework/domain/plugins/requested-version-policy.unit.test.ts create mode 100644 cli/tests/contexts/framework/domain/project-context.unit.test.ts create mode 100644 cli/tests/contexts/framework/domain/tool-recommendations.unit.test.ts diff --git a/cli/tests/contexts/framework/domain/config-capability.unit.test.ts b/cli/tests/contexts/framework/domain/config-capability.unit.test.ts new file mode 100644 index 000000000..6cd094144 --- /dev/null +++ b/cli/tests/contexts/framework/domain/config-capability.unit.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { extractConfigCapabilities } from "../../../../src/contexts/framework/domain/config-capability.js"; +import { HooksCapability } from "../../../../src/contexts/tools/domain/capabilities/hooks-capability.js"; +import { McpCapability } from "../../../../src/contexts/tools/domain/capabilities/mcp-capability.js"; +import { SettingsCapability } from "../../../../src/contexts/tools/domain/capabilities/settings-capability.js"; +import type { + HasSettings, + IdeToolConfig, +} from "../../../../src/contexts/tools/domain/contracts.js"; +import { stubAiTool } from "../../../helpers/ports/stub-ai-tool.js"; + +const mcp = new McpCapability({ outputPath: ".mcp.json", format: "json" }); +const hooks = new HooksCapability({ outputPath: ".claude/settings.json" }); +const settings = new SettingsCapability({ + outputPath: ".claude/settings.json", + mergeStrategy: "none", +}); +const extensions = new SettingsCapability({ + outputPath: ".vscode/extensions.json", + mergeStrategy: "user-prime", +}); + +function ideTool(declared: SettingsCapability | SettingsCapability[]): IdeToolConfig & HasSettings { + return { + kind: "ide", + toolId: "vscode", + directory: ".vscode/", + signalDir: null, + settings: declared, + }; +} + +describe("extractConfigCapabilities — the config files a tool declares", () => { + describe("an IDE tool", () => { + it("lists each settings file of a tool declaring several", () => { + expect(extractConfigCapabilities(ideTool([extensions, settings]))).toStrictEqual([ + extensions, + settings, + ]); + }); + + it("lists the one settings file of a tool declaring a single one", () => { + expect(extractConfigCapabilities(ideTool(settings))).toStrictEqual([settings]); + }); + }); + + describe("an AI tool", () => { + it("lists mcp, hooks and settings in that order", () => { + const tool = stubAiTool("claude", { mcp, hooks, settings }); + + expect(extractConfigCapabilities(tool)).toStrictEqual([mcp, hooks, settings]); + }); + + it("lists each settings file of a tool declaring several", () => { + const tool = stubAiTool("claude", { settings: [extensions, settings] }); + + expect(extractConfigCapabilities(tool)).toStrictEqual([extensions, settings]); + }); + + it("lists nothing for a tool declaring no config capability", () => { + expect(extractConfigCapabilities(stubAiTool("claude", { rules: {} }))).toStrictEqual([]); + }); + + it("lists nothing for a tool whose capabilities are absent altogether", () => { + expect(extractConfigCapabilities(stubAiTool("claude", null))).toStrictEqual([]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/domain/formats/markdown-references.unit.test.ts b/cli/tests/contexts/framework/domain/formats/markdown-references.unit.test.ts index da212858f..c2bebf809 100644 --- a/cli/tests/contexts/framework/domain/formats/markdown-references.unit.test.ts +++ b/cli/tests/contexts/framework/domain/formats/markdown-references.unit.test.ts @@ -17,6 +17,10 @@ describe("isFileReference", () => { expect(isFileReference("docs/")).toBe(false); }); + it("treats an absolute path whose last segment has an extension as a file", () => { + expect(isFileReference("/docs/guide.md")).toBe(true); + }); + it("returns false for paths with no extension in last segment", () => { expect(isFileReference("src/utils/helper")).toBe(false); expect(isFileReference("justadirectory")).toBe(false); @@ -68,3 +72,17 @@ describe("extractMarkdownLinkTargets", () => { expect(refs).toContain("docs/local.md"); }); }); + +describe("code fences that hide references", () => { + it("ignores a reference inside a fence whose language is a single letter", () => { + const content = "```c\n// @docs/ignored.md\n```\n@docs/visible.md"; + + expect(extractAtReferences(content)).toStrictEqual(["docs/visible.md"]); + }); + + it("ignores a reference inside a fence carrying attributes after its language", () => { + const content = "```ts title=example.ts\n// @docs/ignored.md\n```\n@docs/visible.md"; + + expect(extractAtReferences(content)).toStrictEqual(["docs/visible.md"]); + }); +}); diff --git a/cli/tests/contexts/framework/domain/install-scope.unit.test.ts b/cli/tests/contexts/framework/domain/install-scope.unit.test.ts index 965395f54..096339f65 100644 --- a/cli/tests/contexts/framework/domain/install-scope.unit.test.ts +++ b/cli/tests/contexts/framework/domain/install-scope.unit.test.ts @@ -3,14 +3,18 @@ import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; -import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { afterEach, describe, expect, it } from "vitest"; import { assertToolSupportsScope, getToolSupportedScope, isInstallScope, parseInstallScope, } from "../../../../src/contexts/framework/domain/install-scope.js"; +import { getToolConfig, registerTool } from "../../../../src/contexts/tools/domain/registry.js"; import { InvalidPluginScopeError } from "../../../../src/kernel/errors.js"; +import type { AiToolId } from "../../../../src/kernel/tool.js"; +import { stubAiTool } from "../../../helpers/ports/stub-ai-tool.js"; describe("install-scope value object", () => { describe("isInstallScope", () => { @@ -52,6 +56,30 @@ describe("install-scope value object", () => { expect(getToolSupportedScope("copilot")).toBe("project"); expect(getToolSupportedScope("opencode")).toBe("project"); }); + + it("answers project for a tool that is not an AI tool", () => { + expect(getToolSupportedScope("vscode" as AiToolId)).toBe("project"); + }); + + describe("an AI tool declaring no plugins capability", () => { + const original = getToolConfig("opencode"); + + afterEach(() => { + registerTool(original); + }); + + it("answers project", () => { + registerTool(stubAiTool("opencode", {})); + + expect(getToolSupportedScope("opencode")).toBe("project"); + }); + + it("answers project when the capability names no install scope", () => { + registerTool(stubAiTool("opencode", { plugins: {} })); + + expect(getToolSupportedScope("opencode")).toBe("project"); + }); + }); }); describe("assertToolSupportsScope", () => { diff --git a/cli/tests/contexts/framework/domain/installed-rule.unit.test.ts b/cli/tests/contexts/framework/domain/installed-rule.unit.test.ts index 5ee34790a..a8186fa70 100644 --- a/cli/tests/contexts/framework/domain/installed-rule.unit.test.ts +++ b/cli/tests/contexts/framework/domain/installed-rule.unit.test.ts @@ -75,6 +75,17 @@ describe("toInstalledRule — one installed file, read as a rule", () => { expect(rule.paths).toEqual(["src/**", "tests/**"]); }); + it("drops the empty entries a stray or trailing comma leaves in a scope", () => { + const rule = toInstalledRule( + "cursor", + ".cursor/rules/a.mdc", + ".mdc", + '---\nglobs: "src/**,, tests/**,"\n---\n' + ); + + expect(rule.paths).toStrictEqual(["src/**", "tests/**"]); + }); + /** `SCOPE_FIELDS.flatMap` concatenates without deduplicating, so a glob carried under two * field names at once would come out twice in `InstalledRule.paths`. */ it("states a glob carried under two field names only once", () => { diff --git a/cli/tests/contexts/framework/domain/manifest-plugins.unit.test.ts b/cli/tests/contexts/framework/domain/manifest-plugins.unit.test.ts index 7af067da8..c6cea161e 100644 --- a/cli/tests/contexts/framework/domain/manifest-plugins.unit.test.ts +++ b/cli/tests/contexts/framework/domain/manifest-plugins.unit.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; -import { DuplicatePluginError, PluginNotFoundError } from "../../../../src/kernel/errors.js"; +import { + DuplicatePluginError, + PluginNotFoundError, + ToolNotInManifestError, +} from "../../../../src/kernel/errors.js"; import { FileHash, InstallationFile } from "../../../../src/kernel/file.js"; import type { ToolId } from "../../../../src/kernel/tool.js"; @@ -101,6 +105,46 @@ describe("isFileTracked() with plugins", () => { }); }); +describe("updatePlugin()", () => { + it("replaces only the plugin of that name", () => { + const manifest = makeManifest(); + manifest.addPlugin(CLAUDE, makePlugin("other")); + manifest.addPlugin(CLAUDE, makePlugin("target")); + + manifest.updatePlugin(CLAUDE, makePlugin("target").withVersion("2.0.0")); + + expect(manifest.getPlugins(CLAUDE).map((p) => [p.name, p.version])).toStrictEqual([ + ["other", "1.0.0"], + ["target", "2.0.0"], + ]); + }); + + it("throws PluginNotFoundError when plugin does not exist", () => { + const manifest = makeManifest(); + expect(() => manifest.updatePlugin(CLAUDE, makePlugin("ghost"))).toThrow(PluginNotFoundError); + }); +}); + +describe("a tool that is not installed", () => { + const ABSENT = "codex" as ToolId; + + it("has no plugins", () => { + expect(makeManifest().getPlugins(ABSENT)).toStrictEqual([]); + }); + + it("refuses a plugin, naming the tool", () => { + expect(() => makeManifest().addPlugin(ABSENT, makePlugin())).toThrow(ToolNotInManifestError); + }); + + it("refuses a plugin removal, naming the tool", () => { + expect(() => makeManifest().removePlugin(ABSENT, "my-plugin")).toThrow(ToolNotInManifestError); + }); + + it("refuses a plugin update, naming the tool", () => { + expect(() => makeManifest().updatePlugin(ABSENT, makePlugin())).toThrow(ToolNotInManifestError); + }); +}); + describe("addTool() preserves existing plugins on re-add", () => { it("keeps plugins when addTool is called again", () => { const manifest = makeManifest(); diff --git a/cli/tests/contexts/framework/domain/manifest-serialization.unit.test.ts b/cli/tests/contexts/framework/domain/manifest-serialization.unit.test.ts new file mode 100644 index 000000000..79dbb73e1 --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest-serialization.unit.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { parseManifestTools } from "../../../../src/contexts/framework/domain/manifest-serialization.js"; +import { + InvalidManifestDataError, + InvalidManifestToolIdError, +} from "../../../../src/kernel/errors.js"; + +describe("parseManifestTools — the tools section of a manifest document", () => { + it("reads no tool from a document without a tools section", () => { + expect([...parseManifestTools({ version: 8 })]).toStrictEqual([]); + }); + + it("reads no tool from a document whose tools section is null", () => { + expect([...parseManifestTools({ version: 8, tools: null })]).toStrictEqual([]); + }); + + it("refuses a tool id this CLI does not know, naming it", () => { + const raw = { version: 8, tools: { nope: { toolId: "nope", version: "1.0.0", files: [] } } }; + + expect(() => parseManifestTools(raw)).toThrow(new InvalidManifestToolIdError("nope").message); + expect(() => parseManifestTools(raw)).toThrow(InvalidManifestToolIdError); + }); + + it("says a tool's files are missing when the entry has none", () => { + const raw = { version: 8, tools: { claude: { toolId: "claude", version: "1.0.0" } } }; + + expect(() => parseManifestTools(raw)).toThrow( + "Invalid manifest data: tools.claude.files: expected an array, got missing." + ); + expect(() => parseManifestTools(raw)).toThrow(InvalidManifestDataError); + }); + + it("names the type it found when a tool's files are not an array", () => { + const raw = { + version: 8, + tools: { claude: { toolId: "claude", version: "1.0.0", files: "nope" } }, + }; + + expect(() => parseManifestTools(raw)).toThrow( + "Invalid manifest data: tools.claude.files: expected an array, got string." + ); + }); +}); diff --git a/cli/tests/contexts/framework/domain/manifest.unit.test.ts b/cli/tests/contexts/framework/domain/manifest.unit.test.ts index c9bc39c87..66283a081 100644 --- a/cli/tests/contexts/framework/domain/manifest.unit.test.ts +++ b/cli/tests/contexts/framework/domain/manifest.unit.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import type { McpExclusion } from "../../../../src/contexts/tools/domain/mcp-exclusion.js"; +import { InvalidManifestDataError, ToolNotInManifestError } from "../../../../src/kernel/errors.js"; import { FileHash, InstallationFile } from "../../../../src/kernel/file.js"; import type { MergeFileEntry } from "../../../../src/kernel/merge.js"; import type { ToolId } from "../../../../src/kernel/tool.js"; @@ -486,4 +488,182 @@ describe("Manifest", () => { expect(manifest.hasTool("claude" as ToolId)).toBe(false); }); }); + + describe("a tool that is not installed", () => { + it("has no tracked files", () => { + expect(Manifest.create().getToolFiles("claude" as ToolId)).toStrictEqual([]); + }); + + it("has no MCP exclusions", () => { + expect(Manifest.create().getExcludedMcp("claude" as ToolId)).toStrictEqual([]); + }); + + it("has no native registrations", () => { + expect(Manifest.create().getNativeRegistrations("claude" as ToolId)).toBeUndefined(); + }); + + it("refuses native registrations, naming the tool", () => { + const manifest = Manifest.create(); + + expect(() => + manifest.setNativeRegistrations("claude" as ToolId, { + binary: "claude", + marketplaces: [], + pluginRefs: [], + }) + ).toThrow(new ToolNotInManifestError("claude").message); + expect(() => + manifest.setNativeRegistrations("claude" as ToolId, { + binary: "claude", + marketplaces: [], + pluginRefs: [], + }) + ).toThrow(ToolNotInManifestError); + }); + }); + + describe("native registrations", () => { + it("survive a round trip through the document", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + manifest.setNativeRegistrations("claude" as ToolId, { + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "ai-driven-dev" }], + pluginRefs: ["aidd-context@ai-driven-dev"], + }); + + const restored = Manifest.fromJSON(manifest.toJSON()); + + expect(restored.getNativeRegistrations("claude" as ToolId)).toStrictEqual({ + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "ai-driven-dev" }], + pluginRefs: ["aidd-context@ai-driven-dev"], + }); + }); + }); + + describe("getTrackedPathsInDirectory()", () => { + it("lists the tracked, merged and plugin files under the directory, across tools, and no other", () => { + const manifest = Manifest.create(); + manifest.addTool( + "claude" as ToolId, + "3.0.0", + [makeFile(".claude/rules/a.md", "aa"), makeFile("CLAUDE.md", "bb")], + [ + { relativePath: ".claude/settings.json", sectionKey: "hooks", entries: {} }, + { relativePath: ".mcp.json", sectionKey: "mcpServers", entries: {} }, + ] + ); + manifest.addTool("cursor" as ToolId, "1.0.0", [makeFile(".cursor/rules/b.mdc", "cc")]); + manifest.addPlugin( + "claude" as ToolId, + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: { ".claude/skills/x.md": "d".repeat(32), "AGENTS.md": "e".repeat(32) }, + scope: "project", + }) + ); + + expect([...manifest.getTrackedPathsInDirectory(".claude/")]).toStrictEqual([ + ".claude/rules/a.md", + ".claude/settings.json", + ".claude/skills/x.md", + ]); + }); + }); + + describe("getInstalledDirectories()", () => { + it("names each top-level directory a tracked file sits under, with its trailing slash", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles); + manifest.addTool("cursor" as ToolId, "1.0.0", [makeFile(".cursor/rules/b.mdc", "cc")]); + + expect([...manifest.getInstalledDirectories()]).toStrictEqual([".claude/", ".cursor/"]); + }); + }); + + describe("clearExcludedMcp()", () => { + it("keeps the tool's version and files", () => { + const manifest = Manifest.create(); + manifest.addTool( + "claude" as ToolId, + "3.0.0", + claudeFiles, + [], + [{ configPath: ".mcp.json", entryKey: "playwright" }] + ); + + manifest.clearExcludedMcp("claude" as ToolId); + + expect(manifest.getToolVersion("claude" as ToolId)).toBe("3.0.0"); + expect(manifest.getToolFiles("claude" as ToolId).map((f) => f.relativePath)).toStrictEqual([ + ".claude/agents/code-reviewer.md", + ".claude/rules/naming.md", + ]); + }); + }); + + describe("updateToolMergeFiles()", () => { + const exclusion: McpExclusion = { configPath: ".mcp.json", entryKey: "playwright" }; + + it("replaces the exclusions when handed some", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusion]); + const replacement: McpExclusion = { configPath: ".mcp.json", entryKey: "github" }; + + manifest.updateToolMergeFiles("claude" as ToolId, [], [replacement]); + + expect(manifest.getExcludedMcp("claude" as ToolId)).toStrictEqual([replacement]); + }); + + it("keeps the exclusions when handed none", () => { + const manifest = Manifest.create(); + manifest.addTool("claude" as ToolId, "3.0.0", claudeFiles, [], [exclusion]); + + manifest.updateToolMergeFiles("claude" as ToolId, []); + + expect(manifest.getExcludedMcp("claude" as ToolId)).toStrictEqual([exclusion]); + }); + }); + + describe("fromJSON() on a document that is not an object", () => { + it("refuses null, saying an object was expected", () => { + expect(() => Manifest.fromJSON(null)).toThrow(InvalidManifestDataError); + expect(() => Manifest.fromJSON(null)).toThrow("Invalid manifest data: expected an object."); + }); + + it("refuses a string, saying an object was expected", () => { + expect(() => Manifest.fromJSON("nope")).toThrow("Invalid manifest data: expected an object."); + }); + }); + + describe("version refusal wording", () => { + it("spells out the 5.2.2 remedy, then the deletion, for a version 6 document", () => { + expect(() => Manifest.fromJSON({ version: 6, tools: {} })).toThrow( + "Invalid manifest data: manifest version 6 predates version 8, the only one this CLI reads. " + + "5.2.2, a published CLI, wrote this version. Before deleting it, run " + + "`npx @ai-driven-dev/cli@5.2.2 clean --force` in this project so it unregisters " + + "what it registered and clears its own cache — once the manifest naming those " + + "is gone, nothing can drive that anymore. Then delete .aidd/manifest.json in this project, " + + "then run `aidd setup` to reinstall the framework." + ); + }); + + it("spells out the deletion alone for a version 7 document", () => { + expect(() => Manifest.fromJSON({ version: 7, tools: {} })).toThrow( + "Invalid manifest data: manifest version 7 predates version 8, the only one this CLI reads. " + + "No published CLI can write this version: delete .aidd/manifest.json in this project, " + + "then run `aidd setup` to reinstall the framework." + ); + }); + + it("reads a version that is not a number as unreadable, never as newer", () => { + expect(() => Manifest.fromJSON({ version: "99", tools: {} })).toThrow( + "manifest version 99 predates version 8" + ); + }); + }); }); diff --git a/cli/tests/contexts/framework/domain/manifest/mcp-exclusions.unit.test.ts b/cli/tests/contexts/framework/domain/manifest/mcp-exclusions.unit.test.ts new file mode 100644 index 000000000..d4f33fd24 --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest/mcp-exclusions.unit.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { + addExclusions, + removeExclusions, +} from "../../../../../src/contexts/framework/domain/manifest/mcp-exclusions.js"; +import type { McpExclusion } from "../../../../../src/contexts/tools/domain/mcp-exclusion.js"; + +const playwright: McpExclusion = { configPath: ".mcp.json", entryKey: "playwright" }; +const github: McpExclusion = { configPath: ".mcp.json", entryKey: "github" }; +const context7: McpExclusion = { configPath: ".mcp.json", entryKey: "context7" }; + +describe("MCP exclusions recorded for a tool", () => { + it("keeps the exclusions already recorded ahead of the ones added", () => { + expect(addExclusions([playwright], [github])).toStrictEqual([playwright, github]); + }); + + it("removes exactly the exclusions named, keeping the rest", () => { + expect(removeExclusions([playwright, github, context7], [playwright, context7])).toStrictEqual([ + github, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts b/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts new file mode 100644 index 000000000..3fe84e62a --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + type NativeRegistrations, + parseNativeRegistrations, + toNativeRegistrationsData, +} from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; + +const registrations: NativeRegistrations = { + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "ai-driven-dev" }], + pluginRefs: ["aidd-context@ai-driven-dev"], +}; + +describe("native registrations — what a tool's own CLI was asked to register", () => { + describe("serialized", () => { + it("carries the binary, every marketplace pair and every plugin ref", () => { + expect(toNativeRegistrationsData(registrations)).toStrictEqual({ + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "ai-driven-dev" }], + pluginRefs: ["aidd-context@ai-driven-dev"], + }); + }); + }); + + describe("parsed", () => { + it("reads back the binary, every marketplace pair and every plugin ref", () => { + expect( + parseNativeRegistrations({ + binary: "codex", + marketplaces: [{ alias: "local", hostName: "declared" }], + pluginRefs: ["a@declared", "b@declared"], + }) + ).toStrictEqual({ + binary: "codex", + marketplaces: [{ alias: "local", hostName: "declared" }], + pluginRefs: ["a@declared", "b@declared"], + }); + }); + }); +}); diff --git a/cli/tests/contexts/framework/domain/manifest/tool-entry.unit.test.ts b/cli/tests/contexts/framework/domain/manifest/tool-entry.unit.test.ts new file mode 100644 index 000000000..c0fe68116 --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest/tool-entry.unit.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { + createToolEntry, + isFileTrackedInEntry, + parseToolEntry, + removePluginFromEntry, + serializeToolEntry, + type ToolEntry, + updatePluginInEntry, +} from "../../../../../src/contexts/framework/domain/manifest/tool-entry.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginNotFoundError } from "../../../../../src/kernel/errors.js"; +import { FileHash, InstallationFile } from "../../../../../src/kernel/file.js"; + +const makePlugin = (name: string, version = "1.0.0"): InstalledPlugin => + InstalledPlugin.fromJSON({ + name, + source: { kind: "github", repo: `owner/${name}` }, + version, + strict: false, + files: { [`.claude/plugins/${name}/README.md`]: "c".repeat(32) }, + scope: "project", + }); + +const makeEntry = (plugins: InstalledPlugin[]): ToolEntry => + createToolEntry({ + toolId: "claude", + version: "3.0.0", + files: [ + new InstallationFile({ + relativePath: ".claude/CLAUDE.md", + content: "content", + hash: new FileHash("a".repeat(32)), + }), + ], + mergeFiles: [{ relativePath: ".mcp.json", sectionKey: "mcpServers", entries: {} }], + excludedMcp: [], + existingPlugins: plugins, + }); + +describe("a tool's manifest entry", () => { + describe("removing a plugin", () => { + it("keeps every other plugin", () => { + const entry = makeEntry([makePlugin("keep-a"), makePlugin("drop"), makePlugin("keep-b")]); + + const updated = removePluginFromEntry(entry, "drop"); + + expect(updated.plugins.map((p) => p.name)).toStrictEqual(["keep-a", "keep-b"]); + }); + + it("refuses a plugin that is not installed even while others are", () => { + const entry = makeEntry([makePlugin("installed")]); + + expect(() => removePluginFromEntry(entry, "ghost")).toThrow(PluginNotFoundError); + }); + }); + + describe("updating a plugin", () => { + it("replaces only the plugin of that name", () => { + const entry = makeEntry([makePlugin("other"), makePlugin("target")]); + + const updated = updatePluginInEntry(entry, makePlugin("target", "2.0.0")); + + expect(updated.plugins.map((p) => [p.name, p.version])).toStrictEqual([ + ["other", "1.0.0"], + ["target", "2.0.0"], + ]); + }); + + it("refuses a plugin that is not installed", () => { + const entry = makeEntry([makePlugin("installed")]); + + expect(() => updatePluginInEntry(entry, makePlugin("ghost"))).toThrow(PluginNotFoundError); + }); + }); + + describe("tracking a file", () => { + it("does not count a path as tracked because some merge file exists", () => { + const entry = makeEntry([]); + + expect(isFileTrackedInEntry(entry, ".claude/settings.json")).toBe(false); + }); + }); + + describe("serialized", () => { + it("carries what the tool's own CLI was asked to register through a round trip", () => { + const entry: ToolEntry = { + ...makeEntry([]), + nativeRegistrations: { + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "ai-driven-dev" }], + pluginRefs: ["aidd-context@ai-driven-dev"], + }, + }; + + const data = serializeToolEntry(entry); + + expect(data.nativeRegistrations).toStrictEqual({ + binary: "claude", + marketplaces: [{ alias: "aidd-framework", hostName: "ai-driven-dev" }], + pluginRefs: ["aidd-context@ai-driven-dev"], + }); + expect(parseToolEntry("claude", data).nativeRegistrations).toStrictEqual( + entry.nativeRegistrations + ); + }); + }); +}); diff --git a/cli/tests/contexts/framework/domain/manifest/tracked-files.unit.test.ts b/cli/tests/contexts/framework/domain/manifest/tracked-files.unit.test.ts new file mode 100644 index 000000000..da94efa05 --- /dev/null +++ b/cli/tests/contexts/framework/domain/manifest/tracked-files.unit.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + parseTrackedFiles, + type TrackedFile, + toTrackedFileData, + toTrackedFiles, + withUpdatedHash, +} from "../../../../../src/contexts/framework/domain/manifest/tracked-files.js"; +import { FileHash, InstallationFile } from "../../../../../src/kernel/file.js"; + +const HASH_A = new FileHash("a".repeat(32)); +const HASH_B = new FileHash("b".repeat(32)); +const HASH_NEW = new FileHash("f".repeat(32)); + +const installed = (relativePath: string, frameworkPath?: string): InstallationFile => + new InstallationFile({ relativePath, content: "content", hash: HASH_A, frameworkPath }); + +describe("tracked files — one tool's paths and hashes", () => { + describe("recorded from an installation", () => { + it("keeps where a framework-owned file came from", () => { + expect(toTrackedFiles([installed(".claude/rules/a.md", "rules/a.md")])).toStrictEqual([ + { relativePath: ".claude/rules/a.md", hash: HASH_A, frameworkPath: "rules/a.md" }, + ]); + }); + + it("records no origin for a file that has none", () => { + expect(toTrackedFiles([installed(".claude/CLAUDE.md")])).toStrictEqual([ + { relativePath: ".claude/CLAUDE.md", hash: HASH_A }, + ]); + }); + }); + + describe("serialized", () => { + it("writes the origin beside the hash for a framework-owned file", () => { + const file: TrackedFile = { + relativePath: ".claude/rules/a.md", + hash: HASH_A, + frameworkPath: "rules/a.md", + }; + + expect(toTrackedFileData([file])).toStrictEqual([ + { relativePath: ".claude/rules/a.md", hash: "a".repeat(32), frameworkPath: "rules/a.md" }, + ]); + }); + + it("writes no origin key for a file that has none", () => { + expect( + toTrackedFileData([{ relativePath: ".claude/CLAUDE.md", hash: HASH_A }]) + ).toStrictEqual([{ relativePath: ".claude/CLAUDE.md", hash: "a".repeat(32) }]); + }); + }); + + describe("parsed", () => { + it("reads the origin back for a framework-owned file", () => { + expect( + parseTrackedFiles([ + { relativePath: ".claude/rules/a.md", hash: "a".repeat(32), frameworkPath: "rules/a.md" }, + ]) + ).toStrictEqual([ + { relativePath: ".claude/rules/a.md", hash: HASH_A, frameworkPath: "rules/a.md" }, + ]); + }); + + it("reads no origin key for a file that has none", () => { + expect( + parseTrackedFiles([{ relativePath: ".claude/CLAUDE.md", hash: "a".repeat(32) }]) + ).toStrictEqual([{ relativePath: ".claude/CLAUDE.md", hash: HASH_A }]); + }); + }); + + describe("updating one file's hash", () => { + const files: TrackedFile[] = [ + { relativePath: ".claude/a.md", hash: HASH_A }, + { relativePath: ".claude/b.md", hash: HASH_B }, + ]; + + it("changes that file alone", () => { + expect(withUpdatedHash(files, ".claude/b.md", HASH_NEW)).toStrictEqual([ + { relativePath: ".claude/a.md", hash: HASH_A }, + { relativePath: ".claude/b.md", hash: HASH_NEW }, + ]); + }); + + it("appends a bare entry for a path not yet tracked, touching no other", () => { + expect(withUpdatedHash(files, ".claude/c.md", HASH_NEW)).toStrictEqual([ + { relativePath: ".claude/a.md", hash: HASH_A }, + { relativePath: ".claude/b.md", hash: HASH_B }, + { relativePath: ".claude/c.md", hash: HASH_NEW }, + ]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/domain/marketplace-source-drift.unit.test.ts b/cli/tests/contexts/framework/domain/marketplace-source-drift.unit.test.ts index 4b1c342fe..f0212d472 100644 --- a/cli/tests/contexts/framework/domain/marketplace-source-drift.unit.test.ts +++ b/cli/tests/contexts/framework/domain/marketplace-source-drift.unit.test.ts @@ -96,6 +96,34 @@ describe("marketplaceSourceDrift — deciding purely from the path's own segment ).toBeUndefined(); }); + it("is undefined when the requested path names another marketplace than this run's", () => { + const requested = userBuiltMarketplaceDir( + DRIFT_CONTEXT.userCacheRoot, + "1.0.0", + "other-mkt", + "claude" + ); + + expect(marketplaceSourceDrift(sharedPath("2.0.0"), requested, DRIFT_CONTEXT)).toBeUndefined(); + }); + + it("is undefined when the requested path names another tool than this run's", () => { + const requested = userBuiltMarketplaceDir( + DRIFT_CONTEXT.userCacheRoot, + "1.0.0", + "aidd-framework", + "codex" + ); + + expect(marketplaceSourceDrift(sharedPath("2.0.0"), requested, DRIFT_CONTEXT)).toBeUndefined(); + }); + + it("is undefined when the requested version segment is not semver", () => { + expect( + marketplaceSourceDrift(sharedPath("2.0.0"), sharedPath("not-a-version"), DRIFT_CONTEXT) + ).toBeUndefined(); + }); + it("is undefined when the registered path names a different marketplace or tool under the shared cache root", () => { const registered = userBuiltMarketplaceDir( DRIFT_CONTEXT.userCacheRoot, diff --git a/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts index 8e87fc4ce..f5899af98 100644 --- a/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts +++ b/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts @@ -4,13 +4,23 @@ import { InstalledPlugin, type McpDigestMap, type PluginEntryData, + parsePluginSpec, } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; import { InvalidPluginNameError, InvalidPluginVersionError, MalformedPluginScopeError, } from "../../../../../src/kernel/errors.js"; +const makeDistribution = (strict?: boolean): PluginDistribution => + new PluginDistribution({ + manifest: { name: "my-plugin", version: "1.0.0", ...(strict === undefined ? {} : { strict }) }, + format: "claude", + files: [], + components: { skills: [], commands: [], agents: [], rules: [], hooks: [], mcp: [] }, + }); + const makePluginData = (overrides: Partial = {}): PluginEntryData => ({ name: "my-plugin", source: { kind: "github", repo: "owner/my-plugin" }, @@ -156,6 +166,60 @@ describe("InstalledPlugin", () => { }); }); + describe("fromJSON() on a name that starts well and ends badly", () => { + it("throws InvalidPluginNameError for a name whose tail is not a segment", () => { + expect(() => InstalledPlugin.fromJSON(makePluginData({ name: "my-plugin_" }))).toThrow( + InvalidPluginNameError + ); + }); + }); + + describe("toJSON() for a plugin installed from no marketplace", () => { + it("writes no marketplace key at all", () => { + const data = makePluginData(); + + expect(InstalledPlugin.fromJSON(data).toJSON()).toStrictEqual(data); + }); + }); + + describe("fromDistribution()", () => { + const source = { kind: "github", repo: "owner/my-plugin" } as const; + + it("is strict only where the distribution's manifest says so", () => { + expect( + InstalledPlugin.fromDistribution(makeDistribution(true), source, [], "project").strict + ).toBe(true); + }); + + it("is lenient where the distribution's manifest says nothing", () => { + expect( + InstalledPlugin.fromDistribution(makeDistribution(), source, [], "project").strict + ).toBe(false); + }); + + it("keeps each installed path's component path", () => { + const componentPaths = new Map([[".claude/rules/naming.md", "rules/naming.md"]]); + + const plugin = InstalledPlugin.fromDistribution( + makeDistribution(), + source, + [], + "project", + componentPaths + ); + + expect([...plugin.componentPaths]).toStrictEqual([ + [".claude/rules/naming.md", "rules/naming.md"], + ]); + }); + + it("keeps no component path when handed none", () => { + const plugin = InstalledPlugin.fromDistribution(makeDistribution(), source, [], "project"); + + expect([...plugin.componentPaths]).toStrictEqual([]); + }); + }); + describe("the three maps cannot be swapped", () => { it("fails to compile when one map's field is passed where another is expected", () => { const plugin = InstalledPlugin.fromJSON(makePluginData()); @@ -174,3 +238,27 @@ describe("InstalledPlugin", () => { }); }); }); + +describe("parsePluginSpec — a plugin argument as typed on the command line", () => { + it("reads the version after the last @", () => { + expect(parsePluginSpec("aidd-context@1.2.3")).toStrictEqual({ + name: "aidd-context", + version: "1.2.3", + }); + }); + + it("reads a bare name as the name alone, requesting no version", () => { + expect(parsePluginSpec("aidd-context")).toStrictEqual({ name: "aidd-context" }); + }); + + it("keeps a leading @ as part of a scoped name", () => { + expect(parsePluginSpec("@scope/plugin")).toStrictEqual({ name: "@scope/plugin" }); + }); + + it("splits a scoped name from its version at the last @", () => { + expect(parsePluginSpec("@scope/plugin@2.0.0")).toStrictEqual({ + name: "@scope/plugin", + version: "2.0.0", + }); + }); +}); diff --git a/cli/tests/contexts/framework/domain/plugins/plugin-source-resolver.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/plugin-source-resolver.unit.test.ts index 2061a28a5..eaede3b17 100644 --- a/cli/tests/contexts/framework/domain/plugins/plugin-source-resolver.unit.test.ts +++ b/cli/tests/contexts/framework/domain/plugins/plugin-source-resolver.unit.test.ts @@ -134,6 +134,32 @@ describe("resolvePluginSourceFromMarketplace", () => { }); }); + it("returns source unchanged when the absolute path is the marketplace dir itself", () => { + const entrySource: PluginSource = { kind: "local", path: MARKETPLACE_LOCAL_PATH }; + const marketplace = makeGithubMarketplace("org/repo", "main"); + + const result = resolvePluginSourceFromMarketplace( + entrySource, + marketplace, + MARKETPLACE_LOCAL_PATH + ); + + expect(result).toBe(entrySource); + }); + + it("returns source unchanged when the relative path is only ./", () => { + const entrySource: PluginSource = { kind: "local", path: "./" }; + const marketplace = makeGithubMarketplace("org/repo", "main"); + + const result = resolvePluginSourceFromMarketplace( + entrySource, + marketplace, + MARKETPLACE_LOCAL_PATH + ); + + expect(result).toBe(entrySource); + }); + it("returns source unchanged when absolute path is outside marketplace dir", () => { const entrySource: PluginSource = { kind: "local", path: "/some/other/absolute/path" }; const marketplace = makeGithubMarketplace("org/repo", "main"); diff --git a/cli/tests/contexts/framework/domain/plugins/requested-version-policy.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/requested-version-policy.unit.test.ts new file mode 100644 index 000000000..595fc550a --- /dev/null +++ b/cli/tests/contexts/framework/domain/plugins/requested-version-policy.unit.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_REQUESTED_VERSION_POLICY } from "../../../../../src/contexts/framework/domain/plugins/requested-version-policy.js"; + +describe("requested version policy", () => { + it("holds a requested version strictly unless a caller relaxes it", () => { + expect(DEFAULT_REQUESTED_VERSION_POLICY).toBe("strict"); + }); +}); diff --git a/cli/tests/contexts/framework/domain/plugins/user-scope-containment.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/user-scope-containment.unit.test.ts index 3a566c9e3..e7ed34057 100644 --- a/cli/tests/contexts/framework/domain/plugins/user-scope-containment.unit.test.ts +++ b/cli/tests/contexts/framework/domain/plugins/user-scope-containment.unit.test.ts @@ -28,6 +28,10 @@ describe("isStrictlyWithinUserScope", () => { expect(isStrictlyWithinUserScope(BOUNDARY, BOUNDARY)).toBe(false); }); + it("rejects the boundary directory spelled with a trailing separator", () => { + expect(isStrictlyWithinUserScope(`${BOUNDARY}/`, BOUNDARY)).toBe(false); + }); + it("rejects a path that merely starts with the boundary's characters without a separator", () => { // Textually starts with BOUNDARY but is a sibling directory, not something inside it — // a naive `startsWith` would wrongly accept this. diff --git a/cli/tests/contexts/framework/domain/project-context.unit.test.ts b/cli/tests/contexts/framework/domain/project-context.unit.test.ts new file mode 100644 index 000000000..8cb9e56b3 --- /dev/null +++ b/cli/tests/contexts/framework/domain/project-context.unit.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { ProjectContext } from "../../../../src/contexts/framework/domain/project-context.js"; + +describe("ProjectContext.describe() — one line naming what was detected", () => { + it("names every detected trait, dot-separated, in stack, monorepo, framework order", () => { + const context = new ProjectContext({ + stack: "typescript", + isMonorepo: true, + hasFramework: true, + }); + + expect(context.describe()).toBe("typescript · monorepo · AIDD present"); + }); + + it("names the stack alone for a single-package project without the framework", () => { + const context = new ProjectContext({ + stack: "typescript", + isMonorepo: false, + hasFramework: false, + }); + + expect(context.describe()).toBe("typescript"); + }); + + it("omits an unknown stack while still naming the monorepo", () => { + const context = new ProjectContext({ stack: "unknown", isMonorepo: true, hasFramework: false }); + + expect(context.describe()).toBe("monorepo"); + }); + + it("names the framework beside the stack once it is installed", () => { + const context = new ProjectContext({ stack: "python", isMonorepo: false, hasFramework: true }); + + expect(context.describe()).toBe("python · AIDD present"); + }); + + it("calls a project with nothing detected an unknown project", () => { + const context = new ProjectContext({ + stack: "unknown", + isMonorepo: false, + hasFramework: false, + }); + + expect(context.describe()).toBe("unknown project"); + }); +}); diff --git a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts index c371d47db..44b4ca86a 100644 --- a/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts +++ b/cli/tests/contexts/framework/domain/setup-flow.unit.test.ts @@ -46,6 +46,51 @@ describe("SetupFlow", () => { expect(flow.projectRoot).toBe(ROOT); expect(flow.aiTools).toEqual(["claude"]); }); + + it("accepts mode 'named' once a plugin is named, carrying the names through", () => { + const flow = makeFlow({ pluginMode: "named", pluginNames: ["my-plugin"] }); + + expect(flow.pluginMode).toBe("named"); + expect(flow.pluginNames).toStrictEqual(["my-plugin"]); + }); + + it("says what mode 'named' needs when no plugin is named", () => { + expect(() => makeFlow({ pluginMode: "named", pluginNames: [] })).toThrow( + 'Plugin mode "named" requires at least one plugin name.' + ); + }); + + it("names the mode that was given when names arrive under another mode", () => { + expect(() => makeFlow({ pluginMode: "all", pluginNames: ["my-plugin"] })).toThrow( + 'Plugin names provided but mode is "all" (expected "named").' + ); + }); + }); + + describe("defaults for what a caller leaves unsaid", () => { + it("installs no plugin", () => { + expect(makeFlow().pluginMode).toBe("none"); + }); + + it("names no plugin", () => { + expect(makeFlow().pluginNames).toStrictEqual([]); + }); + + it("is not interactive", () => { + expect(makeFlow().interactive).toBe(false); + }); + + it("does not force", () => { + expect(makeFlow().force).toBe(false); + }); + + it("carries a plugin mode through when one is given", () => { + expect(makeFlow({ pluginMode: "all" }).pluginMode).toBe("all"); + }); + + it("carries force through when asked", () => { + expect(makeFlow({ force: true }).force).toBe(true); + }); }); describe("scope", () => { @@ -71,6 +116,10 @@ describe("SetupFlow", () => { expect(() => makeFlow({ scope: "user", aiTools: [] })).toThrow(UserScopeNoToolsError); }); + it("refuses --scope user when --ai is left unsaid, the same as an empty one", () => { + expect(() => makeFlow({ scope: "user" })).toThrow(UserScopeNoToolsError); + }); + it("refuses an AI tool with no user-scope activation at --scope user (opencode)", () => { expect(() => makeFlow({ scope: "user", aiTools: ["opencode"] })).toThrow( UserScopeUnsupportedAiToolsError diff --git a/cli/tests/contexts/framework/domain/tool-recommendations.unit.test.ts b/cli/tests/contexts/framework/domain/tool-recommendations.unit.test.ts new file mode 100644 index 000000000..272316590 --- /dev/null +++ b/cli/tests/contexts/framework/domain/tool-recommendations.unit.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { ProjectContext } from "../../../../src/contexts/framework/domain/project-context.js"; +import { + recommendAiTools, + recommendIdeTools, +} from "../../../../src/contexts/framework/domain/tool-recommendations.js"; + +describe("tool recommendations for a project whose stack is not recognised", () => { + it("recommends claude alone as the AI tool", () => { + const context = new ProjectContext({ + stack: "unknown", + isMonorepo: false, + hasFramework: false, + }); + + expect(recommendAiTools(context)).toStrictEqual(["claude"]); + }); +}); + +describe("tool recommendations once the framework is installed", () => { + it("recommends no IDE tool even for a typescript project", () => { + const context = new ProjectContext({ + stack: "typescript", + isMonorepo: false, + hasFramework: true, + }); + + expect(recommendIdeTools(context)).toStrictEqual([]); + }); +}); From 9b6dd6a09a1d24fb78ef29bf52ae0372db8a3fc8 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:34:40 +0200 Subject: [PATCH 03/12] test(cli): kill the surviving mutants of the framework adapters Plugin distribution reader, user source references, manifest and user manifest repositories, manifest file io and the environment adapter: 34 tests, each shown red first against the mutant it names. Framework mutation score: 72.2 before the series, 95.4 after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../environment-adapter.unit.test.ts | 23 ++ ...est-repository-adapter.integration.test.ts | 10 + ...ibution-reader-adapter.integration.test.ts | 223 ++++++++++++++++++ ...est-repository-adapter.integration.test.ts | 9 + ...ser-source-references-adapter.unit.test.ts | 103 ++++++++ 5 files changed, 368 insertions(+) create mode 100644 cli/tests/contexts/framework/infrastructure/environment-adapter.unit.test.ts diff --git a/cli/tests/contexts/framework/infrastructure/environment-adapter.unit.test.ts b/cli/tests/contexts/framework/infrastructure/environment-adapter.unit.test.ts new file mode 100644 index 000000000..e020613da --- /dev/null +++ b/cli/tests/contexts/framework/infrastructure/environment-adapter.unit.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { EnvironmentAdapter } from "../../../../src/contexts/framework/infrastructure/environment-adapter.js"; + +const VARIABLE = "AIDD_ENVIRONMENT_ADAPTER_PROBE"; + +describe("EnvironmentAdapter", () => { + afterEach(() => { + delete process.env[VARIABLE]; + }); + + it("reads a variable set after it was constructed", () => { + const environment = new EnvironmentAdapter(); + process.env[VARIABLE] = "later"; + + expect(environment.get(VARIABLE)).toBe("later"); + }); + + it("writes a variable the process itself then sees", () => { + new EnvironmentAdapter().set(VARIABLE, "written"); + + expect(process.env[VARIABLE]).toBe("written"); + }); +}); diff --git a/cli/tests/contexts/framework/infrastructure/manifest-repository-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/manifest-repository-adapter.integration.test.ts index 0e5913bd0..61d684223 100644 --- a/cli/tests/contexts/framework/infrastructure/manifest-repository-adapter.integration.test.ts +++ b/cli/tests/contexts/framework/infrastructure/manifest-repository-adapter.integration.test.ts @@ -38,6 +38,16 @@ describe("ManifestRepositoryAdapter", () => { await expect(adapter.load()).rejects.toThrow(manifestPath); }); + + it("rejects a refused manifest version naming this project as where it lives and `aidd setup` as the fix", async () => { + const manifestPath = join(tempDir, ".aidd", "manifest.json"); + await mkdir(join(tempDir, ".aidd"), { recursive: true }); + await writeFile(manifestPath, '{"version": 7, "tools": {}}'); + + await expect(adapter.load()).rejects.toThrow( + `delete ${manifestPath} in this project, then run \`aidd setup\` to reinstall the framework.` + ); + }); }); describe("save() + load() roundtrip", () => { diff --git a/cli/tests/contexts/framework/infrastructure/plugin-distribution-reader-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/plugin-distribution-reader-adapter.integration.test.ts index 3346af9ec..7aca62c86 100644 --- a/cli/tests/contexts/framework/infrastructure/plugin-distribution-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/framework/infrastructure/plugin-distribution-reader-adapter.integration.test.ts @@ -3,6 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import type { + PluginComponentFile, + PluginDistribution, +} from "../../../../src/contexts/translate/domain/plugin-distribution.js"; // Side-effect imports: the adapter reads each tool's declared manifest locations off the // registry, so an unregistered profile is a format it cannot recognise. import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; @@ -13,16 +17,41 @@ import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { InvalidPluginManifestError, InvalidPluginNameError, + InvalidPluginVersionError, } from "../../../../src/kernel/errors.js"; import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; const FIXTURE_DIR = join(process.cwd(), "tests/fixtures/plugins"); +const IN_MEMORY_ROOT = "/plugins/in-memory"; +const IN_MEMORY_MANIFEST = ".claude-plugin/plugin.json"; function makeAdapter(): PluginDistributionReaderAdapter { return new PluginDistributionReaderAdapter(new FileAdapter(new HasherAdapter())); } +function readInMemory( + manifestText: string, + files: Record = {} +): Promise { + const seed: Record = { + [`${IN_MEMORY_ROOT}/${IN_MEMORY_MANIFEST}`]: manifestText, + }; + for (const [relativePath, content] of Object.entries(files)) { + seed[`${IN_MEMORY_ROOT}/${relativePath}`] = content; + } + return new PluginDistributionReaderAdapter(new InMemoryFileAdapter(seed)).read(IN_MEMORY_ROOT); +} + +function readManifest(manifest: Record): Promise { + return readInMemory(JSON.stringify(manifest)); +} + +function byPath(a: PluginComponentFile, b: PluginComponentFile): number { + return a.relativePath.localeCompare(b.relativePath); +} + describe("PluginDistributionReaderAdapter", () => { describe("claude-format fixture", () => { it("detects claude format", async () => { @@ -165,5 +194,199 @@ describe("PluginDistributionReaderAdapter", () => { InvalidPluginManifestError ); }); + + it("names the directory it searched", async () => { + const reader = new PluginDistributionReaderAdapter(new InMemoryFileAdapter()); + + await expect(reader.read("/plugins/none")).rejects.toThrow( + new InvalidPluginManifestError('no plugin.json found in "/plugins/none"') + ); + }); + }); + + describe("collecting a plugin's files", () => { + const manifestText = JSON.stringify({ name: "full", version: "1.0.0" }); + const componentFiles = { + "skills/hello/SKILL.md": "skill", + "commands/greet.md": "command", + "agents/reviewer.md": "agent", + "rules/style.md": "rule", + "hooks/hooks.json": "{}", + ".mcp.json": "{}", + }; + const otherFiles = { + "README.md": "readme", + LICENSE: "license", + "docs/guide.md": "guide", + }; + + it("keeps every component file with its content, plus the manifest, and nothing else", async () => { + const dist = await readInMemory(manifestText, { ...componentFiles, ...otherFiles }); + + expect([...dist.files].sort(byPath)).toStrictEqual([ + { relativePath: IN_MEMORY_MANIFEST, content: manifestText }, + { relativePath: ".mcp.json", content: "{}" }, + { relativePath: "agents/reviewer.md", content: "agent" }, + { relativePath: "commands/greet.md", content: "command" }, + { relativePath: "hooks/hooks.json", content: "{}" }, + { relativePath: "rules/style.md", content: "rule" }, + { relativePath: "skills/hello/SKILL.md", content: "skill" }, + ]); + }); + + it("sorts each file under its own component kind, the manifest under none", async () => { + const dist = await readInMemory(manifestText, componentFiles); + + expect(dist.components).toStrictEqual({ + skills: [{ relativePath: "skills/hello/SKILL.md", content: "skill" }], + commands: [{ relativePath: "commands/greet.md", content: "command" }], + agents: [{ relativePath: "agents/reviewer.md", content: "agent" }], + rules: [{ relativePath: "rules/style.md", content: "rule" }], + hooks: [{ relativePath: "hooks/hooks.json", content: "{}" }], + mcp: [{ relativePath: ".mcp.json", content: "{}" }], + }); + }); + + it.skipIf(process.platform === "win32")( + "reads a listing given with backslash separators as posix paths", + async () => { + const root = await mkdtemp(join(tmpdir(), "aidd-backslash-listing-")); + try { + await mkdir(join(root, ".claude-plugin"), { recursive: true }); + await writeFile(join(root, ".claude-plugin/plugin.json"), manifestText); + await writeFile(join(root, "skills\\hello.md"), "skill"); + + const dist = await makeAdapter().read(root); + + expect(dist.components.skills).toStrictEqual([ + { relativePath: "skills/hello.md", content: "skill" }, + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } + } + ); + }); + + describe("the manifest's fields", () => { + it("keeps name and version alone when nothing else is declared", async () => { + const dist = await readManifest({ name: "full", version: "1.0.0" }); + + expect(dist.manifest).toStrictEqual({ name: "full", version: "1.0.0" }); + }); + + it("keeps a string description", async () => { + const dist = await readManifest({ name: "full", version: "1.0.0", description: "A plugin" }); + + expect(dist.manifest).toStrictEqual({ + name: "full", + version: "1.0.0", + description: "A plugin", + }); + }); + + it("drops a description that is not a string", async () => { + const dist = await readManifest({ name: "full", version: "1.0.0", description: 42 }); + + expect(dist.manifest).toStrictEqual({ name: "full", version: "1.0.0" }); + }); + + it("keeps the author's name and email", async () => { + const dist = await readManifest({ + name: "full", + version: "1.0.0", + author: { name: "Ann", email: "ann@example.com" }, + }); + + expect(dist.manifest).toStrictEqual({ + name: "full", + version: "1.0.0", + author: { name: "Ann", email: "ann@example.com" }, + }); + }); + + it("keeps the author's name alone when the email is not a string", async () => { + const dist = await readManifest({ + name: "full", + version: "1.0.0", + author: { name: "Ann", email: 5 }, + }); + + expect(dist.manifest).toStrictEqual({ + name: "full", + version: "1.0.0", + author: { name: "Ann" }, + }); + }); + + it("drops an author whose name is not a string", async () => { + const dist = await readManifest({ + name: "full", + version: "1.0.0", + author: { name: 5, email: "ann@example.com" }, + }); + + expect(dist.manifest).toStrictEqual({ name: "full", version: "1.0.0" }); + }); + + it("drops an author that is null", async () => { + const dist = await readManifest({ name: "full", version: "1.0.0", author: null }); + + expect(dist.manifest).toStrictEqual({ name: "full", version: "1.0.0" }); + }); + }); + + describe("a manifest it refuses", () => { + it("names invalid JSON", async () => { + await expect(readInMemory("{ not json")).rejects.toThrow( + new InvalidPluginManifestError("plugin.json is not valid JSON") + ); + }); + + it.each([ + ["null", "null"], + ["a list", "[]"], + ["a string", '"full"'], + ])("names a top level that is %s rather than an object", async (_shape, manifestText) => { + await expect(readInMemory(manifestText)).rejects.toThrow( + new InvalidPluginManifestError("plugin.json must be a JSON object") + ); + }); + + it("names a missing name", async () => { + await expect(readManifest({ version: "1.0.0" })).rejects.toThrow( + new InvalidPluginManifestError('"name" must be a non-empty string') + ); + }); + + it("names an empty name", async () => { + await expect(readManifest({ name: "", version: "1.0.0" })).rejects.toThrow( + new InvalidPluginManifestError('"name" must be a non-empty string') + ); + }); + + it("names a name outside lowercase alphanumerics and hyphens", async () => { + await expect(readManifest({ name: "Bad Name", version: "1.0.0" })).rejects.toThrow( + new InvalidPluginNameError("Bad Name") + ); + }); + + it("names a missing version", async () => { + await expect(readManifest({ name: "full" })).rejects.toThrow( + new InvalidPluginManifestError('"version" must be a non-empty string') + ); + }); + + it("names an empty version", async () => { + await expect(readManifest({ name: "full", version: "" })).rejects.toThrow( + new InvalidPluginManifestError('"version" must be a non-empty string') + ); + }); + + it("names a version that is not semver", async () => { + await expect(readManifest({ name: "full", version: "latest" })).rejects.toThrow( + new InvalidPluginVersionError("latest") + ); + }); }); }); diff --git a/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts index 3490b66e8..c498d09c4 100644 --- a/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts +++ b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts @@ -47,6 +47,15 @@ describe("UserManifestRepositoryAdapter", () => { await expect(adapter.load()).rejects.not.toThrow(/\.aidd\/manifest\.json/); await expect(adapter.load()).rejects.not.toThrow(/in this project/); }); + + it("names this machine as where the refused manifest lives", async () => { + const manifestPath = join(userConfigDir, "manifest.json"); + await writeFile(manifestPath, '{"version": 7, "tools": {}}'); + + await expect(adapter.load()).rejects.toThrow( + `delete ${manifestPath} for this machine, then run \`aidd setup --scope user\` to reinstall the framework.` + ); + }); }); describe("save() + load() roundtrip", () => { diff --git a/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts b/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts index d52fea93b..68c61e3de 100644 --- a/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts +++ b/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts @@ -90,6 +90,55 @@ describe("the shared source's own project references", () => { // Nothing here asks which version is "current", only where the project is recorded, so a CLI // self-update between the `sync` that wrote the reference and this read cannot strand it. + it("re-adding a project already recorded under its version leaves the file untouched, even a vanished neighbour", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + markExisting(fs, "/project-b"); + const refs = adapter(fs); + await refs.addReference("1.0.0", "/project-a"); + await refs.addReference("1.0.0", "/project-b"); + await fs.deleteFile("/project-b/marker"); + + await refs.addReference("1.0.0", "/project-a"); + + expect(JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}")).toStrictEqual({ + "1.0.0": ["/project-a", "/project-b"], + }); + }); + + it("a project recorded under two versions ends up recorded once, under the version asked", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + markExisting(fs, "/project-z"); + fs.setFile( + REFERENCES_PATH, + JSON.stringify({ "1.0.0": ["/project-a"], "2.0.0": ["/project-a", "/project-z"] }) + ); + const refs = adapter(fs); + + await refs.addReference("1.0.0", "/project-a"); + + expect(JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}")).toStrictEqual({ + "1.0.0": ["/project-a"], + "2.0.0": ["/project-z"], + }); + }); + + it("a version whose every project vanished disappears from the file at the next write", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + const refs = adapter(fs); + await refs.addReference("1.0.0", "/project-a"); + await fs.deleteFile("/project-a/marker"); + markExisting(fs, "/project-b"); + + await refs.addReference("2.0.0", "/project-b"); + + expect(JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}")).toStrictEqual({ + "2.0.0": ["/project-b"], + }); + }); + it("adding the same project under a new version drops its claim on the old one", async () => { const fs = new InMemoryFileAdapter(); markExisting(fs, "/project-a"); @@ -131,6 +180,21 @@ describe("the shared source's own project references", () => { expect(written).toEqual({}); }); + it("drops a claim recorded under a later version, leaving the earlier version's projects", async () => { + const fs = new InMemoryFileAdapter(); + markExisting(fs, "/project-a"); + markExisting(fs, "/project-b"); + const refs = adapter(fs); + await refs.addReference("1.0.0", "/project-a"); + await refs.addReference("2.0.0", "/project-b"); + + await refs.removeReference("/project-b"); + + expect(JSON.parse(fs.getFile(REFERENCES_PATH) ?? "{}")).toStrictEqual({ + "1.0.0": ["/project-a"], + }); + }); + it("does nothing when this project never held a reference", async () => { const fs = new InMemoryFileAdapter(); markExisting(fs, "/project-a"); @@ -163,6 +227,45 @@ describe("the shared source's own project references", () => { ); }); + describe("a file it cannot read", () => { + it("names the parser's own reason for unparsable JSON", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(REFERENCES_PATH, "not json"); + const refs = adapter(fs); + + await expect(refs.listAllReferencingProjects()).rejects.toThrow( + /registry at \/fake-home\/\.config\/aidd\/references\.json: Unexpected token/ + ); + }); + + it.each([ + ["null", "null"], + ["a list", "[]"], + ["a string", '"/project-a"'], + ])("names a top level that is %s rather than a version-keyed object", async (_shape, raw) => { + const fs = new InMemoryFileAdapter(); + fs.setFile(REFERENCES_PATH, raw); + const refs = adapter(fs); + + await expect(refs.listAllReferencingProjects()).rejects.toThrow( + new UnreadableUserSourceReferencesError(REFERENCES_PATH, "it is not a version-keyed object") + ); + }); + + it("names the version whose entry mixes a non-path in", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(REFERENCES_PATH, JSON.stringify({ "1.0.0": ["/project-a", 5] })); + const refs = adapter(fs); + + await expect(refs.listAllReferencingProjects()).rejects.toThrow( + new UnreadableUserSourceReferencesError( + REFERENCES_PATH, + 'its "1.0.0" entry is not a list of project paths' + ) + ); + }); + }); + describe("listAllReferencingProjects", () => { it("lists existing projects across every version key, deduplicated", async () => { const fs = new InMemoryFileAdapter(); From 45a06bc1406ddc14bf8edb97a0a86bae27cbb499 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:34:46 +0200 Subject: [PATCH 04/12] test(cli): kill the surviving mutants of the install and setup use cases Runtime and IDE config install, AI and IDE tool install, config install, content sections, gitignore, init, setup and its tools, machine scope, marketplace source and project context steps, update decisions and one-tool update: 63 tests, each shown red first against the mutant it names. Framework mutation score: 72.2 before the series, 95.4 after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../gitignore-use-case.unit.test.ts | 121 +++++++++++++ .../resolve-update-decision.unit.test.ts | 29 ++++ .../update-ai-tools-use-case.unit.test.ts | 15 ++ ...date-one-tool-use-case.integration.test.ts | 29 ++++ .../install-skills-use-case.unit.test.ts | 18 ++ .../install-ai-tool-use-case.unit.test.ts | 112 ++++++++++++ ...nstall-config-use-case.integration.test.ts | 163 +++++++++++++++++- .../install-ide-config-use-case.unit.test.ts | 87 +++++++++- .../install-ide-tool-use-case.unit.test.ts | 160 ++++++++++++++++- ...stall-runtime-config-use-case.unit.test.ts | 142 ++++++++++++++- .../application/setup-use-case.unit.test.ts | 136 ++++++++++++++- .../project-context-detector.unit.test.ts | 16 ++ ...p-marketplace-source-use-case.unit.test.ts | 54 ++++++ .../setup/setup-tools-use-case.unit.test.ts | 83 +++++++++ 14 files changed, 1152 insertions(+), 13 deletions(-) create mode 100644 cli/tests/contexts/framework/application/gitignore-use-case.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/setup/setup-tools-use-case.unit.test.ts diff --git a/cli/tests/contexts/framework/application/gitignore-use-case.unit.test.ts b/cli/tests/contexts/framework/application/gitignore-use-case.unit.test.ts new file mode 100644 index 000000000..510eb9bba --- /dev/null +++ b/cli/tests/contexts/framework/application/gitignore-use-case.unit.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from "vitest"; +import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; + +const ROOT = "/project"; +const GITIGNORE = "/project/.gitignore"; + +function build(existing?: string) { + const fs = new InMemoryFileAdapter(existing === undefined ? {} : { [GITIGNORE]: existing }); + return { fs, useCase: new GitignoreUseCase(fs) }; +} + +describe("GitignoreUseCase", () => { + describe("execute", () => { + it("creates the file from the entries, one per line, ending with a newline", async () => { + const { fs, useCase } = build(); + + await expect(useCase.execute(ROOT, ["a/", "b/"])).resolves.toBe(true); + + expect(fs.getFile(GITIGNORE)).toBe("a/\nb/\n"); + }); + + it("starts a new line before appending to a file without a trailing newline", async () => { + const { fs, useCase } = build("keep"); + + await useCase.execute(ROOT, ["a/"]); + + expect(fs.getFile(GITIGNORE)).toBe("keep\na/\n"); + }); + + it("appends right after a trailing newline, without a blank line", async () => { + const { fs, useCase } = build("keep\n"); + + await useCase.execute(ROOT, ["a/"]); + + expect(fs.getFile(GITIGNORE)).toBe("keep\na/\n"); + }); + + it("answers false and leaves the file alone when every entry is already there", async () => { + const { fs, useCase } = build("a/\n"); + + await expect(useCase.execute(ROOT, ["a/"])).resolves.toBe(false); + + expect(fs.getFile(GITIGNORE)).toBe("a/\n"); + }); + + it("counts an entry written with surrounding whitespace as present", async () => { + const { fs, useCase } = build(" a/ \n"); + + await expect(useCase.execute(ROOT, ["a/"])).resolves.toBe(false); + + expect(fs.getFile(GITIGNORE)).toBe(" a/ \n"); + }); + + it("appends only the entries that are missing", async () => { + const { fs, useCase } = build("a/\nb/\n"); + + await expect(useCase.execute(ROOT, ["b/", "c/"])).resolves.toBe(true); + + expect(fs.getFile(GITIGNORE)).toBe("a/\nb/\nc/\n"); + }); + }); + + describe("remove", () => { + it("does nothing when there is no file", async () => { + const { fs, useCase } = build(); + + await expect(useCase.remove(ROOT, ["a/"])).resolves.toBeUndefined(); + + expect(fs.has(GITIGNORE)).toBe(false); + }); + + it("drops the entry and keeps the other lines in order", async () => { + const { fs, useCase } = build("a/\nb/\nc/\n"); + + await useCase.remove(ROOT, ["b/"]); + + expect(fs.getFile(GITIGNORE)).toBe("a/\nc/\n"); + }); + + it("drops an entry written with surrounding whitespace", async () => { + const { fs, useCase } = build(" b/ \nc/\n"); + + await useCase.remove(ROOT, ["b/"]); + + expect(fs.getFile(GITIGNORE)).toBe("c/\n"); + }); + + it("leaves the file byte-identical when no entry matches", async () => { + const { fs, useCase } = build("\na/\n\n"); + + await useCase.remove(ROOT, ["z/"]); + + expect(fs.getFile(GITIGNORE)).toBe("\na/\n\n"); + }); + + it("leaves an empty file in place when no entry matches", async () => { + const { fs, useCase } = build(""); + + await useCase.remove(ROOT, ["z/"]); + + expect(fs.getFile(GITIGNORE)).toBe(""); + }); + + it("collapses the blank lines around what remains", async () => { + const { fs, useCase } = build("\n\nb/\na/\nc/\n\n"); + + await useCase.remove(ROOT, ["b/"]); + + expect(fs.getFile(GITIGNORE)).toBe("a/\nc/\n"); + }); + + it("deletes the file once its last entry is removed", async () => { + const { fs, useCase } = build("a/\n"); + + await useCase.remove(ROOT, ["a/"]); + + expect(fs.has(GITIGNORE)).toBe(false); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/global/resolve-update-decision.unit.test.ts b/cli/tests/contexts/framework/application/global/resolve-update-decision.unit.test.ts index 47437e11f..cc4c91ebc 100644 --- a/cli/tests/contexts/framework/application/global/resolve-update-decision.unit.test.ts +++ b/cli/tests/contexts/framework/application/global/resolve-update-decision.unit.test.ts @@ -35,6 +35,21 @@ describe("ResolveUpdateDecisionUseCase", () => { ).rejects.toThrow(InputRequiredError); }); + it("names --force as the way out", async () => { + const useCase = new ResolveUpdateDecisionUseCase(buildFakePrompter("overwrite")); + + const run = useCase.execute({ + relativePath: "some/file.md", + userForce: false, + interactive: false, + bulkState: new BulkConflictState(), + }); + + await expect(run).rejects.toThrow( + "Use --force to overwrite modified files in non-interactive mode." + ); + }); + it("never calls prompter in non-TTY mode", async () => { const prompter = buildFakePrompter("overwrite"); const useCase = new ResolveUpdateDecisionUseCase(prompter); @@ -150,6 +165,20 @@ describe("ResolveUpdateDecisionUseCase", () => { expect(prompter.resolveConflictBulk).not.toHaveBeenCalled(); }); + it("records nothing for a single-file answer", async () => { + const useCase = new ResolveUpdateDecisionUseCase(buildFakePrompter("overwrite")); + const bulkState = new BulkConflictState(); + + await useCase.execute({ + relativePath: "some/file.md", + userForce: false, + interactive: true, + bulkState, + }); + + expect(bulkState.get()).toBeNull(); + }); + it("records overwrite-all in bulkState when prompted", async () => { const prompter = buildFakePrompter("overwrite-all"); const useCase = new ResolveUpdateDecisionUseCase(prompter); diff --git a/cli/tests/contexts/framework/application/global/update-ai-tools-use-case.unit.test.ts b/cli/tests/contexts/framework/application/global/update-ai-tools-use-case.unit.test.ts index 271a8a6a5..714a00c9f 100644 --- a/cli/tests/contexts/framework/application/global/update-ai-tools-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/global/update-ai-tools-use-case.unit.test.ts @@ -36,6 +36,21 @@ describe("UpdateAiToolsUseCase", () => { }); }); + describe("no toolArg", () => { + it("updates the installed AI tools and leaves an installed IDE alone", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "claude"); + await installTool(deps, PROJECT_ROOT, "vscode"); + + const useCase = buildUseCase(deps, buildUpdateOneToolUseCase(deps)); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, ...NO_FORCE_TTY }); + + expect(result.updatedTools.map((t) => t.toolId)).toStrictEqual(["claude"]); + expect(result.errors).toStrictEqual([]); + }); + }); + describe("single toolArg", () => { it("updates only the specified tool when toolArg is provided", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/application/global/update-one-tool-use-case.integration.test.ts b/cli/tests/contexts/framework/application/global/update-one-tool-use-case.integration.test.ts index a1ed1a27a..7deb53fb5 100644 --- a/cli/tests/contexts/framework/application/global/update-one-tool-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/global/update-one-tool-use-case.integration.test.ts @@ -136,6 +136,35 @@ describe("UpdateOneToolUseCase integration", () => { }); }); + describe("installer skipped the tool", () => { + it("answers null with no error recorded", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + vi.spyOn(deps.installRuntimeConfigUseCase, "execute").mockResolvedValue({ + toolId: "claude", + fileCount: 0, + files: [], + skipped: true, + warnings: [], + }); + + const useCase = buildUseCase(deps, buildFakePrompter("keep")); + const errors: Parameters[4] = []; + + const result = await useCase.execute( + "claude", + await loadManifest(deps), + PROJECT_ROOT, + "test", + errors, + { userForce: false, interactive: false, bulkState: new BulkConflictState() } + ); + + expect(result).toBeNull(); + expect(errors).toStrictEqual([]); + }); + }); + describe("install failure", () => { it("reports the failure and returns null instead of throwing", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/application/install/content/install-skills-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/content/install-skills-use-case.unit.test.ts index 8e860bafc..28f34b0c8 100644 --- a/cli/tests/contexts/framework/application/install/content/install-skills-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/content/install-skills-use-case.unit.test.ts @@ -148,6 +148,24 @@ describe("InstallSkillsUseCase", () => { expect(files[0].frameworkPath).toBe("skills/my-skill/SKILL.md"); }); + it("accepts the entry file however deep its skill directory nests", () => { + const { useCase } = buildUseCase(); + const contentFiles = new Map([ + [ + "skills/group/my-skill/SKILL.md", + "---\nname: my-skill\ndescription: Deep\n---\n# Skill\n", + ], + ]); + + const files = useCase.execute({ + toolConfig: claude, + section: skillsSectionWithEntry, + contentFiles, + }); + + expect(files.map((f) => f.frameworkPath)).toStrictEqual(["skills/group/my-skill/SKILL.md"]); + }); + it("filters out non-SKILL.md files from subdirectory skills", () => { const { useCase } = buildUseCase(); const contentFiles = new Map([ diff --git a/cli/tests/contexts/framework/application/install/install-ai-tool-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-ai-tool-use-case.unit.test.ts index 86974bedb..d9c01033f 100644 --- a/cli/tests/contexts/framework/application/install/install-ai-tool-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-ai-tool-use-case.unit.test.ts @@ -92,7 +92,99 @@ describe("InstallAiToolUseCase", () => { }); }); + describe("installed tools without plugins", () => { + it("runs no activation when nothing was propagated", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const { useCase, syncSettingsMock } = buildUseCase(deps); + + const result = await useCase.execute({ + toolId: "opencode", + projectRoot: PROJECT_ROOT, + force: false, + version: VERSION, + propagatePlugins: true, + }); + + expect(syncSettingsMock.execute).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ + runtimeResult: result.runtimeResult, + propagatedPlugins: [], + propagationWarnings: [], + activation: undefined, + }); + }); + + it("propagates nothing when the manifest vanished right after the install", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const loaded = await deps.manifestRepo.load(); + vi.spyOn(deps.manifestRepo, "load").mockResolvedValueOnce(loaded).mockResolvedValueOnce(null); + const { useCase } = buildUseCase(deps); + + const result = await useCase.execute({ + toolId: "opencode", + projectRoot: PROJECT_ROOT, + force: false, + version: VERSION, + propagatePlugins: true, + }); + + expect(result).toStrictEqual({ + runtimeResult: result.runtimeResult, + propagatedPlugins: [], + propagationWarnings: [], + }); + }); + }); + describe("manifest with plugins on another tool", () => { + it("propagates with the exact non-interactive replace request", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await addPlugin(deps, "claude", makeMockPlugin("my-plugin")); + const { useCase, pluginInstallMock } = buildUseCase(deps); + + await useCase.execute({ + toolId: "opencode", + projectRoot: PROJECT_ROOT, + force: false, + version: VERSION, + propagatePlugins: true, + }); + + expect(pluginInstallMock.execute).toHaveBeenCalledWith({ + pluginName: "my-plugin", + version: "1.0.0", + fromMarketplace: "aidd", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + interactive: false, + autoSelect: true, + replace: true, + requestedVersionPolicy: "prefer-catalog", + }); + }); + + it("does not propagate the tool's own plugins back onto it on a forced reinstall", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installTool(deps, PROJECT_ROOT, "opencode"); + await addPlugin(deps, "opencode", makeMockPlugin("own-plugin")); + const { useCase, pluginInstallMock } = buildUseCase(deps); + + const result = await useCase.execute({ + toolId: "opencode", + projectRoot: PROJECT_ROOT, + force: true, + version: VERSION, + propagatePlugins: true, + }); + + expect(result.propagatedPlugins).toStrictEqual([]); + expect(pluginInstallMock.execute).not.toHaveBeenCalled(); + }); + it("propagates plugins from existing tools onto the new tool", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude"); @@ -233,6 +325,26 @@ describe("InstallAiToolUseCase", () => { expect(result.propagatedPlugins).toHaveLength(0); expect(pluginInstallMock.execute).not.toHaveBeenCalled(); }); + + it("answers the skipped install alone, with nothing propagated and no warning", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "opencode"); + const { useCase } = buildUseCase(deps); + + const result = await useCase.execute({ + toolId: "opencode", + projectRoot: PROJECT_ROOT, + force: false, + version: VERSION, + propagatePlugins: true, + }); + + expect(result).toStrictEqual({ + runtimeResult: { toolId: "opencode", fileCount: 0, files: [], skipped: true, warnings: [] }, + propagatedPlugins: [], + propagationWarnings: [], + }); + }); }); describe("orphaned plugin (no marketplace)", () => { diff --git a/cli/tests/contexts/framework/application/install/install-config-use-case.integration.test.ts b/cli/tests/contexts/framework/application/install/install-config-use-case.integration.test.ts index c242ad637..6c8716f37 100644 --- a/cli/tests/contexts/framework/application/install/install-config-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/install/install-config-use-case.integration.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from "vitest"; import { InstallConfigUseCase } from "../../../../../src/contexts/framework/application/install/install-config-use-case.js"; import { extractConfigCapabilities } from "../../../../../src/contexts/framework/domain/config-capability.js"; +import { CONFIG_MCP } from "../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; +import { McpCapability } from "../../../../../src/contexts/tools/domain/capabilities/mcp-capability.js"; import { SettingsCapability } from "../../../../../src/contexts/tools/domain/capabilities/settings-capability.js"; import { copilot } from "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { FrameworkDescriptor } from "../../../../../src/contexts/translate/domain/canon.js"; import { BundledAssetProviderAdapter } from "../../../../../src/runtime/assets/asset-loader.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; -import { linuxPlatform } from "../helpers.js"; +import { StubAssetProvider } from "../../../../helpers/ports/stub-asset-provider.js"; +import { linuxPlatform, win32Platform } from "../helpers.js"; const PROJECT_ROOT = "/test-project"; @@ -113,4 +116,162 @@ describe("InstallConfigUseCase — staticContent", () => { expect(results).toHaveLength(0); }); + + it("leaves a consumes-only capability alone even with an asset provider at hand", async () => { + const { useCase } = buildUseCase(); + const capability = new SettingsCapability({ + outputPath: ".vscode/settings.json", + mergeStrategy: "framework-prime", + consumes: ["someSignal"], + }); + + const results = await useCase.execute({ + capabilities: [capability], + configRefs: [], + contentFiles: new Map(), + projectRoot: PROJECT_ROOT, + platform: linuxPlatform, + assetProvider: new BundledAssetProviderAdapter(), + toolId: "copilot", + }); + + expect(results).toStrictEqual([]); + }); + + describe("an asset-backed capability", () => { + const fromAsset = new SettingsCapability({ + outputPath: ".vscode/settings.json", + mergeStrategy: "framework-prime", + staticContentAssetFile: "vscode-settings.json", + }); + + it("produces nothing without an asset provider", async () => { + const { useCase } = buildUseCase(); + + const results = await useCase.execute({ + capabilities: [fromAsset], + configRefs: [], + contentFiles: new Map(), + projectRoot: PROJECT_ROOT, + platform: linuxPlatform, + toolId: "copilot", + }); + + expect(results).toStrictEqual([]); + }); + + it("produces nothing without a tool to ask the asset for", async () => { + const { useCase } = buildUseCase(); + + const results = await useCase.execute({ + capabilities: [fromAsset], + configRefs: [], + contentFiles: new Map(), + projectRoot: PROJECT_ROOT, + platform: linuxPlatform, + assetProvider: new BundledAssetProviderAdapter(), + }); + + expect(results).toStrictEqual([]); + }); + + it("carries an asset answered as text verbatim", async () => { + const { useCase } = buildUseCase(); + const text = '{\n // kept as written\n "t": 1\n}'; + + const results = await useCase.execute({ + capabilities: [fromAsset], + configRefs: [], + contentFiles: new Map(), + projectRoot: PROJECT_ROOT, + platform: linuxPlatform, + assetProvider: new StubAssetProvider({ "copilot/vscode-settings.json": text }), + toolId: "copilot", + }); + + expect(results.map((f) => f.content)).toStrictEqual([text]); + }); + }); +}); + +describe("InstallConfigUseCase — MCP config", () => { + const MCP_REF = { name: CONFIG_MCP, path: "config/mcp.json" }; + const HOOKS_REF = { name: "hooks", path: "config/hooks.json" }; + const NPX_SERVER = JSON.stringify( + { mcpServers: { docs: { command: "npx", args: ["-y", "docs-server"] } } }, + null, + 2 + ); + + function mcpCapability(mergeStrategy?: "user-prime" | "framework-prime" | "none") { + return new McpCapability({ + outputPath: ".cursor/mcp.json", + format: "json", + consumes: [CONFIG_MCP], + ...(mergeStrategy !== undefined && { mergeStrategy }), + }); + } + + it("rewrites npx servers for Windows", async () => { + const { useCase } = buildUseCase(); + + const results = await useCase.execute({ + capabilities: [mcpCapability()], + configRefs: [MCP_REF], + contentFiles: new Map([[MCP_REF.path, NPX_SERVER]]), + projectRoot: PROJECT_ROOT, + platform: win32Platform, + }); + + expect(results.map((f) => JSON.parse(f.content))).toStrictEqual([ + { mcpServers: { docs: { command: "cmd", args: ["/c", "npx", "-y", "docs-server"] } } }, + ]); + }); + + it("leaves a non-MCP config as written on Windows, even from a capability that also takes MCP", async () => { + const { useCase } = buildUseCase(); + const both = new SettingsCapability({ + outputPath: ".cursor/hooks.json", + mergeStrategy: "user-prime", + consumes: [CONFIG_MCP, HOOKS_REF.name], + }); + + const results = await useCase.execute({ + capabilities: [both], + configRefs: [HOOKS_REF], + contentFiles: new Map([[HOOKS_REF.path, NPX_SERVER]]), + projectRoot: PROJECT_ROOT, + platform: win32Platform, + }); + + expect(results.map((f) => f.content)).toStrictEqual([NPX_SERVER]); + }); + + it("honours the merge strategy the MCP capability declares", async () => { + const { useCase } = buildUseCase(); + + const results = await useCase.execute({ + capabilities: [mcpCapability("framework-prime")], + configRefs: [MCP_REF], + contentFiles: new Map([[MCP_REF.path, NPX_SERVER]]), + projectRoot: PROJECT_ROOT, + platform: linuxPlatform, + }); + + expect(results.map((f) => f.mergeStrategy)).toStrictEqual(["framework-prime"]); + }); + + it("lets the user's MCP entries win when the capability declares no strategy", async () => { + const { useCase } = buildUseCase(); + + const results = await useCase.execute({ + capabilities: [mcpCapability()], + configRefs: [MCP_REF], + contentFiles: new Map([[MCP_REF.path, NPX_SERVER]]), + projectRoot: PROJECT_ROOT, + platform: linuxPlatform, + }); + + expect(results.map((f) => f.mergeStrategy)).toStrictEqual(["user-prime"]); + }); }); diff --git a/cli/tests/contexts/framework/application/install/install-ide-config-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-ide-config-use-case.unit.test.ts index c86f0ae72..2db9f287c 100644 --- a/cli/tests/contexts/framework/application/install/install-ide-config-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-ide-config-use-case.unit.test.ts @@ -2,16 +2,22 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { InstallIdeConfigUseCase } from "../../../../../src/contexts/framework/application/install/install-ide-config-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; import { buildUnitDeps, initProject } from "../../../../helpers/ports/build-unit-deps.js"; +import { StubAssetProvider } from "../../../../helpers/ports/stub-asset-provider.js"; const PROJECT_ROOT = "/test-project"; +const KEYBINDINGS = ".vscode/keybindings.json"; -function buildUseCase(deps: Awaited>) { +function buildUseCase( + deps: Awaited>, + assets: AssetProvider = deps.assetProvider +) { return new InstallIdeConfigUseCase( deps.fs, deps.hasher, deps.logger, - deps.assetProvider, + assets, deps.postInstallPipelineUseCase ); } @@ -32,12 +38,89 @@ describe("InstallIdeConfigUseCase", () => { expect(result.skipped).toBe(false); expect(result.fileCount).toBeGreaterThan(0); + expect(result.warnings).toStrictEqual([]); expect(deps.fs.has(join(PROJECT_ROOT, ".vscode/settings.json"))).toBe(true); const saved = await deps.manifestRepo.load(); expect(saved?.hasTool("vscode")).toBe(true); }); + it("answers an empty skipped result for an installed IDE", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + await buildUseCase(deps).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + const result = await buildUseCase(deps).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + expect(result).toStrictEqual({ + toolId: "vscode", + fileCount: 0, + files: [], + skipped: true, + warnings: [], + }); + }); + + it("tracks a file the caller chose to skip under the hash it has on disk", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + await buildUseCase(deps).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + const userContent = '[{"key": "ctrl+k"}]'; + await deps.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), userContent); + + await buildUseCase(deps).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: true, + version: "1.0.0", + onBeforeWriteRegularFile: async (path) => (path === KEYBINDINGS ? "skip" : "write"), + }); + + expect(deps.fs.getFile(join(PROJECT_ROOT, KEYBINDINGS))).toBe(userContent); + expect(manifest.getToolFiles("vscode")).toStrictEqual([ + { relativePath: KEYBINDINGS, hash: deps.hasher.hash(userContent) }, + ]); + }); + + it("writes an asset answered as text verbatim", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + const text = '[\n // user comment\n {"key": "ctrl+k"}\n]'; + const assets = new StubAssetProvider({ "vscode/keybindings.json": text }, deps.assetProvider); + + await buildUseCase(deps, assets).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + expect(deps.fs.getFile(join(PROJECT_ROOT, KEYBINDINGS))).toBe(text); + }); + it("returns skipped without writing when already installed and no force", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initProject(deps, PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/application/install/install-ide-tool-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-ide-tool-use-case.unit.test.ts index ec7787629..20fd3af04 100644 --- a/cli/tests/contexts/framework/application/install/install-ide-tool-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-ide-tool-use-case.unit.test.ts @@ -1,25 +1,33 @@ import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { InstallIdeToolUseCase } from "../../../../../src/contexts/framework/application/install/install-ide-tool-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { SettingsCapability } from "../../../../../src/contexts/tools/domain/capabilities/settings-capability.js"; +import { cursor } from "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { registerTool } from "../../../../../src/contexts/tools/domain/registry.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; import { buildUnitDeps, initAndInstall, initProject, installTool, } from "../../../../helpers/ports/build-unit-deps.js"; +import { StubAssetProvider } from "../../../../helpers/ports/stub-asset-provider.js"; const PROJECT_ROOT = "/test-project"; const VERSION = "1.0.0"; -function buildUseCase(deps: Awaited>) { +function buildUseCase( + deps: Awaited>, + assetProvider: AssetProvider | null = deps.assetProvider +) { return new InstallIdeToolUseCase( deps.installIdeConfigUseCase, deps.manifestRepo, deps.fs, deps.hasher, deps.postInstallPipelineUseCase, - deps.assetProvider + assetProvider ?? undefined ); } @@ -129,4 +137,150 @@ describe("InstallIdeToolUseCase", () => { expect(content).toContain('"editor.formatOnSave"'); }); }); + + describe("selecting what an installed AI tool declares for the IDE", () => { + const forIde = new SettingsCapability({ + outputPath: ".vscode/aidd-a.json", + mergeStrategy: "framework-prime", + staticContent: '{"a": 1}', + requiresTool: "vscode", + }); + const consumesOnly = new SettingsCapability({ + outputPath: ".vscode/aidd-b.json", + mergeStrategy: "user-prime", + consumes: ["something"], + }); + const forItself = new SettingsCapability({ + outputPath: ".cursor/aidd-c.json", + mergeStrategy: "framework-prime", + staticContent: '{"c": 1}', + }); + const fromAsset = new SettingsCapability({ + outputPath: ".vscode/aidd-d.json", + mergeStrategy: "framework-prime", + staticContentAssetFile: "d.json", + requiresTool: "vscode", + }); + + function registerCursorWith(settings: SettingsCapability[]): void { + registerTool({ ...cursor, capabilities: { ...cursor.capabilities, settings } }); + } + + afterEach(() => { + registerTool(cursor); + }); + + async function withCursorRegistered( + deps: Awaited>, + mergeFiles: Parameters[3] = [] + ): Promise { + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + manifest.addTool("cursor", VERSION, [], mergeFiles); + await deps.manifestRepo.save(manifest); + return manifest; + } + + async function installVscode( + deps: Awaited>, + manifest: Manifest + ) { + return buildUseCase(deps).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: VERSION, + }); + } + + function parsed(deps: Awaited>, relativePath: string) { + return JSON.parse(deps.fs.getFile(join(PROJECT_ROOT, relativePath)) ?? ""); + } + + it("merges only the static settings that name this IDE", async () => { + registerCursorWith([forIde, consumesOnly, forItself]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = await withCursorRegistered(deps); + + await installVscode(deps, manifest); + + expect(parsed(deps, ".vscode/aidd-a.json")).toStrictEqual({ a: 1 }); + expect(deps.fs.has(join(PROJECT_ROOT, ".vscode/aidd-b.json"))).toBe(false); + expect(deps.fs.has(join(PROJECT_ROOT, ".cursor/aidd-c.json"))).toBe(false); + }); + + it("keeps the tool's other merge entries and replaces a stale one for the same file", async () => { + registerCursorWith([forIde]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const mcpEntry = { relativePath: ".cursor/mcp.json", sectionKey: null, entries: {} }; + const stale = { + relativePath: ".vscode/aidd-a.json", + sectionKey: null, + entries: { stale: deps.hasher.hash("0") }, + }; + const manifest = await withCursorRegistered(deps, [mcpEntry, stale]); + + await installVscode(deps, manifest); + + expect(manifest.getMergeFiles("cursor")).toStrictEqual([ + mcpEntry, + { + relativePath: ".vscode/aidd-a.json", + sectionKey: null, + entries: { a: deps.hasher.hash("1") }, + }, + ]); + }); + + it("leaves a hand-edited file alone when the IDE was already installed", async () => { + registerCursorWith([forIde]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "vscode"); + const manifest = await withCursorRegistered(deps); + await deps.fs.writeFile(join(PROJECT_ROOT, ".vscode/aidd-a.json"), '{"user": true}'); + + const result = await installVscode(deps, manifest); + + expect(result.skipped).toBe(true); + expect(deps.fs.getFile(join(PROJECT_ROOT, ".vscode/aidd-a.json"))).toBe('{"user": true}'); + }); + + it("writes an asset answered as text verbatim", async () => { + registerCursorWith([fromAsset]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = await withCursorRegistered(deps); + const assets = new StubAssetProvider({ "cursor/d.json": '{"d": 1}' }); + + await buildUseCase(deps, assets).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: VERSION, + }); + + expect(parsed(deps, ".vscode/aidd-d.json")).toStrictEqual({ d: 1 }); + }); + + it("merges an empty object for an asset-backed capability when no asset provider was given", async () => { + registerCursorWith([fromAsset]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = await withCursorRegistered(deps); + + await buildUseCase(deps, null).execute({ + toolId: "vscode", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: VERSION, + }); + + expect(parsed(deps, ".vscode/aidd-d.json")).toStrictEqual({}); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts index eae45c091..e7f661ad4 100644 --- a/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts @@ -1,21 +1,30 @@ import { join } from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { InstallRuntimeConfigUseCase } from "../../../../../src/contexts/framework/application/install/install-runtime-config-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { SettingsCapability } from "../../../../../src/contexts/tools/domain/capabilities/settings-capability.js"; +import { cursor } from "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { registerTool } from "../../../../../src/contexts/tools/domain/registry.js"; +import { extractMergeEntries } from "../../../../../src/kernel/merge.js"; +import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; import { buildUnitDeps, initProject, installTool, } from "../../../../helpers/ports/build-unit-deps.js"; +import { StubAssetProvider } from "../../../../helpers/ports/stub-asset-provider.js"; const PROJECT_ROOT = "/test-project"; -function buildUseCase(deps: Awaited>) { +function buildUseCase( + deps: Awaited>, + assets: AssetProvider = deps.assetProvider +) { return new InstallRuntimeConfigUseCase( deps.fs, deps.hasher, deps.logger, - deps.assetProvider, + assets, deps.postInstallPipelineUseCase ); } @@ -64,8 +73,36 @@ describe("InstallRuntimeConfigUseCase", () => { version: "1.0.0", }); - expect(result.skipped).toBe(true); - expect(result.fileCount).toBe(0); + expect(result).toStrictEqual({ + toolId: "claude", + fileCount: 0, + files: [], + skipped: true, + warnings: [], + }); + }); + + it("tracks a file the caller chose to skip under the hash it has on disk", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "claude"); + const userContent = '{"user": true}'; + await deps.fs.writeFile(join(PROJECT_ROOT, ".claude/settings.json"), userContent); + + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + await buildUseCase(deps).execute({ + toolId: "claude", + projectRoot: PROJECT_ROOT, + manifest, + force: true, + version: "1.0.0", + onBeforeWriteRegularFile: async () => "skip", + }); + + expect(deps.fs.getFile(join(PROJECT_ROOT, ".claude/settings.json"))).toBe(userContent); + expect(manifest.getToolFiles("claude")).toStrictEqual([ + { relativePath: ".claude/settings.json", hash: deps.hasher.hash(userContent) }, + ]); }); it("overwrites existing tracked files when force is true", async () => { @@ -158,5 +195,100 @@ describe("InstallRuntimeConfigUseCase", () => { expect(parsed).toHaveProperty("github.copilot.enable"); expect(parsed).toHaveProperty("chat.plugins.enabled", true); }); + + it("records the merged settings file with a hash per top-level key", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "vscode"); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + + await buildUseCase(deps).execute({ + toolId: "copilot", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + const onDisk = deps.fs.getFile(join(PROJECT_ROOT, ".vscode/settings.json")) ?? ""; + expect(manifest.getMergeFiles("copilot")).toStrictEqual([ + { + relativePath: ".vscode/settings.json", + sectionKey: null, + entries: extractMergeEntries(onDisk, null, deps.hasher), + }, + ]); + }); + }); + + describe("static settings declared by the tool", () => { + const inline = new SettingsCapability({ + outputPath: ".cursor/aidd-static.json", + mergeStrategy: "framework-prime", + staticContent: '{"static": true}', + }); + const consumesOnly = new SettingsCapability({ + outputPath: ".cursor/consumed.json", + mergeStrategy: "user-prime", + consumes: ["something"], + }); + const fromAsset = new SettingsCapability({ + outputPath: ".cursor/from-asset.json", + mergeStrategy: "framework-prime", + staticContentAssetFile: "static.json", + }); + + function registerCursorWith(settings: SettingsCapability[]): void { + registerTool({ ...cursor, capabilities: { ...cursor.capabilities, settings } }); + } + + afterEach(() => { + registerTool(cursor); + }); + + it("writes inline content and passes over a capability that only consumes", async () => { + registerCursorWith([inline, consumesOnly]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + + const result = await buildUseCase(deps).execute({ + toolId: "cursor", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + const written = deps.fs.getFile(join(PROJECT_ROOT, ".cursor/aidd-static.json")) ?? ""; + expect(JSON.parse(written)).toStrictEqual({ static: true }); + expect(deps.fs.has(join(PROJECT_ROOT, ".cursor/consumed.json"))).toBe(false); + expect(result.files.map((f) => f.relativePath)).toStrictEqual([ + ".cursor/settings.json", + ".cursor/aidd-static.json", + ]); + }); + + it("writes an asset answered as text verbatim", async () => { + registerCursorWith([fromAsset]); + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + const assets = new StubAssetProvider( + { "cursor/static.json": '{"fromAsset": true}' }, + deps.assetProvider + ); + + await buildUseCase(deps, assets).execute({ + toolId: "cursor", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + const written = deps.fs.getFile(join(PROJECT_ROOT, ".cursor/from-asset.json")) ?? ""; + expect(JSON.parse(written)).toStrictEqual({ fromAsset: true }); + }); }); }); diff --git a/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts index cd9212b81..6291436ab 100644 --- a/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup-use-case.unit.test.ts @@ -11,15 +11,19 @@ import { import { MarketplaceSourceMode } from "../../../../src/contexts/distribution/domain/marketplace-source-mode.js"; import type { MarketplaceSyncSettings } from "../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; import type { PluginInstallFromMarketplace } from "../../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; +import { ProjectContextDetectorUseCase } from "../../../../src/contexts/framework/application/setup/project-context-detector-use-case.js"; import { SetupMachineScopeUseCase } from "../../../../src/contexts/framework/application/setup/setup-machine-scope-use-case.js"; import { SetupMarketplaceSourceUseCase } from "../../../../src/contexts/framework/application/setup/setup-marketplace-source-use-case.js"; import { SetupToolsUseCase } from "../../../../src/contexts/framework/application/setup/setup-tools-use-case.js"; import { SetupUseCase } from "../../../../src/contexts/framework/application/setup-use-case.js"; import { SetupMarketplaceRegistrationUseCase } from "../../../../src/contexts/framework/application/shared/setup-marketplace-registration-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import type { ManifestRepository } from "../../../../src/contexts/framework/domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../../../../src/contexts/framework/domain/ports/user-source-references.js"; +import { ProjectContext } from "../../../../src/contexts/framework/domain/project-context.js"; import { SetupFlow } from "../../../../src/contexts/framework/domain/setup-flow.js"; import { UserSourceReferencesAdapter } from "../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import { UserScopeUnavailableError } from "../../../../src/kernel/errors.js"; import type { Logger } from "../../../../src/kernel/ports/logger.js"; import type { ToolId } from "../../../../src/kernel/tool.js"; import { AI_TOOL_IDS, IDE_TOOL_IDS } from "../../../../src/kernel/tool.js"; @@ -127,6 +131,7 @@ async function buildUseCase( marketplaceSyncSettingsUseCase?: MarketplaceSyncSettings & { execute: ReturnType; }; + detectContext?: boolean; } ) { const deps = await buildUnitDeps(PROJECT_ROOT); @@ -140,9 +145,10 @@ async function buildUseCase( deps.installRuntimeConfigUseCase, deps.installIdeConfigUseCase ); + const pluginInstallFromMarketplace = makeNoOpPluginInstallFromMarketplace(); const setupPluginsPromptUseCase = new SetupPluginsPromptUseCase( makeNoOpPluginPick(), - makeNoOpPluginInstallFromMarketplace(), + pluginInstallFromMarketplace, new InMemoryMarketplaceRegistry(), makeNoOpResolveMarketplace() ); @@ -180,7 +186,7 @@ async function buildUseCase( setupPluginsPromptUseCase, deps.currentVersionProvider, setupToolsPromptUseCase, - undefined, + options?.detectContext === true ? new ProjectContextDetectorUseCase(deps.fs) : undefined, setupMachineScopeUseCase ); return { @@ -189,6 +195,7 @@ async function buildUseCase( marketplaceRegisterFramework, marketplaceRefresh, marketplaceSyncSettingsUseCase, + pluginInstallFromMarketplace, }; } @@ -296,6 +303,58 @@ describe("setup without TTY", () => { expect(result.kind).toBe("up-to-date"); }); + it("hands the detected project context back with the result", async () => { + const { useCase, deps } = await buildUseCase(undefined, undefined, undefined, { + detectContext: true, + }); + deps.fs.setFile(join(PROJECT_ROOT, "tsconfig.json"), "{}"); + + const result = await useCase.execute(remoteFlow()); + + expect(result.context).toStrictEqual( + new ProjectContext({ stack: "typescript", isMonorepo: false, hasFramework: false }) + ); + }); + + describe("plugins named on the command line", () => { + function namedFlow(registerDefaultMarketplace: boolean): SetupFlow { + return new SetupFlow({ + projectRoot: PROJECT_ROOT, + source: MarketplaceSourceMode.remote(), + aiTools: ["claude" as ToolId], + ideTools: [], + pluginMode: "named", + pluginNames: ["aidd-context"], + interactive: false, + registerDefaultMarketplace, + }); + } + + it("installs each named plugin on every tool once the framework marketplace is registered", async () => { + const { useCase, pluginInstallFromMarketplace } = await buildUseCase(); + + await useCase.execute(namedFlow(true)); + + expect(pluginInstallFromMarketplace.execute).toHaveBeenCalledTimes(1); + expect(pluginInstallFromMarketplace.execute).toHaveBeenCalledWith({ + pluginName: "aidd-context", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + autoSelect: true, + replace: true, + }); + }); + + it("installs no plugin when the default marketplace is opted out", async () => { + const { useCase, pluginInstallFromMarketplace } = await buildUseCase(); + + await useCase.execute(namedFlow(false)); + + expect(pluginInstallFromMarketplace.execute).not.toHaveBeenCalled(); + }); + }); + describe("default marketplace opt-out (#197)", () => { it("registers framework marketplace by default", async () => { const { useCase, marketplaceRegisterFramework, marketplaceRefresh } = await buildUseCase(); @@ -502,6 +561,79 @@ describe("setup interactive tool selection", () => { }); describe("setup --scope user", () => { + it("refuses when no machine-scope use case was wired", async () => { + const { useCase } = await buildUseCase(); + + await expect( + useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" })) + ).rejects.toThrow(UserScopeUnavailableError); + }); + + it("reports a first run as initialized, installing nothing", async () => { + const userManifestRepo = new InMemoryManifestRepository(); + const { useCase } = await buildUseCase(undefined, undefined, undefined, { userManifestRepo }); + + const result = await useCase.execute( + remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" }) + ); + + expect(result).toStrictEqual({ + kind: "initialized", + install: { results: [] }, + activation: { updatedTools: [] }, + context: undefined, + }); + }); + + it("reports a repeat run as up-to-date", async () => { + const userManifestRepo = new InMemoryManifestRepository(Manifest.create()); + const { useCase } = await buildUseCase(undefined, undefined, undefined, { userManifestRepo }); + + const result = await useCase.execute( + remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" }) + ); + + expect(result).toStrictEqual({ + kind: "up-to-date", + install: { results: [] }, + activation: { updatedTools: [] }, + context: undefined, + }); + }); + + it("keeps the tools an earlier run registered", async () => { + const seed = Manifest.create(); + seed.addTool("codex", "9.9.9", []); + const userManifestRepo = new InMemoryManifestRepository(seed); + const { useCase } = await buildUseCase(undefined, undefined, undefined, { userManifestRepo }); + + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" })); + + expect(userManifestRepo.getCurrent()?.getInstalledToolIds()).toStrictEqual(["codex", "claude"]); + }); + + it("registers a new tool with no files and saves the manifest once", async () => { + const userManifestRepo = new InMemoryManifestRepository(Manifest.create()); + const { useCase } = await buildUseCase(undefined, undefined, undefined, { userManifestRepo }); + + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" })); + + expect(userManifestRepo.getCurrent()?.getToolFiles("claude")).toStrictEqual([]); + expect(userManifestRepo.saveCount).toBe(1); + }); + + it("leaves an already-registered tool at its recorded version, saving nothing", async () => { + const seed = Manifest.create(); + seed.addTool("claude", "9.9.9", []); + const userManifestRepo = new InMemoryManifestRepository(seed); + const { useCase } = await buildUseCase(undefined, undefined, undefined, { userManifestRepo }); + + await useCase.execute(remoteFlow({ aiTools: ["claude" as ToolId], scope: "user" })); + + expect(userManifestRepo.getCurrent()?.getToolVersion("claude")).toBe("9.9.9"); + expect(userManifestRepo.saveCount).toBe(0); + }); + it("writes nothing at all under projectRoot — full directory delta, not a list", async () => { const userManifestRepo = new InMemoryManifestRepository(); const { useCase, deps } = await buildUseCase(undefined, undefined, undefined, { diff --git a/cli/tests/contexts/framework/application/setup/project-context-detector.unit.test.ts b/cli/tests/contexts/framework/application/setup/project-context-detector.unit.test.ts index 4b7adb6f0..b6d936181 100644 --- a/cli/tests/contexts/framework/application/setup/project-context-detector.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/project-context-detector.unit.test.ts @@ -62,6 +62,22 @@ describe("ProjectContextDetectorUseCase", () => { expect(ctx.hasFramework).toBe(false); }); + it("reads a project holding only unrelated files as an unknown stack", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + await fs.writeFile(join(PROJECT_ROOT, "README.md"), "# hello"); + const detector = new ProjectContextDetectorUseCase(fs); + const ctx = await detector.execute({ projectRoot: PROJECT_ROOT }); + expect(ctx.stack).toBe("unknown"); + }); + + it("does not read a package.json without a workspaces field as a monorepo", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + await fs.writeFile(join(PROJECT_ROOT, "package.json"), JSON.stringify({ name: "solo" })); + const detector = new ProjectContextDetectorUseCase(fs); + const ctx = await detector.execute({ projectRoot: PROJECT_ROOT }); + expect(ctx.isMonorepo).toBe(false); + }); + it("describe returns hyphen-readable summary", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); await fs.writeFile(join(PROJECT_ROOT, "tsconfig.json"), "{}"); diff --git a/cli/tests/contexts/framework/application/setup/setup-marketplace-source-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/setup-marketplace-source-use-case.unit.test.ts index ef210ad5e..d4ac12682 100644 --- a/cli/tests/contexts/framework/application/setup/setup-marketplace-source-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/setup/setup-marketplace-source-use-case.unit.test.ts @@ -138,6 +138,46 @@ describe("SetupMarketplaceSourceUseCase", () => { expect(result.ref).toBeUndefined(); }); + it("labels the newest release as latest and offers HEAD last", async () => { + const resolver = makeResolver(["v3.0.0", "v2.9.0"]); + const prompter = new ScriptedPrompter([ + ScriptedPrompter.answer.select("remote"), + ScriptedPrompter.answer.select("v2.9.0"), + ]); + const uc = new SetupMarketplaceSourceUseCase(prompter, resolver); + + await uc.execute({ projectRoot: PROJECT_ROOT, interactive: true }); + + expect(prompter.askedSelects).toStrictEqual([ + { + message: "Select framework source:", + names: [ + "remote (fetch from GitHub marketplace — recommended)", + "local (copy from local framework directory)", + ], + }, + { + message: "Select framework release to install:", + names: ["v3.0.0 (latest)", "v2.9.0", "HEAD (main branch tip — unreleased)"], + }, + ]); + }); + + it("asks for the local path with an empty default", async () => { + const resolver = makeResolver([]); + const prompter = new ScriptedPrompter([ + ScriptedPrompter.answer.select("local"), + ScriptedPrompter.answer.input("/abs/framework"), + ]); + const uc = new SetupMarketplaceSourceUseCase(prompter, resolver); + + await uc.execute({ projectRoot: PROJECT_ROOT, interactive: true }); + + expect(prompter.askedInputs).toStrictEqual([ + { message: "Path to local framework directory:", defaultValue: "" }, + ]); + }); + it("prompts for local path when user selects local", async () => { const resolver = makeResolver([]); const prompter = new ScriptedPrompter([ @@ -185,5 +225,19 @@ describe("SetupMarketplaceSourceUseCase", () => { expect(result.ref).toBe("v4.0.0"); }); + + it("pins an older release the user picks rather than the newest", async () => { + const resolver = makeResolver(["v4.0.0", "v3.0.0"]); + const prompter = new ScriptedPrompter([ScriptedPrompter.answer.select("v3.0.0")]); + const uc = new SetupMarketplaceSourceUseCase(prompter, resolver); + + const result = await uc.execute({ + projectRoot: PROJECT_ROOT, + sourceFromCli: MarketplaceSourceMode.remote(), + interactive: true, + }); + + expect(result.ref).toBe("v3.0.0"); + }); }); }); diff --git a/cli/tests/contexts/framework/application/setup/setup-tools-use-case.unit.test.ts b/cli/tests/contexts/framework/application/setup/setup-tools-use-case.unit.test.ts new file mode 100644 index 000000000..5bd705c17 --- /dev/null +++ b/cli/tests/contexts/framework/application/setup/setup-tools-use-case.unit.test.ts @@ -0,0 +1,83 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { SetupToolsUseCase } from "../../../../../src/contexts/framework/application/setup/setup-tools-use-case.js"; +import { CategoryMismatchError } from "../../../../../src/kernel/errors.js"; +import { AI_TOOL_IDS, type ToolId } from "../../../../../src/kernel/tool.js"; +import { + buildUnitDeps, + initProject, + installTool, +} from "../../../../helpers/ports/build-unit-deps.js"; + +const PROJECT_ROOT = "/test-project"; +const VERSION = "1.0.0"; + +async function build() { + const deps = await buildUnitDeps(PROJECT_ROOT); + const useCase = new SetupToolsUseCase( + deps.manifestRepo, + deps.installRuntimeConfigUseCase, + deps.installIdeConfigUseCase + ); + return { deps, useCase }; +} + +function options(aiTools: ToolId[], ideTools: ToolId[]) { + return { projectRoot: PROJECT_ROOT, aiTools, ideTools, force: false, version: VERSION }; +} + +describe("SetupToolsUseCase", () => { + it("installs nothing when no tool was asked for", async () => { + const { useCase } = await build(); + + await expect(useCase.execute(options([], []))).resolves.toStrictEqual({ results: [] }); + }); + + it("installs the AI tools asked for when no IDE was", async () => { + const { useCase } = await build(); + + const { results } = await useCase.execute(options(["claude"], [])); + + expect(results.map((r) => [r.toolId, r.skipped])).toStrictEqual([["claude", false]]); + }); + + it("installs the IDE asked for when no AI tool was", async () => { + const { useCase } = await build(); + + const { results } = await useCase.execute(options([], ["vscode"])); + + expect(results.map((r) => [r.toolId, r.skipped])).toStrictEqual([["vscode", false]]); + }); + + it("installs the IDE first so an AI tool depending on it finds it installed", async () => { + const { deps, useCase } = await build(); + + const { results } = await useCase.execute(options(["copilot"], ["vscode"])); + + expect(results.map((r) => r.toolId)).toStrictEqual(["vscode", "copilot"]); + const settings = JSON.parse(deps.fs.getFile(join(PROJECT_ROOT, ".vscode/settings.json")) ?? ""); + expect(settings).toHaveProperty("github.copilot.enable"); + }); + + it("carries the existing manifest forward instead of starting a fresh one", async () => { + const { deps, useCase } = await build(); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "claude"); + + await useCase.execute(options([], ["vscode"])); + + const manifest = await deps.manifestRepo.load(); + expect(manifest?.getInstalledToolIds()).toStrictEqual(["claude", "vscode"]); + }); + + it("refuses an IDE handed in as an AI tool, naming the valid ones", async () => { + const { useCase } = await build(); + + const run = useCase.execute(options(["vscode"], [])); + + await expect(run).rejects.toThrow(CategoryMismatchError); + await expect(run).rejects.toThrow( + `vscode is not an AI tool. Valid AI tools: ${AI_TOOL_IDS.join(", ")}` + ); + }); +}); From 39cb49beb1f3ef936c10119ebe7d46a730881077 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:34:54 +0200 Subject: [PATCH 05/12] test(cli): kill the surviving mutants of the plugin use cases Plugin add, install, install from marketplace, remove, update, search, target resolution and helpers: 68 tests, each shown red first against the mutant it names. Framework mutation score: 72.2 before the series, 95.4 after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../plugin/plugin-add-mcp.unit.test.ts | 97 ++++++ .../plugin/plugin-add-use-case.unit.test.ts | 305 +++++++++++++++++- .../plugin/plugin-helpers.unit.test.ts | 36 +++ ...all-from-marketplace-use-case.unit.test.ts | 193 ++++++++++- .../plugin-install-use-case.unit.test.ts | 256 ++++++++++++++- .../plugin-remove-cache.integration.test.ts | 82 +++++ ...move-native-activation.integration.test.ts | 65 ++++ ...emove-shared-ref-guard.integration.test.ts | 99 ++++++ .../plugin-remove-use-case.unit.test.ts | 163 +++++++++- .../plugin-search-use-case.unit.test.ts | 74 +++++ .../plugin-target-resolution.unit.test.ts | 57 ++++ .../plugin-update-use-case.unit.test.ts | 152 ++++++++- 12 files changed, 1562 insertions(+), 17 deletions(-) create mode 100644 cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/plugin/plugin-helpers.unit.test.ts diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts new file mode 100644 index 000000000..0f5b9a766 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts @@ -0,0 +1,97 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import type { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; + +const EXTRA_PLUGIN_FIXTURE = join( + process.cwd(), + "tests/fixtures/plugins/claude-format/extra-plugin" +); +const PROJECT_ROOT = "/test-project"; +const MCP_PLUGIN_DIR = "/plugins/mcp-plugin"; +const OPENCODE_JSON = join(PROJECT_ROOT, "opencode.json"); + +type Deps = Awaited>; + +async function buildOpencodeProject(): Promise<{ + deps: Deps; + logger: CapturingLogger; + useCase: PluginAddUseCase; +}> { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "opencode"); + deps.fs.setFile( + join(MCP_PLUGIN_DIR, ".claude-plugin/plugin.json"), + JSON.stringify({ name: "mcp-plugin", version: "1.0.0" }) + ); + deps.fs.setFile(join(MCP_PLUGIN_DIR, "skills/demo/SKILL.md"), "# Demo skill"); + deps.fs.setFile( + join(MCP_PLUGIN_DIR, ".mcp.json"), + JSON.stringify({ mcpServers: { "local-tool": { command: "node", args: ["./server.js"] } } }) + ); + const logger = new CapturingLogger(); + const useCase = new PluginAddUseCase( + deps.fs, + deps.manifestRepo, + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs), + deps.hasher, + logger, + deps.marketplaceRegistry, + fakeEnsureBuiltMarketplace() + ); + return { deps, logger, useCase }; +} + +function addLocal(useCase: PluginAddUseCase, path: string, replace?: boolean): Promise { + return useCase.execute({ + source: { kind: "local", path }, + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + interactive: false, + replace, + }); +} + +function installedMcpPlugin(deps: Deps): InstalledPlugin | undefined { + return deps.manifestRepo + .getCurrent() + ?.getPlugins("opencode") + .find((p) => p.name === "mcp-plugin"); +} + +describe("PluginAddUseCase and opencode MCP servers", () => { + it("keeps its own MCP server, without a collision, when re-added with replace", async () => { + const { deps, logger, useCase } = await buildOpencodeProject(); + await seedFromDirectory(deps.fs, EXTRA_PLUGIN_FIXTURE, { useAbsolutePaths: true }); + await addLocal(useCase, EXTRA_PLUGIN_FIXTURE); + await addLocal(useCase, MCP_PLUGIN_DIR); + const firstEntries = [...(installedMcpPlugin(deps)?.mcpEntries ?? [])]; + + await addLocal(useCase, MCP_PLUGIN_DIR, true); + + expect(firstEntries.map(([name]) => name)).toStrictEqual(["local-tool"]); + expect([...(installedMcpPlugin(deps)?.mcpEntries ?? [])]).toStrictEqual(firstEntries); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("warns for an MCP server the user already owns and records no entry for it", async () => { + const { deps, logger, useCase } = await buildOpencodeProject(); + deps.fs.setFile( + OPENCODE_JSON, + JSON.stringify({ mcp: { "local-tool": { type: "local", command: ["mine"] } } }) + ); + + await addLocal(useCase, MCP_PLUGIN_DIR); + + expect(logger.warnMessages).toStrictEqual([ + 'Plugin "mcp-plugin": mcp skipped for opencode — local-tool: server already exists in opencode.json (user-owned); plugin entry skipped', + ]); + expect([...(installedMcpPlugin(deps)?.mcpEntries ?? [])]).toStrictEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts index 610d3e316..881db28e2 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts @@ -9,28 +9,81 @@ import { DuplicatePluginError, MissingPluginMetadataError, } from "../../../../../src/kernel/errors.js"; +import type { Logger } from "../../../../../src/kernel/ports/logger.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const EXTRA_PLUGIN_FIXTURE = join( + process.cwd(), + "tests/fixtures/plugins/claude-format/extra-plugin" +); const PROJECT_ROOT = "/test-project"; +const GREET_PATH = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); +const GITHUB_SOURCE = { + kind: "git-subdir" as const, + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/sample-plugin", +}; -async function makeUseCase(deps: Awaited>) { - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); +type Deps = Awaited>; + +class RefusingMarketplaceRegistry extends InMemoryMarketplaceRegistry { + override async list(): Promise { + throw new Error("the registry was consulted"); + } +} + +function buildAddUseCase( + deps: Deps, + registry: InMemoryMarketplaceRegistry = deps.marketplaceRegistry, + logger: Logger = deps.logger +): PluginAddUseCase { return new PluginAddUseCase( deps.fs, deps.manifestRepo, deps.pluginFetcher, new PluginDistributionReaderAdapter(deps.fs), deps.hasher, - deps.logger, - deps.marketplaceRegistry, + logger, + registry, fakeEnsureBuiltMarketplace() ); } +async function makeUseCase(deps: Deps) { + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + return buildAddUseCase(deps); +} + +async function saveMarketplace( + registry: InMemoryMarketplaceRegistry, + name: string, + source: { kind: "github"; repo: string } | { kind: "local"; path: string } +): Promise { + await registry.save( + PROJECT_ROOT, + Marketplace.create({ name, source, scope: "project", addedAt: "2026-05-01T00:00:00.000Z" }) + ); +} + +function localAdd(toolId: "claude" | "opencode", path = PLUGIN_FIXTURE, replace?: boolean) { + return { + source: { kind: "local" as const, path }, + toolIds: [toolId], + projectRoot: PROJECT_ROOT, + interactive: false, + replace, + }; +} + +function pluginNames(deps: Deps, toolId: "claude" | "opencode" | "codex"): string[] { + return (deps.manifestRepo.getCurrent()?.getPlugins(toolId) ?? []).map((p) => p.name).sort(); +} + describe("PluginAddUseCase", () => { describe("add local plugin for claude", () => { it("writes plugin files and updates manifest", async () => { @@ -595,4 +648,248 @@ describe("PluginAddUseCase", () => { expect(plugins.find((p) => p.name === "zero-plugin")).toBeUndefined(); }); }); + + describe("marketplace resolution", () => { + it("resolves the named marketplace rather than the first registered one", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const registry = new InMemoryMarketplaceRegistry(); + await saveMarketplace(registry, "aidd-framework", { + kind: "github", + repo: "ai-driven-dev/framework", + }); + await saveMarketplace(registry, "local-mkt", { kind: "local", path: "/mkt-source" }); + + await buildAddUseCase(deps, registry).execute({ + ...localAdd("claude"), + marketplace: "local-mkt", + }); + + const installed = deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .find((p) => p.name === "sample-plugin"); + expect(installed?.marketplace).toBe("local-mkt"); + }); + + it("treats a marketplace name the registry does not list as a local marketplace", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = await makeUseCase(deps); + + await useCase.execute({ ...localAdd("claude"), marketplace: "ghost" }); + + const installed = deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .find((p) => p.name === "sample-plugin"); + expect(installed?.marketplace).toBe("ghost"); + }); + + it("never consults the registry for a local add without a marketplace", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + + await buildAddUseCase(deps, new RefusingMarketplaceRegistry()).execute(localAdd("claude")); + + expect(deps.fs.has(GREET_PATH)).toBe(true); + }); + }); + + describe("github marketplace re-registration", () => { + async function registerTwice(deps: Deps, replace: boolean | undefined): Promise { + const registry = new InMemoryMarketplaceRegistry(); + await saveMarketplace(registry, "aidd-framework", { + kind: "github", + repo: "ai-driven-dev/framework", + }); + const useCase = buildAddUseCase(deps, registry); + const options = { + source: GITHUB_SOURCE, + toolIds: ["codex" as const], + projectRoot: PROJECT_ROOT, + marketplace: "aidd-framework", + interactive: false, + pluginMetadata: { name: "sample-plugin", version: "1.0.0", strict: false }, + }; + await useCase.execute(options); + await useCase.execute({ ...options, replace }); + } + + it("re-registers the plugin when replace is requested", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "codex"); + + await registerTwice(deps, true); + + expect(pluginNames(deps, "codex")).toStrictEqual(["sample-plugin"]); + }); + + it("refuses a second registration without replace", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "codex"); + + await expect(registerTwice(deps, undefined)).rejects.toThrow(DuplicatePluginError); + }); + + it("fetches the distribution once for a flat tool when the catalog omits the version", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "opencode"); + deps.fs.setFile("/built/opencode/.opencode/skills/sample-plugin/demo/SKILL.md", "# Demo"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + deps.pluginFetcher.register(GITHUB_SOURCE, PLUGIN_FIXTURE); + const registry = new InMemoryMarketplaceRegistry(); + await saveMarketplace(registry, "aidd-framework", { + kind: "github", + repo: "ai-driven-dev/framework", + }); + const fetchSpy = vi.spyOn(deps.pluginFetcher, "fetch"); + + await buildAddUseCase(deps, registry).execute({ + source: GITHUB_SOURCE, + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + marketplace: "aidd-framework", + interactive: false, + pluginMetadata: { name: "sample-plugin", strict: false }, + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe("duplicate and replace for a local add", () => { + it("re-adds an installed plugin in place when replace is requested", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = await makeUseCase(deps); + await useCase.execute(localAdd("claude")); + + await useCase.execute(localAdd("claude", PLUGIN_FIXTURE, true)); + + expect(pluginNames(deps, "claude")).toStrictEqual(["sample-plugin"]); + }); + + it("leaves another installed plugin registered when replacing", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = await makeUseCase(deps); + await seedFromDirectory(deps.fs, EXTRA_PLUGIN_FIXTURE, { useAbsolutePaths: true }); + await useCase.execute(localAdd("claude")); + + await useCase.execute(localAdd("claude", EXTRA_PLUGIN_FIXTURE, true)); + + expect(pluginNames(deps, "claude")).toStrictEqual(["extra-plugin", "sample-plugin"]); + }); + + it("accepts a second plugin with a different name", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = await makeUseCase(deps); + await seedFromDirectory(deps.fs, EXTRA_PLUGIN_FIXTURE, { useAbsolutePaths: true }); + await useCase.execute(localAdd("claude")); + + await useCase.execute(localAdd("claude", EXTRA_PLUGIN_FIXTURE)); + + expect(pluginNames(deps, "claude")).toStrictEqual(["extra-plugin", "sample-plugin"]); + }); + + it("leaves the installed files untouched when refusing a duplicate", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = await makeUseCase(deps); + await useCase.execute(localAdd("claude")); + const installedContent = deps.fs.getFile(GREET_PATH); + deps.fs.setFile( + "/alt/sample-plugin/.claude-plugin/plugin.json", + JSON.stringify({ name: "sample-plugin", version: "1.0.0" }) + ); + deps.fs.setFile("/alt/sample-plugin/commands/greet.md", "# Changed greeting"); + + await expect(useCase.execute(localAdd("claude", "/alt/sample-plugin"))).rejects.toThrow( + DuplicatePluginError + ); + + expect(deps.fs.getFile(GREET_PATH)).toBe(installedContent); + }); + }); + + describe("required version", () => { + it("accepts a required version equal to the plugin's own", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = await makeUseCase(deps); + + await useCase.execute({ ...localAdd("claude"), requiredVersion: "1.0.0" }); + + expect(pluginNames(deps, "claude")).toStrictEqual(["sample-plugin"]); + }); + }); + + describe("native tool from a local marketplace", () => { + it("registers a claude plugin from a local marketplace without writing its files", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const registry = new InMemoryMarketplaceRegistry(); + await saveMarketplace(registry, "local-mkt", { kind: "local", path: "/mkt-source" }); + + await buildAddUseCase(deps, registry).execute({ + ...localAdd("claude"), + marketplace: "local-mkt", + }); + + const installed = deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .find((p) => p.name === "sample-plugin"); + expect([ + installed?.marketplace, + installed?.files.size, + deps.fs.has(GREET_PATH), + ]).toStrictEqual(["local-mkt", 0, false]); + }); + + it("materializes a plugin a local marketplace catalogs from github", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + deps.pluginFetcher.register(GITHUB_SOURCE, PLUGIN_FIXTURE); + const registry = new InMemoryMarketplaceRegistry(); + await saveMarketplace(registry, "local-mkt", { kind: "local", path: "/mkt-source" }); + + await buildAddUseCase(deps, registry).execute({ + source: GITHUB_SOURCE, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + marketplace: "local-mkt", + interactive: false, + }); + + const installed = deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .find((p) => p.name === "sample-plugin"); + expect([ + installed?.marketplace, + installed?.files.has(".claude/plugins/sample-plugin/commands/greet.md"), + deps.fs.has(GREET_PATH), + ]).toStrictEqual(["local-mkt", true, true]); + }); + }); + + describe("install notices", () => { + it("logs no install notice for a flat add", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "opencode"); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const logger = new CapturingLogger(); + + await buildAddUseCase(deps, deps.marketplaceRegistry, logger).execute(localAdd("opencode")); + + expect(logger.infoMessages).toStrictEqual([]); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-helpers.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-helpers.unit.test.ts new file mode 100644 index 000000000..344b291b4 --- /dev/null +++ b/cli/tests/contexts/framework/application/plugin/plugin-helpers.unit.test.ts @@ -0,0 +1,36 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + deletePluginFilesForTool, + loadPluginManifest, +} from "../../../../../src/contexts/framework/application/plugin/plugin-helpers.js"; +import { NoManifestError } from "../../../../../src/kernel/errors.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +const PROJECT_ROOT = "/test-project"; + +describe("loadPluginManifest()", () => { + it("refuses a project that was never initialized", async () => { + await expect(loadPluginManifest(new InMemoryManifestRepository())).rejects.toThrow( + NoManifestError + ); + }); +}); + +describe("deletePluginFilesForTool()", () => { + it("returns exactly the tracked paths it deleted, in manifest order", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "a/first.md"), "1"); + fs.setFile(join(PROJECT_ROOT, "b/second.md"), "2"); + const files = new Map([ + ["a/first.md", "h1"], + ["b/second.md", "h2"], + ]); + + const deleted = await deletePluginFilesForTool(files, "project", "claude", PROJECT_ROOT, fs); + + expect(deleted).toStrictEqual(["a/first.md", "b/second.md"]); + expect(fs.listAll()).toStrictEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts index f451f7be5..32371c22e 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts @@ -12,7 +12,11 @@ import { PluginNotInMarketplaceError, VersionMismatchError, } from "../../../../../src/kernel/errors.js"; +import type { Logger } from "../../../../../src/kernel/ports/logger.js"; +import type { Prompter } from "../../../../../src/kernel/ports/prompter.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { ChoosingPrompter } from "../../../../helpers/ports/choosing-prompter.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import type { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; @@ -20,6 +24,10 @@ import { KeepPrompter } from "../../../../helpers/ports/scripted-prompter.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const EXTRA_PLUGIN_FIXTURE = join( + process.cwd(), + "tests/fixtures/plugins/claude-format/extra-plugin" +); const PROJECT_ROOT = "/test-project"; const MKT1_DIR = "/mkt1"; const MKT2_DIR = "/mkt2"; @@ -32,7 +40,7 @@ function seedMarketplaceFile( fs.writeFile(join(dir, ".claude-plugin/marketplace.json"), JSON.stringify({ plugins })); } -async function buildUseCase() { +async function buildUseCase(options: { logger?: Logger | null; prompter?: Prompter } = {}) { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "claude"); await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); @@ -49,16 +57,40 @@ async function buildUseCase() { fakeEnsureBuiltMarketplace() ); const fetchMarketplaceSource = new FetchMarketplaceSourceUseCase(deps.pluginFetcher); + const logger = options.logger === null ? undefined : (options.logger ?? deps.logger); const useCase = new PluginInstallFromMarketplaceUseCase( new ResolveMarketplaceUseCase(fetchMarketplaceSource, catalogRepo), registry, pluginAdd, - new KeepPrompter(), - deps.logger + options.prompter ?? new KeepPrompter(), + logger ); return { useCase, deps, registry }; } +async function saveLocalMarketplace( + registry: InMemoryMarketplaceRegistry, + name: string, + dir: string +): Promise { + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name, + source: { kind: "local", path: dir }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); +} + +function installedSamplePlugin(deps: Awaited>) { + return deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .find((p) => p.name === "sample-plugin"); +} + describe("PluginInstallFromMarketplaceUseCase", () => { it("installs a plugin found in a single marketplace and tags it", async () => { const { useCase, deps, registry } = await buildUseCase(); @@ -427,4 +459,159 @@ describe("PluginInstallFromMarketplaceUseCase", () => { expect(result.marketplace.name).toBe("mkt1"); }); + + describe("choosing among several matches", () => { + const entryIn = (version?: string) => ({ + name: "sample-plugin", + source: { kind: "local", path: PLUGIN_FIXTURE }, + ...(version === undefined ? {} : { version }), + }); + + it("lets an interactive user pick among the marketplaces that match", async () => { + const prompter = new ChoosingPrompter("mkt2 — ?"); + const { useCase, deps, registry } = await buildUseCase({ prompter }); + seedMarketplaceFile(deps.fs, MKT1_DIR, [entryIn("1.0.0")]); + seedMarketplaceFile(deps.fs, MKT2_DIR, [entryIn()]); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + await saveLocalMarketplace(registry, "mkt2", MKT2_DIR); + + const result = await useCase.execute({ + pluginName: "sample-plugin", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(result.marketplace.name).toBe("mkt2"); + expect(prompter.selectCalls).toStrictEqual([ + { + message: "Multiple matches for 'sample-plugin'. Select one:", + choiceNames: ["mkt1 — 1.0.0", "mkt2 — ?"], + }, + ]); + }); + + it("names every matching marketplace when refusing a non-interactive install", async () => { + const { useCase, deps, registry } = await buildUseCase(); + seedMarketplaceFile(deps.fs, MKT1_DIR, [entryIn("1.0.0")]); + seedMarketplaceFile(deps.fs, MKT2_DIR, [entryIn("1.0.0")]); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + await saveLocalMarketplace(registry, "mkt2", MKT2_DIR); + + await expect( + useCase.execute({ + pluginName: "sample-plugin", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }) + ).rejects.toThrow( + "Plugin 'sample-plugin' matches multiple marketplaces: mkt1, mkt2. Use --from ." + ); + }); + + it("matches a catalog entry by name alone", async () => { + const { useCase, deps, registry } = await buildUseCase(); + seedMarketplaceFile(deps.fs, MKT1_DIR, [ + { name: "extra-plugin", source: { kind: "local", path: EXTRA_PLUGIN_FIXTURE } }, + entryIn("1.0.0"), + ]); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + + const result = await useCase.execute({ + pluginName: "sample-plugin", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(result.entry.name).toBe("sample-plugin"); + }); + + it("passes over a marketplace that has no catalog", async () => { + const { useCase, deps, registry } = await buildUseCase(); + seedMarketplaceFile(deps.fs, MKT1_DIR, [entryIn("1.0.0")]); + await saveLocalMarketplace(registry, "no-catalog", "/no-catalog"); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + + const result = await useCase.execute({ + pluginName: "sample-plugin", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(result.marketplace.name).toBe("mkt1"); + }); + }); + + describe("requested version against the catalog", () => { + it("accepts a requested version equal to the catalog's", async () => { + const { useCase, deps, registry } = await buildUseCase(); + seedMarketplaceFile(deps.fs, MKT1_DIR, [ + { + name: "sample-plugin", + source: { kind: "local", path: PLUGIN_FIXTURE }, + version: "1.0.0", + }, + ]); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + + await useCase.execute({ + pluginName: "sample-plugin", + version: "1.0.0", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(installedSamplePlugin(deps)?.version).toBe("1.0.0"); + }); + + it("logs nothing under prefer-catalog when no version was requested", async () => { + const logger = new CapturingLogger(); + const { useCase, deps, registry } = await buildUseCase({ logger }); + seedMarketplaceFile(deps.fs, MKT1_DIR, [ + { + name: "sample-plugin", + source: { kind: "local", path: PLUGIN_FIXTURE }, + version: "2.0.0", + }, + ]); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + + await useCase.execute({ + pluginName: "sample-plugin", + requestedVersionPolicy: "prefer-catalog", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(logger.allMessages).toStrictEqual([]); + }); + + it("tolerates having no logger when the catalog version differs under prefer-catalog", async () => { + const { useCase, deps, registry } = await buildUseCase({ logger: null }); + seedMarketplaceFile(deps.fs, MKT1_DIR, [ + { + name: "sample-plugin", + source: { kind: "local", path: PLUGIN_FIXTURE }, + version: "2.0.0", + }, + ]); + await saveLocalMarketplace(registry, "mkt1", MKT1_DIR); + + await useCase.execute({ + pluginName: "sample-plugin", + version: "1.0.0", + requestedVersionPolicy: "prefer-catalog", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(installedSamplePlugin(deps)?.marketplace).toBe("mkt1"); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-install-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-install-use-case.unit.test.ts index 9d2e72355..f2284e3ff 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-install-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-install-use-case.unit.test.ts @@ -6,6 +6,7 @@ import type { MarketplaceTrustStore } from "../../../../../src/contexts/distribu import type { PluginAdd } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; import type { PluginInstallFromMarketplace } from "../../../../../src/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; import { PluginInstallUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-install-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InteractiveOnlyError, InvalidPluginScopeError, @@ -19,6 +20,21 @@ import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory- const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; +class RecordingEnvironment extends InMemoryEnvironment { + readonly sets: Array<[string, string]> = []; + + override set(name: string, value: string): void { + this.sets.push([name, value]); + super.set(name, value); + } +} + +function manifestWith(toolId: "claude" | "cursor"): InMemoryManifestRepository { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", []); + return new InMemoryManifestRepository(manifest, PROJECT_ROOT); +} + function makeAlwaysTrustStore(): MarketplaceTrustStore { return { isTrusted: vi.fn().mockResolvedValue(true), @@ -44,6 +60,7 @@ function makeUseCases(overrides?: { trustStore?: MarketplaceTrustStore; prompter?: Prompter; environment?: InMemoryEnvironment; + manifestRepo?: InMemoryManifestRepository; }) { const pickExecute = overrides?.pickExecute ?? vi.fn(); const addExecute = overrides?.addExecute ?? vi.fn(); @@ -53,7 +70,7 @@ function makeUseCases(overrides?: { const pluginInstallFromMarketplaceUseCase: PluginInstallFromMarketplace = { execute: marketplaceExecute, }; - const manifestRepo = new InMemoryManifestRepository(); + const manifestRepo = overrides?.manifestRepo ?? new InMemoryManifestRepository(); const trustStore = overrides?.trustStore ?? makeAlwaysTrustStore(); const prompter = overrides?.prompter ?? makeSilentPrompter(); const environment = overrides?.environment ?? new InMemoryEnvironment(); @@ -314,5 +331,242 @@ describe("PluginInstallUseCase", () => { expect(environment.get("AIDD_TOKEN")).toBeUndefined(); }); + + it("leaves the environment untouched when no token is passed", async () => { + const environment = new RecordingEnvironment(); + const marketplaceExecute = vi.fn().mockResolvedValue({ entry: { name: "my-plugin" } }); + + await makeUseCase({ marketplaceExecute, environment }).execute({ + pluginArg: "my-plugin", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(environment.sets).toStrictEqual([]); + }); + }); + + describe("scope validation against every targeted tool", () => { + const marketplaceExecute = () => vi.fn().mockResolvedValue({ entry: { name: "my-plugin" } }); + + it("checks every AI tool when no manifest exists", async () => { + await expect( + makeUseCase({ marketplaceExecute: marketplaceExecute() }).execute({ + pluginArg: "my-plugin", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + scope: "project", + }) + ).rejects.toBeInstanceOf(InvalidPluginScopeError); + }); + + it("checks only the installed tools when a manifest exists", async () => { + const result = await makeUseCase({ + marketplaceExecute: marketplaceExecute(), + manifestRepo: manifestWith("claude"), + }).execute({ + pluginArg: "my-plugin", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + scope: "project", + }); + + expect(result.kind).toBe("marketplace"); + }); + + it("rejects a scope an installed tool refuses", async () => { + await expect( + makeUseCase({ + marketplaceExecute: marketplaceExecute(), + manifestRepo: manifestWith("claude"), + }).execute({ + pluginArg: "my-plugin", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + scope: "user", + }) + ).rejects.toBeInstanceOf(InvalidPluginScopeError); + }); + }); + + describe("source argument shapes", () => { + it("routes a URL argument to the local-source add", async () => { + const addExecute = vi.fn().mockResolvedValue(undefined); + + const result = await makeUseCase({ addExecute }).execute({ + pluginArg: "https://github.com/x/y.git", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(result).toStrictEqual({ kind: "local", installed: [] }); + }); + + it("routes a relative ./ path to the local-source add", async () => { + const addExecute = vi.fn().mockResolvedValue(undefined); + + const result = await makeUseCase({ addExecute }).execute({ + pluginArg: "./plugins/mine", + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(result).toStrictEqual({ kind: "local", installed: [] }); + }); + }); + + describe("what each delegate receives", () => { + it("names the action when refusing a non-interactive pick", async () => { + await expect( + makeUseCase().execute({ + pluginArg: undefined, + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + }) + ).rejects.toThrow("'plugin install' requires an interactive terminal."); + }); + + it("hands the pick its tools, project and an interactive flag", async () => { + const pickExecute = vi.fn().mockResolvedValue({ installed: [] }); + + await makeUseCase({ pickExecute }).execute({ + pluginArg: undefined, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(pickExecute).toHaveBeenCalledWith({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: true, + }); + }); + + it("hands the add the parsed source and the caller's options", async () => { + const addExecute = vi.fn().mockResolvedValue(undefined); + + const result = await makeUseCase({ addExecute }).execute({ + pluginArg: PLUGIN_FIXTURE, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(addExecute).toHaveBeenCalledWith({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + expect(result).toStrictEqual({ kind: "local", installed: [] }); + }); + + it("hands the marketplace install the parsed name, version and options", async () => { + const marketplaceExecute = vi.fn().mockResolvedValue({ entry: { name: "my-plugin" } }); + + await makeUseCase({ marketplaceExecute }).execute({ + pluginArg: "my-plugin@1.2.3", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + fromMarketplace: "mkt", + yes: true, + }); + + expect(marketplaceExecute).toHaveBeenCalledWith({ + pluginName: "my-plugin", + version: "1.2.3", + fromMarketplace: "mkt", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + autoSelect: true, + }); + }); + + it("defaults autoSelect to false when --yes is absent", async () => { + const marketplaceExecute = vi.fn().mockResolvedValue({ entry: { name: "my-plugin" } }); + + await makeUseCase({ marketplaceExecute }).execute({ + pluginArg: "my-plugin", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + + expect(marketplaceExecute).toHaveBeenCalledWith({ + pluginName: "my-plugin", + version: undefined, + fromMarketplace: undefined, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + autoSelect: false, + }); + }); + }); + + describe("trusting a direct source", () => { + function untrustedStore(): MarketplaceTrustStore { + return { + isTrusted: vi.fn().mockResolvedValue(false), + trust: vi.fn().mockResolvedValue(undefined), + }; + } + + it("neither prompts nor records trust for a source already trusted", async () => { + const trustStore = makeAlwaysTrustStore(); + const prompter = makeSilentPrompter(); + + await makeUseCase({ addExecute: vi.fn(), trustStore, prompter }).execute({ + pluginArg: PLUGIN_FIXTURE, + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(trustStore.trust).not.toHaveBeenCalled(); + }); + + it("records trust without prompting when --yes is passed", async () => { + const trustStore = untrustedStore(); + const prompter = makeSilentPrompter(); + + await makeUseCase({ addExecute: vi.fn(), trustStore, prompter }).execute({ + pluginArg: PLUGIN_FIXTURE, + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: false, + yes: true, + }); + + expect(prompter.confirm).not.toHaveBeenCalled(); + expect(trustStore.trust).toHaveBeenCalledWith(PROJECT_ROOT, { + kind: "local", + path: PLUGIN_FIXTURE, + }); + }); + + it("asks to trust the source by its description", async () => { + const prompter = makeSilentPrompter(); + + await makeUseCase({ addExecute: vi.fn(), trustStore: untrustedStore(), prompter }).execute({ + pluginArg: PLUGIN_FIXTURE, + toolIds: "all", + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(prompter.confirm).toHaveBeenCalledWith(`Trust plugin source '${PLUGIN_FIXTURE}'?`); + }); }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts index dddbf2bf6..905f99e37 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts @@ -6,6 +6,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { ModeAMarketplaceTranslator } from "../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; @@ -174,4 +175,85 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { expect(await fs.fileExists(witness)).toBe(true); expect(logger.warnMessages.some((m) => m.includes("does not resolve inside"))).toBe(true); }); + + it("names the purged cache directory", async () => { + const fs = new InMemoryFileAdapter(); + await fs.writeFile( + join(CLAUDE_CACHE_ROOT, HOST_NAME, PLUGIN_NAME, "1.0.0", "plugin.json"), + "{}" + ); + const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); + const logger = new CapturingLogger(); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["claude", new FakeNativePluginActivator({ available: true })]]) + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(logger.infoMessages).toStrictEqual([ + `claude: cache for '${PLUGIN_NAME}' purged: ${join(CLAUDE_CACHE_ROOT, HOST_NAME, PLUGIN_NAME)}`, + ]); + }); + + it("leaves the cache alone, silently, when no activator drives this tool", async () => { + const fs = new InMemoryFileAdapter(); + const cacheEntry = join(CLAUDE_CACHE_ROOT, HOST_NAME, PLUGIN_NAME, "1.0.0", "plugin.json"); + await fs.writeFile(cacheEntry, "{}"); + const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); + const logger = new CapturingLogger(); + const removeUseCase = new PluginRemoveUseCase(fs, manifestRepo, logger, new Map()); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(await fs.fileExists(cacheEntry)).toBe(true); + expect(logger.allMessages).toStrictEqual([]); + }); + + it("purges nothing, silently, for a host that declares no plugin cache directory", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("copilot", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "copilot", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + ALIAS + ); + manifest.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [{ alias: ALIAS, hostName: HOST_NAME }], + pluginRefs: [REF], + }); + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["copilot", activator]]) + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["copilot"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toStrictEqual([REF]); + expect(logger.allMessages).toStrictEqual([]); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts index 2468e8f25..8582973d0 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts @@ -350,4 +350,69 @@ describe("PluginRemoveUseCase undoes native activation", () => { expect(activator.uninstalledPlugins).toEqual([REF]); expect(activator.uninstalledPluginScopes).toEqual(["user"]); }); + + it("logs nothing when this tool's own native registrations name the alias", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: MARKETPLACE_NAME, hostName: "upstream" }], + pluginRefs: [], + }); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toStrictEqual([`${PLUGIN_NAME}@upstream`]); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("names the host, the ref and the host's answer when the host CLI refuses the uninstall", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + failOnUninstall: [REF], + }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + await manifestRepo.save(manifest); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(logger.warnMessages).toStrictEqual([ + `claude plugin uninstall '${REF}' failed: plugin \`${REF}\` is not installed — an entry for it may remain in claude's own plugin registry.`, + ]); + }); + + it("propagates a failure that is not the host CLI refusing", async () => { + const activator = new FakeNativePluginActivator({ available: true, crashOnUninstall: true }); + const logger = new CapturingLogger(); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + await manifestRepo.save(manifest); + + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow("activator crashed uninstalling a plugin"); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts index 31fdf29e8..67d584575 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts @@ -176,4 +176,103 @@ describe("plugin remove guards a ref another project on this machine still needs expect(activator.uninstalledPlugins).toContain(REF); expect(logger.warnMessages).toEqual([]); }); + + describe("a ref outside the shared source", () => { + it("uninstalls a ref from a marketplace that is not the shared source, whatever other projects reference", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const registry = seedSharedMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "other-mkt", + source: { kind: "local", path: "/other/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const { removeUseCase } = buildUseCase( + fs, + activator, + logger, + seedManifest("other-mkt"), + registry + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toStrictEqual([`${PLUGIN_NAME}@other-mkt`]); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("uninstalls a ref whose marketplace this project's registry no longer lists", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const { removeUseCase } = buildUseCase(fs, activator, logger, seedManifest("gone-mkt")); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toStrictEqual([`${PLUGIN_NAME}@gone-mkt`]); + expect(logger.warnMessages).toStrictEqual([]); + }); + }); + + describe("without one of the guard's two registries", () => { + it("skips the guard when no references registry is wired, even for the shared source", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + const removeUseCase = new PluginRemoveUseCase( + fs, + new InMemoryManifestRepository(seedManifest(), PROJECT_ROOT), + new CapturingLogger(), + new Map([["codex", activator]]), + new Map(), + undefined, + seedSharedMarketplaceRegistry() + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toStrictEqual([REF]); + }); + + it("skips the guard when no marketplace registry is wired", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + const removeUseCase = new PluginRemoveUseCase( + fs, + new InMemoryManifestRepository(seedManifest(), PROJECT_ROOT), + new CapturingLogger(), + new Map([["codex", activator]]), + new Map(), + new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR) + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toStrictEqual([REF]); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts index e87ef7c19..3e3dc52f1 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts @@ -1,12 +1,17 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; -import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { + InstalledPlugin, + type PluginEntryData, +} from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { PluginNotFoundError } from "../../../../../src/kernel/errors.js"; +import type { AiToolId } from "../../../../../src/kernel/tool.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; @@ -14,22 +19,49 @@ import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-ad import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; -/** Records every path `deleteFile` is called with, so a test can prove where a plugin's - * file actually got deleted from without inspecting private use-case state. */ class RecordingFileAdapter extends InMemoryFileAdapter { readonly deletedPaths: string[] = []; + readonly writtenPaths: string[] = []; override async deleteFile(path: string): Promise { this.deletedPaths.push(path); return super.deleteFile(path); } + + override async writeFile(path: string, content: string): Promise { + this.writtenPaths.push(path); + return super.writeFile(path, content); + } +} + +class UnreadableFileAdapter extends InMemoryFileAdapter { + constructor(private readonly unreadablePath: string) { + super(); + } + + override async readFile(path: string): Promise { + if (path === this.unreadablePath) { + throw Object.assign(new Error(`EACCES: permission denied, open '${path}'`), { + code: "EACCES", + }); + } + return super.readFile(path); + } } const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const EXTRA_PLUGIN_FIXTURE = join( + process.cwd(), + "tests/fixtures/plugins/claude-format/extra-plugin" +); const PROJECT_ROOT = "/test-project"; +const OPENCODE_JSON = join(PROJECT_ROOT, "opencode.json"); -async function installPlugin(deps: Awaited>): Promise { - await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); +async function installPlugin( + deps: Awaited>, + fixture = PLUGIN_FIXTURE +): Promise { + await seedFromDirectory(deps.fs, fixture, { useAbsolutePaths: true }); const addUseCase = new PluginAddUseCase( deps.fs, deps.manifestRepo, @@ -41,13 +73,40 @@ async function installPlugin(deps: Awaited>): P fakeEnsureBuiltMarketplace() ); await addUseCase.execute({ - source: { kind: "local", path: PLUGIN_FIXTURE }, + source: { kind: "local", path: fixture }, toolIds: ["claude"], projectRoot: PROJECT_ROOT, interactive: false, }); } +function manifestHolding( + toolId: AiToolId, + entry: Partial & { name: string } +): InMemoryManifestRepository { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", []); + manifest.addPlugin( + toolId, + InstalledPlugin.fromJSON({ + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + ...entry, + }) + ); + return new InMemoryManifestRepository(manifest, PROJECT_ROOT); +} + +function removeUseCaseOver( + fs: InMemoryFileAdapter, + manifestRepo: InMemoryManifestRepository +): PluginRemoveUseCase { + return new PluginRemoveUseCase(fs, manifestRepo, new CapturingLogger(), new Map()); +} + describe("PluginRemoveUseCase", () => { describe("remove installed plugin", () => { it("deletes plugin files and updates manifest", async () => { @@ -135,4 +194,96 @@ describe("PluginRemoveUseCase", () => { ); }); }); + + describe("only the named plugin", () => { + it("deletes only the named plugin's files when another plugin is installed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installPlugin(deps); + await installPlugin(deps, EXTRA_PLUGIN_FIXTURE); + const greet = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); + const bye = join(PROJECT_ROOT, ".claude/plugins/extra-plugin/commands/bye.md"); + + await new PluginRemoveUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.nativePluginActivators + ).execute({ pluginName: "extra-plugin", toolIds: ["claude"], projectRoot: PROJECT_ROOT }); + + expect([deps.fs.has(greet), deps.fs.has(bye)]).toStrictEqual([true, false]); + expect( + deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .map((p) => p.name) + ).toStrictEqual(["sample-plugin"]); + }); + }); + + describe("a tool without native activation", () => { + it("removes a cursor plugin recorded with a marketplace without driving any host CLI", async () => { + const manifestRepo = manifestHolding("cursor", { name: "aidd-context", marketplace: "mkt" }); + + await removeUseCaseOver(new InMemoryFileAdapter(), manifestRepo).execute({ + pluginName: "aidd-context", + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + }); + + expect(manifestRepo.getCurrent()?.getPlugins("cursor")).toStrictEqual([]); + }); + }); + + describe("MCP entries on removal", () => { + it("does not rewrite opencode's config when the plugin carries no MCP entries", async () => { + const fs = new RecordingFileAdapter(); + fs.setFile(OPENCODE_JSON, JSON.stringify({ mcp: { mine: { type: "local" } } })); + const manifestRepo = manifestHolding("opencode", { name: "aidd-context" }); + + await removeUseCaseOver(fs, manifestRepo).execute({ + pluginName: "aidd-context", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + }); + + expect(fs.writtenPaths).toStrictEqual([]); + }); + + it("leaves a native tool's own MCP file untouched when the plugin carries MCP entries", async () => { + const fs = new InMemoryFileAdapter(); + const mcpFile = join(PROJECT_ROOT, ".mcp.json"); + const content = JSON.stringify({ mcpServers: { srv: { command: "node" } } }); + fs.setFile(mcpFile, content); + const manifestRepo = manifestHolding("claude", { + name: "aidd-context", + mcpEntries: { srv: "abc123" }, + }); + + await removeUseCaseOver(fs, manifestRepo).execute({ + pluginName: "aidd-context", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(fs.getFile(mcpFile)).toBe(content); + }); + + it("propagates a read failure of opencode's config that is not an absence", async () => { + const fs = new UnreadableFileAdapter(OPENCODE_JSON); + fs.setFile(OPENCODE_JSON, "{}"); + const manifestRepo = manifestHolding("opencode", { + name: "aidd-context", + mcpEntries: { srv: "abc123" }, + }); + + await expect( + removeUseCaseOver(fs, manifestRepo).execute({ + pluginName: "aidd-context", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow("EACCES: permission denied"); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-search-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-search-use-case.unit.test.ts index 17e17fdd8..365e89d6a 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-search-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-search-use-case.unit.test.ts @@ -145,6 +145,80 @@ describe("PluginSearchUseCase", () => { expect(result.hits[0]?.marketplace.name).toBe("mkt2"); }); + describe("what a query matches", () => { + async function registryWith(entry: Record): Promise<{ + fs: InMemoryFileAdapter; + registry: InMemoryMarketplaceRegistry; + }> { + const fs = new InMemoryFileAdapter(); + seedMarketplace(fs, MKT1_PATH, [entry]); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "mkt1", + source: { kind: "local", path: MKT1_PATH }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + return { fs, registry }; + } + + it("matches nothing when neither name nor description contains the query", async () => { + const { fs, registry } = await registryWith({ + name: "sample-plugin", + source: { kind: "github", repo: "x/y" }, + description: "Hello world", + }); + + const result = await buildUseCase(fs, registry).execute({ + query: "zzz", + recommendedOnly: false, + projectRoot: PROJECT_ROOT, + }); + + expect(result.hits).toStrictEqual([]); + }); + + it("does not match a description an entry lacks", async () => { + const { fs, registry } = await registryWith({ + name: "sample-plugin", + source: { kind: "github", repo: "x/y" }, + }); + + const result = await buildUseCase(fs, registry).execute({ + query: "was here", + recommendedOnly: false, + projectRoot: PROJECT_ROOT, + }); + + expect(result.hits).toStrictEqual([]); + }); + }); + + it("yields no hit for a marketplace without a catalog", async () => { + const fs = new InMemoryFileAdapter(); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "no-catalog", + source: { kind: "local", path: "/no-catalog" }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await buildUseCase(fs, registry).execute({ + query: "", + recommendedOnly: false, + projectRoot: PROJECT_ROOT, + }); + + expect(result.hits).toStrictEqual([]); + }); + it("returns empty when no marketplaces are registered", async () => { const fs = new InMemoryFileAdapter(); const registry = new InMemoryMarketplaceRegistry(); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-target-resolution.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-target-resolution.unit.test.ts index 90f2bd75b..4d684782c 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-target-resolution.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-target-resolution.unit.test.ts @@ -3,14 +3,71 @@ import { describe, expect, it } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { + isFrameworkPrimeFlatMcp, resolveBaseDirFromRecord, + resolvePluginToolIds, resolveScopeForInstall, } from "../../../../../src/contexts/framework/application/plugin/plugin-target-resolution.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { McpCapability } from "../../../../../src/contexts/tools/domain/capabilities/mcp-capability.js"; +import { PluginsCapability } from "../../../../../src/contexts/tools/domain/capabilities/plugins-capability.js"; import { UnresolvableUserScopeError } from "../../../../../src/kernel/errors.js"; const HOME = "/home/u"; const homedir = () => HOME; +function mcp(mergeStrategy?: "user-prime" | "framework-prime"): McpCapability { + return new McpCapability({ outputPath: "mcp.json", format: "json", mergeStrategy }); +} + +const FLAT_PLUGINS = new PluginsCapability({ + mode: "flat", + flatNamespacePrefix: "x-", + acceptsHooks: false, + hooksUnsupportedReason: "none", +}); +const NATIVE_PLUGINS = new PluginsCapability({ + mode: "native", + pluginsDir: ".x/plugins/", + pluginManifestRelativePath: null, + acceptsHooks: false, + hooksUnsupportedReason: "none", +}); + +describe("resolvePluginToolIds()", () => { + it("resolves 'all' to the AI tools the manifest holds, in registry order", () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addTool("claude", "1.0.0", []); + + expect(resolvePluginToolIds("all", manifest)).toStrictEqual(["claude", "cursor"]); + }); +}); + +describe("isFrameworkPrimeFlatMcp()", () => { + it("fails for a tool declaring no MCP capability", () => { + expect(isFrameworkPrimeFlatMcp({ plugins: FLAT_PLUGINS })).toBe(false); + }); + + it("fails for an mcp declaration that is not a capability", () => { + expect(isFrameworkPrimeFlatMcp({ mcp: "framework-prime", plugins: FLAT_PLUGINS })).toBe(false); + }); + + it("fails for a flat tool merging MCP user-prime", () => { + expect(isFrameworkPrimeFlatMcp({ mcp: mcp("user-prime"), plugins: FLAT_PLUGINS })).toBe(false); + }); + + it("fails for a flat tool declaring no merge strategy", () => { + expect(isFrameworkPrimeFlatMcp({ mcp: mcp(), plugins: FLAT_PLUGINS })).toBe(false); + }); + + it("fails for a native tool merging MCP framework-prime", () => { + expect(isFrameworkPrimeFlatMcp({ mcp: mcp("framework-prime"), plugins: NATIVE_PLUGINS })).toBe( + false + ); + }); +}); + describe("resolveScopeForInstall()", () => { it("reads the scope a fresh install writes from the tool's own profile", () => { expect(resolveScopeForInstall("cursor")).toBe("user"); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts index 968dde656..a6750db11 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts @@ -1,16 +1,43 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import type { PluginFetchOptions } from "../../../../../src/contexts/distribution/domain/ports/plugin-fetcher.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; import { PluginUpdateUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; +import type { BuiltMaterializationDeps } from "../../../../../src/contexts/framework/application/shared/apply-plugin-files-use-case.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import type { PluginSource } from "../../../../../src/kernel/source.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const EXTRA_PLUGIN_FIXTURE = join( + process.cwd(), + "tests/fixtures/plugins/claude-format/extra-plugin" +); const PROJECT_ROOT = "/test-project"; +const GREET_PATH = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); -async function setup(deps: Awaited>) { +type Deps = Awaited>; + +class RecordingFetcher extends FixturePluginFetcher { + readonly fetchOptions: Array = []; + + override fetch( + source: PluginSource, + cacheDir: string, + options?: PluginFetchOptions + ): Promise { + this.fetchOptions.push(options); + return super.fetch(source, cacheDir, options); + } +} + +async function setup( + deps: Deps, + options: { fetcher?: FixturePluginFetcher; builtDeps?: BuiltMaterializationDeps } = {} +) { await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); const reader = new PluginDistributionReaderAdapter(deps.fs); const addUseCase = new PluginAddUseCase( @@ -26,13 +53,39 @@ async function setup(deps: Awaited>) { const updateUseCase = new PluginUpdateUseCase( deps.fs, deps.manifestRepo, - deps.pluginFetcher, + options.fetcher ?? deps.pluginFetcher, reader, - deps.hasher + deps.hasher, + options.builtDeps ); return { addUseCase, updateUseCase }; } +async function addLocal(addUseCase: PluginAddUseCase, path = PLUGIN_FIXTURE): Promise { + await addUseCase.execute({ + source: { kind: "local", path }, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); +} + +async function recordVersion(deps: Deps, name: string, version: string): Promise { + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("manifest not found"); + const plugin = manifest.getPlugins("claude").find((p) => p.name === name); + if (plugin === undefined) throw new Error("plugin not found"); + manifest.updatePlugin("claude", plugin.withVersion(version)); + await deps.manifestRepo.save(manifest); +} + +function installed(deps: Deps, name: string) { + return deps.manifestRepo + .getCurrent() + ?.getPlugins("claude") + .find((p) => p.name === name); +} + describe("PluginUpdateUseCase", () => { describe("same version", () => { it("does not re-write files when version is equal", async () => { @@ -85,4 +138,97 @@ describe("PluginUpdateUseCase", () => { expect(updatedPlugin?.version).toBe("1.0.0"); }); }); + + describe("what it reports", () => { + it("reports nothing when the installed version is current", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const { addUseCase, updateUseCase } = await setup(deps); + await addLocal(addUseCase); + + const updated = await updateUseCase.execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(updated).toStrictEqual([]); + }); + + it("reports exactly the plugin it updated", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const { addUseCase, updateUseCase } = await setup(deps); + await addLocal(addUseCase); + await recordVersion(deps, "sample-plugin", "0.0.1"); + + const updated = await updateUseCase.execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(updated).toStrictEqual(["sample-plugin"]); + }); + + it("updates only the plugins named", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const { addUseCase, updateUseCase } = await setup(deps); + await seedFromDirectory(deps.fs, EXTRA_PLUGIN_FIXTURE, { useAbsolutePaths: true }); + await addLocal(addUseCase); + await addLocal(addUseCase, EXTRA_PLUGIN_FIXTURE); + await recordVersion(deps, "sample-plugin", "0.0.1"); + await recordVersion(deps, "extra-plugin", "0.0.1"); + + const updated = await updateUseCase.execute({ + pluginNames: ["extra-plugin"], + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(updated).toStrictEqual(["extra-plugin"]); + expect([ + installed(deps, "sample-plugin")?.version, + installed(deps, "extra-plugin")?.version, + ]).toStrictEqual(["0.0.1", "1.0.0"]); + }); + }); + + describe("how it fetches", () => { + it("fetches the plugin source with a forced refresh", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const fetcher = new RecordingFetcher(); + const { addUseCase, updateUseCase } = await setup(deps, { fetcher }); + await addLocal(addUseCase); + + await updateUseCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT }); + + expect(fetcher.fetchOptions).toStrictEqual([{ forceRefresh: true }]); + }); + }); + + describe("a plugin installed from a local path", () => { + it("re-writes its files on disk even when built-tree deps are wired", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const { addUseCase, updateUseCase } = await setup(deps, { + builtDeps: { + ensureBuilt: fakeEnsureBuiltMarketplace(), + marketplaceRegistry: deps.marketplaceRegistry, + homedir: () => "/home/u", + }, + }); + await addLocal(addUseCase); + await recordVersion(deps, "sample-plugin", "0.0.1"); + + await updateUseCase.execute({ toolIds: ["claude"], projectRoot: PROJECT_ROOT }); + + const plugin = installed(deps, "sample-plugin"); + expect([ + plugin?.version, + plugin?.files.has(".claude/plugins/sample-plugin/commands/greet.md"), + deps.fs.has(GREET_PATH), + ]).toStrictEqual(["1.0.0", true, true]); + }); + }); }); From f17df2cba11886642639b2247f68f92cc2eee80c Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:35:00 +0200 Subject: [PATCH 06/12] test(cli): kill the surviving mutants of the clean use cases Clean and clean user scope, including the shared reference guard: 61 tests, each shown red first against the mutant it names. Framework mutation score: 72.2 before the series, 95.4 after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../clean-use-case.integration.test.ts | 66 ++ .../application/clean-use-case.unit.test.ts | 897 ++++++++++++++++++ ...clean-shared-ref-guard.integration.test.ts | 26 + ...an-user-scope-use-case.integration.test.ts | 555 ++++++++++- 4 files changed, 1519 insertions(+), 25 deletions(-) create mode 100644 cli/tests/contexts/framework/application/clean-use-case.integration.test.ts diff --git a/cli/tests/contexts/framework/application/clean-use-case.integration.test.ts b/cli/tests/contexts/framework/application/clean-use-case.integration.test.ts new file mode 100644 index 000000000..0ddb127e3 --- /dev/null +++ b/cli/tests/contexts/framework/application/clean-use-case.integration.test.ts @@ -0,0 +1,66 @@ +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { CleanUseCase } from "../../../../src/contexts/framework/application/clean-use-case.js"; +import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { FileAdapter } from "../../../../src/runtime/filesystem/file-adapter.js"; +import { HasherAdapter } from "../../../../src/runtime/filesystem/hasher-adapter.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; + +let projectRoot: string; + +beforeEach(async () => { + projectRoot = await mkdtemp(join(tmpdir(), "aidd-clean-aidd-dir-")); +}); + +afterEach(async () => { + await rm(projectRoot, { recursive: true, force: true }); +}); + +function buildUseCase(logger: CapturingLogger): CleanUseCase { + const fs = new FileAdapter(new HasherAdapter(), logger); + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + return new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, projectRoot), + logger, + new GitignoreUseCase(fs) + ); +} + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + +describe("clean and the .aidd/ directory itself", () => { + it("removes .aidd/ once nothing it wrote is left inside, rather than leaving an empty shell", async () => { + const aiddDir = join(projectRoot, ".aidd"); + await mkdir(join(aiddDir, "cache", "built"), { recursive: true }); + await writeFile(join(aiddDir, "cache", "built", "x.json"), "{}"); + const logger = new CapturingLogger(); + + await buildUseCase(logger).execute({ projectRoot, force: true }); + + expect(await exists(aiddDir)).toBe(false); + expect(logger.infoMessages).toStrictEqual(["Removing claude files..."]); + }); + + it("finishes cleanly when .aidd/ was already removed by hand before the run", async () => { + const logger = new CapturingLogger(); + + const result = await buildUseCase(logger).execute({ projectRoot, force: true }); + + expect(result.fileCount).toBe(0); + expect(await exists(join(projectRoot, ".aidd"))).toBe(false); + }); +}); diff --git a/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts b/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts index 77051b099..38d69476d 100644 --- a/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts @@ -5,6 +5,7 @@ import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; import { CleanUseCase } from "../../../../src/contexts/framework/application/clean-use-case.js"; import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; @@ -13,6 +14,7 @@ import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugi import { UserSourceReferencesAdapter } from "../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; import { cursorProjectHooksScriptDir } from "../../../../src/contexts/tools/domain/formats/cursor-hooks-project-merge.js"; import type { NativePluginActivator } from "../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; +import { FileHash, InstallationFile } from "../../../../src/kernel/file.js"; import type { AiToolId, ToolId } from "../../../../src/kernel/tool.js"; import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; @@ -21,6 +23,7 @@ import { FakeNativePluginActivator } from "../../../helpers/ports/fake-native-pl import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; +import { RecordingPrompter } from "../../../helpers/ports/recording-prompter.js"; const PROJECT_ROOT = "/test-project"; @@ -1024,6 +1027,456 @@ describe("clean", () => { expect(activator.removedMarketplaces).toEqual([HOST_NAME]); expect(activator.removedMarketplaces).not.toContain(ALIAS); }); + + describe("what the host is told, word for word", () => { + const HOME = "/fake-home"; + const CODEX_CACHE = join(HOME, ".codex", "plugins", "cache"); + + function buildUseCase(deps: { + manifest: Manifest; + fs: InMemoryFileAdapter; + logger: CapturingLogger; + activators: ReadonlyMap; + registry: InMemoryMarketplaceRegistry | undefined; + }): CleanUseCase { + return new CleanUseCase( + deps.fs, + new InMemoryManifestRepository(deps.manifest, PROJECT_ROOT), + deps.logger, + new GitignoreUseCase(deps.fs), + deps.activators, + deps.registry, + undefined, + new Map(), + () => HOME + ); + } + + it("names the absent binary alone when no activator is wired for it", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest: seedManifestWithNativeRegistrations(), + fs, + logger, + activators: new Map(), + registry: seedMarketplaceRegistry(), + }); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.manifestFound).toBe(true); + expect(logger.warnMessages).toStrictEqual([ + `codex: registration left in place, the codex CLI is not on the PATH. Its cache survives at: ${join(CODEX_CACHE, MARKETPLACE)}.`, + ]); + }); + + it("leaves a registration in place when its alias is no longer registered here, still uninstalling the plugin ref", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(CODEX_CACHE, MARKETPLACE, "leftover.json"), "{}"); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "other-mkt", + source: { kind: "local", path: "/other/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const useCase = buildUseCase({ + manifest: seedManifestWithNativeRegistrations(), + fs, + logger, + activators: new Map([[BINARY, activator]]), + registry, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toStrictEqual([REF]); + expect(activator.removedMarketplaces).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([ + `codex: '${MARKETPLACE}' is no longer a registered marketplace here, so its scope cannot be resolved — its codex registration was left in place.`, + `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, + ]); + }); + + it("leaves a registration in place when no marketplace registry is wired in at all", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + manifest: seedManifestWithNativeRegistrations(), + fs, + logger, + activators: new Map([[BINARY, activator]]), + registry: undefined, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.removedMarketplaces).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([ + `codex: '${MARKETPLACE}' is no longer a registered marketplace here, so its scope cannot be resolved — its codex registration was left in place.`, + `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, + ]); + }); + + it("names a refused marketplace removal by the host's own words", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true, throwOnRemove: true }); + const useCase = buildUseCase({ + manifest: seedManifestWithNativeRegistrations(), + fs, + logger, + activators: new Map([[BINARY, activator]]), + registry: seedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + `codex marketplace remove '${MARKETPLACE}' failed: marketplace remove ${MARKETPLACE} failed: '${MARKETPLACE}' is not configured or installed`, + `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, + ]); + }); + + it("names a refused plugin uninstall by the host's own words, after trying both scopes", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ + available: true, + failOnUninstall: [REF], + }); + const useCase = buildUseCase({ + manifest: seedManifestWithNativeRegistrations(), + fs, + logger, + activators: new Map([[BINARY, activator]]), + registry: seedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPluginScopes).toStrictEqual(["project", "user"]); + expect(logger.warnMessages).toStrictEqual([ + `codex plugin uninstall '${REF}' failed: plugin \`${REF}\` is not installed`, + ]); + }); + + it("propagates an activator failure that is not the host refusing", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildUseCase({ + manifest: seedManifestWithNativeRegistrations(), + fs, + logger: new CapturingLogger(), + activators: new Map([[BINARY, new CrashingUninstallActivator()]]), + registry: seedMarketplaceRegistry(), + }); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "activator crashed uninstalling a plugin" + ); + }); + + function seedSharedRegistry(): InMemoryMarketplaceRegistry { + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + return registry; + } + + function seedRegistrations(toolId: ToolId, binary: string): Manifest { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", []); + manifest.setNativeRegistrations(toolId, { + binary, + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [], + }); + return manifest; + } + + it("names no cache for a shared marketplace at a host whose profile declares no cache directory (copilot)", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest: seedRegistrations("copilot", "copilot"), + fs, + logger, + activators: new Map([["copilot", new FakeNativePluginActivator({ available: true })]]), + registry: seedSharedRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + `copilot: '${MARKETPLACE}' is shared by every project on this machine — left registered. Its entry survives at userConfigDir()/marketplaces.json.`, + ]); + }); + + it("names no cache for a shared marketplace at a tool that drives no native CLI (cursor)", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest: seedRegistrations("cursor", "cursor"), + fs, + logger, + activators: new Map([["cursor", new FakeNativePluginActivator({ available: true })]]), + registry: seedSharedRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + `cursor: '${MARKETPLACE}' is shared by every project on this machine — left registered. Its entry survives at userConfigDir()/marketplaces.json.`, + ]); + }); + + it("never treats a shared marketplace it left registered as one the host forgot, so its cache stays", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(CODEX_CACHE, MARKETPLACE, "leftover.json"), "{}"); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest: seedRegistrations("codex", BINARY), + fs, + logger, + activators: new Map([[BINARY, new FakeNativePluginActivator({ available: true })]]), + registry: seedSharedRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.has(join(CODEX_CACHE, MARKETPLACE, "leftover.json"))).toBe(true); + expect(logger.warnMessages).toStrictEqual([ + `codex: '${MARKETPLACE}' is shared by every project on this machine — left registered. Its entry survives at userConfigDir()/marketplaces.json, and its cache at: ${join(CODEX_CACHE, MARKETPLACE)}.`, + `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, + ]); + }); + + it("names no surviving cache when the absent binary's profile declares no cache directory (copilot)", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest: seedRegistrations("copilot", "copilot"), + fs, + logger, + activators: new Map(), + registry: seedSharedRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + "copilot: registration left in place, the copilot CLI is not on the PATH.", + ]); + }); + + it("names no surviving cache when the absent binary belongs to a tool that drives no native CLI (cursor)", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest: seedRegistrations("cursor", "cursor"), + fs, + logger, + activators: new Map(), + registry: seedSharedRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + "cursor: registration left in place, the cursor CLI is not on the PATH.", + ]); + }); + + it("names no surviving cache when the absent binary registered no marketplace", async () => { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: [], + pluginRefs: [REF], + }); + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest, + fs, + logger, + activators: new Map(), + registry: seedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + "codex: registration left in place, the codex CLI is not on the PATH.", + ]); + }); + + it("names every surviving cache of an absent binary, one path per marketplace", async () => { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: [ + { alias: "mkt-a", hostName: "mkt-a" }, + { alias: "mkt-b", hostName: "mkt-b" }, + ], + pluginRefs: [], + }); + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + manifest, + fs, + logger, + activators: new Map(), + registry: seedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.warnMessages).toStrictEqual([ + `codex: registration left in place, the codex CLI is not on the PATH. Its cache survives at: ${join(CODEX_CACHE, "mkt-a")}, ${join(CODEX_CACHE, "mkt-b")}.`, + ]); + }); + }); + + describe("the scope the manifest recorded for a plugin ref, at cursor, the one host admitting a user-scope record", () => { + const CURSOR_REF = "aidd-context@aidd-framework"; + + function seedCursorManifest( + marketplaces: ReadonlyArray<{ alias: string; hostName: string }>, + plugins: ReadonlyArray<{ name: string; scope: "project" | "user"; marketplace: string }> + ): Manifest { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + for (const plugin of plugins) { + manifest.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + plugin.name, + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + plugin.scope, + plugin.marketplace + ) + ); + } + manifest.setNativeRegistrations("cursor", { + binary: "cursor", + marketplaces: [...marketplaces], + pluginRefs: [CURSOR_REF], + }); + return manifest; + } + + async function uninstallScopesFor(manifest: Manifest): Promise { + const fs = new InMemoryFileAdapter(); + const activator = new FakeNativePluginActivator({ + available: true, + installedAtScope: new Map([[CURSOR_REF, "user"]]), + }); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([["cursor", activator]]), + seedMarketplaceRegistry() + ); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + return activator.uninstalledPluginScopes; + } + + it("tries the user scope first when that is what the manifest recorded for the plugin behind the ref, found by its own alias among several", async () => { + const scopes = await uninstallScopesFor( + seedCursorManifest( + [ + { alias: "other", hostName: "other-host" }, + { alias: MARKETPLACE, hostName: MARKETPLACE }, + ], + [ + { name: "other-plugin", scope: "project", marketplace: MARKETPLACE }, + { name: "aidd-context", scope: "user", marketplace: MARKETPLACE }, + ] + ) + ); + + expect(scopes).toStrictEqual(["user"]); + }); + + it("tries the project scope first when the plugin's alias matches no recorded marketplace", async () => { + const scopes = await uninstallScopesFor( + seedCursorManifest( + [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + [{ name: "aidd-context", scope: "user", marketplace: "unknown-alias" }] + ) + ); + + expect(scopes).toStrictEqual(["project", "user"]); + }); + + it("tries the project scope first when the manifest records no plugin at all for the ref", async () => { + const scopes = await uninstallScopesFor( + seedCursorManifest([{ alias: MARKETPLACE, hostName: MARKETPLACE }], []) + ); + + expect(scopes).toStrictEqual(["project", "user"]); + }); + }); + + it("previews no shared-source fact when the references port is wired in but no marketplace registry is", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(seedManifestWithNativeRegistrations(), PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map(), + undefined, + undefined, + new Map(), + () => "/fake-home", + new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd") + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(result).toStrictEqual({ + dryRun: true, + manifestFound: true, + preview: { + tools: [{ toolId: "codex", fileCount: 0 }], + totalFileCount: 0, + nativeRegistrations: [ + { + toolId: "codex", + binary: BINARY, + marketplaceCount: 1, + pluginRefCount: 1, + cachePaths: [join("/fake-home", ".codex", "plugins", "cache", MARKETPLACE)], + }, + ], + sharedSourceOtherProjects: undefined, + }, + fileCount: 0, + }); + }); }); describe("machine-local files a tool's own materialization writes outside the manifest", () => { @@ -1202,8 +1655,452 @@ describe("clean", () => { expect(logger.warnMessages.some((m) => m.includes(PLUGIN_KEY))).toBe(true); }); }); + + describe("without a manifest", () => { + it("reports nothing found and nothing to preview", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(null, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result).toStrictEqual({ + dryRun: false, + manifestFound: false, + preview: { tools: [], totalFileCount: 0, nativeRegistrations: [] }, + fileCount: 0, + }); + }); + }); + + describe("confirming before removing anything", () => { + function buildConfirmingUseCase( + fs: InMemoryFileAdapter, + prompter: RecordingPrompter | undefined + ) { + return new CleanUseCase( + fs, + new InMemoryManifestRepository(seedTrackedFiles(fs, ["a.md"]), PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map(), + undefined, + prompter + ); + } + + it("asks its one question, word for word, and removes everything on yes", async () => { + const fs = new InMemoryFileAdapter(); + const prompter = new RecordingPrompter(true); + const useCase = buildConfirmingUseCase(fs, prompter); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + }); + + expect(prompter.confirmMessages).toStrictEqual(["Remove all AIDD files?"]); + expect(result).toStrictEqual({ + dryRun: false, + manifestFound: true, + preview: previewOf([["claude", 1]]), + fileCount: 1, + }); + expect(fs.has(join(PROJECT_ROOT, "a.md"))).toBe(false); + }); + + it("removes nothing on no and answers with the dry-run preview", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildConfirmingUseCase(fs, new RecordingPrompter(false)); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + }); + + expect(result).toStrictEqual({ + dryRun: true, + manifestFound: true, + preview: previewOf([["claude", 1]]), + fileCount: 0, + }); + expect(fs.has(join(PROJECT_ROOT, "a.md"))).toBe(true); + }); + + it("never asks outside an interactive run, even with a prompter wired in", async () => { + const fs = new InMemoryFileAdapter(); + const prompter = new RecordingPrompter(true); + const useCase = buildConfirmingUseCase(fs, prompter); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(prompter.confirmMessages).toStrictEqual([]); + expect(result).toStrictEqual({ + dryRun: true, + manifestFound: true, + preview: previewOf([["claude", 1]]), + fileCount: 0, + }); + }); + + it("stays a dry-run in an interactive run with no prompter wired in", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildConfirmingUseCase(fs, undefined); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + }); + + expect(result.dryRun).toBe(true); + expect(fs.has(join(PROJECT_ROOT, "a.md"))).toBe(true); + }); + }); + + describe("what a dry-run previews", () => { + it("counts each tool's tracked and merged files, and totals them", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = seedTrackedFiles(fs, ["a.md", "b.md"]); + manifest.addTool( + "claude", + "1.0.0", + [fileEntry("a.md"), fileEntry("b.md")], + [{ relativePath: ".claude/settings.json", sectionKey: null, entries: { owned: hash() } }] + ); + manifest.addTool("codex", "1.0.0", [fileEntry("c.md")]); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(result).toStrictEqual({ + dryRun: true, + manifestFound: true, + preview: previewOf([ + ["claude", 3], + ["codex", 1], + ]), + fileCount: 0, + }); + }); + + it("announces no cache path for a host whose profile declares no cache directory (copilot)", async () => { + const manifest = Manifest.create(); + manifest.addTool("copilot", "1.0.0", []); + manifest.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + pluginRefs: ["aidd-context@aidd-framework"], + }); + const fs = new InMemoryFileAdapter(); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(result.preview.nativeRegistrations).toStrictEqual([ + { + toolId: "copilot", + binary: "copilot", + marketplaceCount: 1, + pluginRefCount: 1, + cachePaths: [], + }, + ]); + }); + + it("announces no cache path for a tool that drives no native CLI (cursor)", async () => { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.setNativeRegistrations("cursor", { + binary: "cursor", + marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + pluginRefs: [], + }); + const fs = new InMemoryFileAdapter(); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: false }); + + expect(result.preview.nativeRegistrations).toStrictEqual([ + { + toolId: "cursor", + binary: "cursor", + marketplaceCount: 1, + pluginRefCount: 0, + cachePaths: [], + }, + ]); + }); + }); + + describe("counting what was removed", () => { + function buildCountingUseCase( + fs: InMemoryFileAdapter, + manifest: Manifest, + logger = new CapturingLogger() + ) { + return new CleanUseCase( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + logger, + new GitignoreUseCase(fs) + ); + } + + it("counts every tracked file and names the tool being removed", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = buildCountingUseCase(fs, seedTrackedFiles(fs, ["a.md", "b.md"]), logger); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.fileCount).toBe(2); + expect(fs.has(join(PROJECT_ROOT, "a.md"))).toBe(false); + expect(fs.has(join(PROJECT_ROOT, "b.md"))).toBe(false); + expect(logger.infoMessages).toStrictEqual(["Removing claude files..."]); + }); + + it("counts the machine-local settings file a tool wrote outside the manifest", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, ".claude", "settings.local.json"), "{}"); + const useCase = buildCountingUseCase(fs, seedTrackedFiles(fs, ["a.md"])); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.fileCount).toBe(2); + }); + + it("does not count a machine-local settings file that was never written", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildCountingUseCase(fs, seedTrackedFiles(fs, ["a.md"])); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.fileCount).toBe(1); + }); + + it("counts the project hooks file a cursor plugin was unmerged from", async () => { + const pluginName = "aidd-context"; + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + pluginName, + "1.0.0", + { kind: "local", path: "/p" }, + false, + "project" + ) + ); + const fs = new InMemoryFileAdapter(); + const scriptMarker = cursorProjectHooksScriptDir(pluginName); + fs.setFile( + join(PROJECT_ROOT, ".cursor", "hooks.json"), + JSON.stringify({ + version: 1, + hooks: { PreToolUse: [{ command: `./${scriptMarker}run.js` }] }, + }) + ); + const useCase = buildCountingUseCase(fs, manifest); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.fileCount).toBe(1); + }); + + it("deletes a project-scope plugin's files from the project and counts them", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "aidd-context", + "1.0.0", + { kind: "local", path: "/p" }, + false, + "project" + ).withFiles(new Map([["skills/x.md", HASH]])) + ); + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "skills", "x.md"), "x"); + const useCase = buildCountingUseCase(fs, manifest); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.has(join(PROJECT_ROOT, "skills", "x.md"))).toBe(false); + expect(result.fileCount).toBe(1); + }); + + describe("a file merged into one the project shares", () => { + const MERGED = ".claude/settings.json"; + + function seedMerged( + fs: InMemoryFileAdapter, + content: string | null, + ownedKeys: string[] + ): Manifest { + const manifest = Manifest.create(); + const entries: Record = {}; + for (const key of ownedKeys) entries[key] = hash(); + manifest.addTool( + "claude", + "1.0.0", + [], + [{ relativePath: MERGED, sectionKey: null, entries }] + ); + if (content !== null) fs.setFile(join(PROJECT_ROOT, MERGED), content); + return manifest; + } + + it("takes back only its own keys, leaving the project's, and counts the file once", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildCountingUseCase( + fs, + seedMerged(fs, '{"owned":1,"theirs":2}', ["owned"]) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.getFile(join(PROJECT_ROOT, MERGED))).toBe('{\n "theirs": 2\n}'); + expect(result.fileCount).toBe(1); + }); + + it("deletes the file once nothing but its own keys was in it", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildCountingUseCase(fs, seedMerged(fs, '{"owned":1}', ["owned"])); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.has(join(PROJECT_ROOT, MERGED))).toBe(false); + expect(result.fileCount).toBe(1); + }); + + it("deletes a file it recorded whole, one with no keys of its own", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildCountingUseCase(fs, seedMerged(fs, '{"theirs":2}', [])); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.has(join(PROJECT_ROOT, MERGED))).toBe(false); + expect(result.fileCount).toBe(1); + }); + + it("skips a merged file already gone, without counting it", async () => { + const fs = new InMemoryFileAdapter(); + const useCase = buildCountingUseCase(fs, seedMerged(fs, null, ["owned"])); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.fileCount).toBe(0); + }); + }); + }); + + describe("what stays under .aidd/", () => { + it("says it kept config.json, by its path", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, ".aidd", "config.json"), "{}"); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(seedTrackedFiles(fs, ["a.md"]), PROJECT_ROOT), + logger, + new GitignoreUseCase(fs) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.infoMessages).toStrictEqual([ + "Removing claude files...", + "Kept .aidd/config.json", + ]); + }); + + it("says nothing about config.json when another file is what keeps .aidd/ alive", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, ".aidd", "auth.json"), "{}"); + const logger = new CapturingLogger(); + const useCase = new CleanUseCase( + fs, + new InMemoryManifestRepository(seedTrackedFiles(fs, ["a.md"]), PROJECT_ROOT), + logger, + new GitignoreUseCase(fs) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(logger.infoMessages).toStrictEqual(["Removing claude files..."]); + }); + }); }); +const HASH = "abc123abc123abc123abc123abc123ab"; + +function hash(): FileHash { + return new FileHash(HASH); +} + +function fileEntry(relativePath: string): InstallationFile { + return new InstallationFile({ relativePath, content: "", hash: hash() }); +} + +function seedTrackedFiles(fs: InMemoryFileAdapter, paths: readonly string[]): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", paths.map(fileEntry)); + for (const path of paths) fs.setFile(join(PROJECT_ROOT, path), "x"); + return manifest; +} + +function previewOf(tools: ReadonlyArray) { + return { + tools: tools.map(([toolId, fileCount]) => ({ toolId, fileCount })), + totalFileCount: tools.reduce((sum, [, fileCount]) => sum + fileCount, 0), + nativeRegistrations: [], + sharedSourceOtherProjects: undefined, + }; +} + +class CrashingUninstallActivator implements NativePluginActivator { + isAvailable(): boolean { + return true; + } + addMarketplace(): void {} + enablesPlugins(): boolean { + return false; + } + removeMarketplace(): void {} + registrationState(): "live" | "dead" | "unknown" { + return "live"; + } + upgradeMarketplaces(): void {} + enablePlugin(): void {} + uninstallPlugin(): void { + throw new Error("activator crashed uninstalling a plugin"); + } +} + /** Records `uninstallPlugin`/`removeMarketplace` calls in the order they happen, so a test * can assert one came before the other. */ class OrderRecordingActivator implements NativePluginActivator { diff --git a/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts b/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts index 5a5ee8a05..e1fd62d06 100644 --- a/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts +++ b/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts @@ -189,6 +189,32 @@ describe("clean guards a ref another project on this machine still needs", () => expect(activator.uninstalledPlugins).toContain("plugin-b@other-mkt"); }); + it("still disables a ref from another marketplace when that marketplace is recorded before the shared source", async () => { + const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); + seedReferences(fs, [OTHER_PROJECT]); + const activator = new FakeNativePluginActivator({ available: true }); + + const useCase = buildUseCase({ + fs, + manifest: seedManifest( + "codex", + [ + { alias: "other-mkt", hostName: "other-mkt" }, + { alias: "aidd-framework", hostName: "aidd-framework" }, + ], + ["plugin-b@other-mkt", "aidd-vcs@aidd-framework"] + ), + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toStrictEqual(["plugin-b@other-mkt"]); + }); + it("disables codex's ref when no claim was ever recorded for this project, and no other project references it either", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); // No references.json at all: no claim of this project's own to drop, and nothing else diff --git a/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts b/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts index 2a483db83..78c203f75 100644 --- a/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts @@ -1,6 +1,8 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { FRAMEWORK_MARKETPLACE_NAME, @@ -11,16 +13,27 @@ import { Manifest } from "../../../../../src/contexts/framework/domain/manifest. import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { UserSourceReferencesAdapter } from "../../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; import type { NativePluginActivator } from "../../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; -import type { Prompter } from "../../../../../src/kernel/ports/prompter.js"; import type { MarketplaceScope } from "../../../../../src/kernel/scope.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { RecordingPrompter } from "../../../../helpers/ports/recording-prompter.js"; const USER_CONFIG_DIR = "/fake-home/.config/aidd"; const HOME = "/fake-home"; +const CLAUDE_CACHE = join(HOME, ".claude", "plugins", "cache"); +const CODEX_CACHE = join(HOME, ".codex", "plugins", "cache"); +const WHITELIST_PURGE_MESSAGES = [ + `user scope: cache/built purged: ${join(USER_CONFIG_DIR, "cache", "built")}`, + `user scope: cache/update-check.json purged: ${join(USER_CONFIG_DIR, "cache", "update-check.json")}`, + `user scope: cache purged: ${join(USER_CONFIG_DIR, "cache")}`, + `user scope: update-check.json purged: ${join(USER_CONFIG_DIR, "update-check.json")}`, + `user scope: references.json purged: ${join(USER_CONFIG_DIR, "references.json")}`, +]; /** Records every delete in order, so an ordering constraint can be proved without reading * the use case's own private state. A shared array correlates with another recorder. */ @@ -80,31 +93,19 @@ class RecordingActivator implements NativePluginActivator { } } -/** Answers a fixed way and keeps the message, so the confirmation's exact wording and the - * "answered no" branch can both be pinned. */ -class RecordingPrompter implements Prompter { - lastConfirmMessage: string | undefined; - - constructor(private readonly answer: boolean) {} - - async confirm(message: string): Promise { - this.lastConfirmMessage = message; - return this.answer; - } - async resolveConflict(): Promise<"keep" | "overwrite"> { - return "keep"; - } - async resolveConflictBulk(): Promise<"keep" | "overwrite" | "overwrite-all" | "skip-all"> { - return "keep"; - } - async input(): Promise { - return ""; - } - async select(): Promise { - throw new Error("not implemented"); +class StrictListingFileAdapter extends InMemoryFileAdapter { + constructor(private readonly deniedDir?: string) { + super(); } - async checkbox(): Promise { - return []; + + override async listDirectory(dirPath: string): Promise { + if (dirPath === this.deniedDir) { + throw Object.assign(new Error("permission denied"), { code: "EACCES" }); + } + if (!(await this.fileExists(dirPath))) { + throw Object.assign(new Error(`ENOENT: no such directory, ${dirPath}`), { code: "ENOENT" }); + } + return super.listDirectory(dirPath); } } @@ -537,5 +538,509 @@ describe("clean --scope user", () => { expect(manifestRepo.getCurrent()).not.toBeNull(); expect(fs.order).toEqual([]); }); + + it("never asks outside an interactive run, even with a prompter wired in", async () => { + const fs = new RecordingFileAdapter(); + const prompter = new RecordingPrompter(true); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + undefined, + prompter + ); + + const result = await useCase.execute({ projectRoot: "/wherever", force: false }); + + expect(prompter.confirmMessages).toStrictEqual([]); + expect(result.dryRun).toBe(true); + expect(fs.order).toStrictEqual([]); + }); + + it("names every built version and every referencing project in its question", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(USER_CONFIG_DIR, "cache", "built", "2.0.0", "aidd-framework", "x"), "1"); + fs.setFile(join(USER_CONFIG_DIR, "cache", "built", "1.2.3", "aidd-framework", "x"), "1"); + fs.setFile("/project-a/marker", ""); + fs.setFile("/project-b/marker", ""); + const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); + await userSourceReferences.addReference("1.2.3", "/project-a"); + await userSourceReferences.addReference("2.0.0", "/project-b"); + const prompter = new RecordingPrompter(false); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + userSourceReferences, + prompter + ); + + await useCase.execute({ projectRoot: "/wherever", force: false, interactive: true }); + + expect(prompter.confirmMessages).toStrictEqual([ + "Remove the shared 'aidd-framework' source for this machine " + + "(versions: 1.2.3, 2.0.0)? Still referenced by: /project-a, /project-b.", + ]); + }); + }); + + describe("what the run reports", () => { + function buildClaudeUseCase(fs: InMemoryFileAdapter, logger: CapturingLogger) { + return new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(manifestWithClaude()), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["claude", new FakeNativePluginActivator({ available: true })]]), + new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ + location: "known_marketplaces.json", + entries: new Map(), + }), + ], + ]), + () => HOME + ); + } + + it("reports the manifest found and the tools it named, and purges the cache the host forgot", async () => { + const fs = new InMemoryFileAdapter(); + const claudeCachePath = seedClaudeCache(fs); + const useCase = buildClaudeUseCase(fs, new CapturingLogger()); + + const result = await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(result).toStrictEqual({ + dryRun: false, + manifestFound: true, + preview: { toolIds: ["claude"], builtVersions: [], referencingProjects: [] }, + }); + expect(await fs.fileExists(join(claudeCachePath, "marker.json"))).toBe(false); + }); + + it("names each purge, by its label and path, and nothing about a missing user registration", async () => { + const fs = new InMemoryFileAdapter(); + seedClaudeCache(fs); + const logger = new CapturingLogger(); + const useCase = buildClaudeUseCase(fs, logger); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(logger.infoMessages).toStrictEqual([ + `claude: cache for 'aidd-framework' purged: ${join(CLAUDE_CACHE, "aidd-framework")}`, + ...WHITELIST_PURGE_MESSAGES, + ]); + }); + + it("reports no tool, and says plainly that nothing was registered, when no user manifest exists", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(null), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME + ); + + const result = await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(result).toStrictEqual({ + dryRun: false, + manifestFound: false, + preview: { toolIds: [], builtVersions: [], referencingProjects: [] }, + }); + expect(logger.infoMessages).toStrictEqual([ + "No host registration was undone: nothing was registered at user scope.", + ...WHITELIST_PURGE_MESSAGES, + ]); + }); + + it("names every referencing project, comma separated, when nothing was registered here", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile("/project-a/marker", ""); + fs.setFile("/project-b/marker", ""); + const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); + await userSourceReferences.addReference("1.0.0", "/project-a"); + await userSourceReferences.addReference("1.0.0", "/project-b"); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(null), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + userSourceReferences + ); + + await useCase.execute({ projectRoot: "/wherever", force: false }); + + expect(logger.infoMessages).toStrictEqual([ + "No host registration was undone: nothing was registered at user scope. " + + "/project-a, /project-b still resolve the shared source through their own host; " + + "full removal is `aidd clean` in each of them, then `aidd clean --scope user`.", + ]); + }); + + it("reports no referencing project, and warns, when references.json cannot be read", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(USER_CONFIG_DIR, "references.json"), "not json"); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME, + new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR) + ); + + const result = await useCase.execute({ projectRoot: "/wherever", force: false }); + + expect(result.preview.referencingProjects).toStrictEqual([]); + expect(logger.warnMessages.some((m) => m.includes("references.json"))).toBe(true); + }); + }); + + describe("the versions built on this machine", () => { + function buildPreviewUseCase(fs: InMemoryFileAdapter) { + return new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + } + + it("lists them sorted, whatever order the cache holds them in", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(USER_CONFIG_DIR, "cache", "built", "2.0.0", "aidd-framework", "x"), "1"); + fs.setFile(join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "aidd-framework", "x"), "1"); + + const result = await buildPreviewUseCase(fs).execute({ + projectRoot: "/wherever", + force: false, + }); + + expect(result.preview.builtVersions).toStrictEqual(["1.0.0", "2.0.0"]); + }); + + it("lists none when the built cache root does not exist", async () => { + const fs = new StrictListingFileAdapter(); + + const result = await buildPreviewUseCase(fs).execute({ + projectRoot: "/wherever", + force: false, + }); + + expect(result.preview.builtVersions).toStrictEqual([]); + }); + + it("propagates a built cache root that exists but cannot be read", async () => { + const fs = new StrictListingFileAdapter(join(USER_CONFIG_DIR, "cache", "built")); + + await expect( + buildPreviewUseCase(fs).execute({ projectRoot: "/wherever", force: false }) + ).rejects.toThrow("permission denied"); + }); + }); + + describe("driving the host CLI", () => { + function buildUseCase(deps: { + fs: InMemoryFileAdapter; + logger: CapturingLogger; + manifest?: Manifest; + binary?: string; + activator: NativePluginActivator; + }) { + return new CleanUserScopeUseCase( + deps.fs, + new InMemoryManifestRepository(deps.manifest ?? manifestWithClaude()), + deps.logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([[deps.binary ?? "claude", deps.activator]]), + new Map(), + () => HOME + ); + } + + it("uninstalls every plugin ref at the user scope", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs: new InMemoryFileAdapter(), + logger: new CapturingLogger(), + activator, + }); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(activator.uninstalledPlugins).toStrictEqual(["aidd-context@aidd-framework"]); + expect(activator.uninstalledPluginScopes).toStrictEqual(["user"]); + }); + + it("names a refused plugin uninstall by the host's own words and still unregisters the marketplace", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + failOnUninstall: ["aidd-context@aidd-framework"], + }); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ fs: new InMemoryFileAdapter(), logger, activator }); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(logger.warnMessages).toStrictEqual([ + "claude plugin uninstall 'aidd-context@aidd-framework' failed: plugin `aidd-context@aidd-framework` is not installed", + ]); + expect(activator.removedMarketplaces).toStrictEqual(["aidd-framework"]); + }); + + it("unregisters every marketplace at the user scope", async () => { + const activator = new RecordingActivator([]); + const useCase = buildUseCase({ + fs: new InMemoryFileAdapter(), + logger: new CapturingLogger(), + activator, + }); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(activator.removedMarketplaces).toStrictEqual(["aidd-framework"]); + expect(activator.removedMarketplaceScopes).toStrictEqual(["user"]); + }); + + it("names a refused marketplace removal by the host's own words", async () => { + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs: new InMemoryFileAdapter(), + logger, + activator: new FakeNativePluginActivator({ available: true, throwOnRemove: true }), + }); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(logger.warnMessages).toStrictEqual([ + "claude marketplace remove 'aidd-framework' failed: marketplace remove aidd-framework failed: 'aidd-framework' is not configured or installed", + `claude: cache for 'aidd-framework' left in place, its own removal was not confirmed: ${join(CLAUDE_CACHE, "aidd-framework")}`, + ]); + }); + + it("never treats a refused removal as one the host confirmed, so codex's cache stays", async () => { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + pluginRefs: [], + }); + const fs = new InMemoryFileAdapter(); + const leftover = join(CODEX_CACHE, "aidd-framework", "leftover.json"); + fs.setFile(leftover, "{}"); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + logger, + manifest, + binary: "codex", + activator: new FakeNativePluginActivator({ available: true, throwOnRemove: true }), + }); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(fs.has(leftover)).toBe(true); + expect(logger.warnMessages).toStrictEqual([ + "codex marketplace remove 'aidd-framework' failed: marketplace remove aidd-framework failed: 'aidd-framework' is not configured or installed", + `codex: cache for 'aidd-framework' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, "aidd-framework")}`, + ]); + }); + }); + + describe("what an absent binary is said to leave standing", () => { + function seedRegistrations( + toolId: ToolId, + marketplaces: ReadonlyArray<{ alias: string; hostName: string }>, + pluginRefs: readonly string[] + ): Manifest { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", []); + manifest.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [...marketplaces], + pluginRefs: [...pluginRefs], + }); + return manifest; + } + + async function warningsFor(manifest: Manifest): Promise { + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + new InMemoryFileAdapter(), + new InMemoryManifestRepository(manifest), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME + ); + await useCase.execute({ projectRoot: "/wherever", force: true }); + return logger.warnMessages; + } + + it("names no cache for a host whose profile declares no cache directory (copilot)", async () => { + const warnings = await warningsFor( + seedRegistrations( + "copilot", + [{ alias: "aidd-framework", hostName: "aidd-framework" }], + ["aidd-context@aidd-framework"] + ) + ); + + expect(warnings).toStrictEqual([ + "copilot: registration left in place, the copilot CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 1 plugin ref(s).", + ]); + }); + + it("names no cache for a tool that drives no native CLI (cursor)", async () => { + const warnings = await warningsFor( + seedRegistrations("cursor", [{ alias: "aidd-framework", hostName: "aidd-framework" }], []) + ); + + expect(warnings).toStrictEqual([ + "cursor: registration left in place, the cursor CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 0 plugin ref(s).", + ]); + }); + + it("names no cache when the binary registered no marketplace", async () => { + const warnings = await warningsFor( + seedRegistrations("codex", [], ["aidd-context@aidd-framework"]) + ); + + expect(warnings).toStrictEqual([ + "codex: registration left in place, the codex CLI is not on the PATH. It would have unregistered 0 marketplace(s) and 1 plugin ref(s).", + ]); + }); + + it("names every surviving cache, one path per marketplace", async () => { + const warnings = await warningsFor( + seedRegistrations( + "codex", + [ + { alias: "mkt-a", hostName: "mkt-a" }, + { alias: "mkt-b", hostName: "mkt-b" }, + ], + [] + ) + ); + + expect(warnings).toStrictEqual([ + `codex: registration left in place, the codex CLI is not on the PATH. It would have unregistered 2 marketplace(s) and 0 plugin ref(s). Its cache survives at: ${join(CODEX_CACHE, "mkt-a")}, ${join(CODEX_CACHE, "mkt-b")}.`, + ]); + }); + }); + + describe("a plugin's own files", () => { + it("leaves a project-scope plugin's files alone, this run owning no project", async () => { + const fs = new RecordingFileAdapter(); + const projectFile = join("/wherever", "skills", "x.md"); + fs.setFile(projectFile, "x"); + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "aidd-context", + "1.0.0", + { kind: "local", path: "/whatever" }, + false, + "project" + ).withFiles(new Map([["skills/x.md", "hash"]])) + ); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(manifest), + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + () => HOME + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(fs.has(projectFile)).toBe(true); + expect(fs.order).not.toContain(`deleteFile:${projectFile}`); + }); + }); + + describe("the cache/ shell around the whitelist", () => { + it("leaves cache/ and everything under it in place, naming each, once cache/ resolves outside userConfigDir()", async () => { + const fs = new RecordingFileAdapter(); + const cacheDir = join(USER_CONFIG_DIR, "cache"); + fs.setFile(join(cacheDir, "built", "1.0.0", "aidd-framework", "x"), "1"); + fs.setSymlink(cacheDir, "/elsewhere/cache"); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(fs.has(join(cacheDir, "built", "1.0.0", "aidd-framework", "x"))).toBe(true); + expect(logger.warnMessages).toStrictEqual([ + `user scope: cache/built does not resolve inside ${USER_CONFIG_DIR}; left in place: ${join(cacheDir, "built")}`, + `user scope: cache/update-check.json does not resolve inside ${USER_CONFIG_DIR}; left in place: ${join(cacheDir, "update-check.json")}`, + `user scope: cache does not resolve inside ${USER_CONFIG_DIR}; left in place: ${cacheDir}`, + ]); + }); + + it("keeps the shell while something this whitelist never named still lives in it", async () => { + const fs = new RecordingFileAdapter(); + const other = join(USER_CONFIG_DIR, "cache", "other.json"); + fs.setFile(other, "{}"); + const logger = new CapturingLogger(); + const useCase = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + + await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(fs.has(other)).toBe(true); + expect(logger.infoMessages).toStrictEqual( + WHITELIST_PURGE_MESSAGES.filter((m) => !m.startsWith("user scope: cache purged")) + ); + }); }); }); From cc00161f3096b0bf4fb81acec930bee3e9636fa5 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:35:05 +0200 Subject: [PATCH 07/12] test(cli): kill the surviving mutants of the status and doctor use cases Status, status all, doctor, doctor all, registration, references, tracked files, merge files, layout and plugin checks: 65 tests, each shown red first against the mutant it names. Framework mutation score: 72.2 before the series, 95.4 after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- ...or-marketplace-sources.integration.test.ts | 84 +++++- .../application/doctor-plugin.unit.test.ts | 30 +++ .../doctor-registration.unit.test.ts | 108 ++++++-- .../application/doctor-use-case.unit.test.ts | 61 +++++ .../doctor-layout-use-case.unit.test.ts | 80 ++++++ .../doctor-merge-files-use-case.unit.test.ts | 84 ++++++ .../doctor-references-use-case.unit.test.ts | 181 +++++++++++++ ...doctor-tracked-files-use-case.unit.test.ts | 91 +++++++ .../global/doctor-all-use-case.unit.test.ts | 138 +++++++++- .../status-all-use-case.unit.test.ts | 39 ++- .../application/status-use-case.unit.test.ts | 253 ++++++++++++++++++ 11 files changed, 1107 insertions(+), 42 deletions(-) create mode 100644 cli/tests/contexts/framework/application/doctor/doctor-layout-use-case.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/doctor/doctor-merge-files-use-case.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/doctor/doctor-references-use-case.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/doctor/doctor-tracked-files-use-case.unit.test.ts diff --git a/cli/tests/contexts/framework/application/doctor-marketplace-sources.integration.test.ts b/cli/tests/contexts/framework/application/doctor-marketplace-sources.integration.test.ts index 5c68a2241..aa0a6fcf4 100644 --- a/cli/tests/contexts/framework/application/doctor-marketplace-sources.integration.test.ts +++ b/cli/tests/contexts/framework/application/doctor-marketplace-sources.integration.test.ts @@ -1,6 +1,7 @@ import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; import { DoctorRegistrationUseCase } from "../../../../src/contexts/framework/application/doctor/doctor-registration-use-case.js"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; @@ -255,6 +256,78 @@ describe("DoctorRegistrationUseCase — marketplace source conflicts", () => { expect(await issuesFor(hostReader, { fs })).toEqual([]); }); + it("reports nothing, and does not throw, when no reader exists for the tool's marketplace registry", async () => { + const fs = new InMemoryFileAdapter({ + [`${expectedBuiltDir()}/${CATALOG_RELATIVE}`]: JSON.stringify({ name: NAME, plugins: [] }), + }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: NAME, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const useCase = new DoctorRegistrationUseCase( + fs, + registry, + new Map(), + new Map(), + new Map(), + () => "/user-cache", + fakeVersion("1.0.0") + ); + + await expect( + useCase.execute({ manifest, projectRoot: PROJECT_ROOT, allowedIds: null }) + ).resolves.toStrictEqual([]); + }); + + it("reports nothing for a tool whose profile declares no marketplace registry, whatever a reader would answer", async () => { + const codexCatalog = ".agents/plugins/marketplace.json"; + const fs = new InMemoryFileAdapter({ + [`${resolve(builtMarketplaceDir(PROJECT_ROOT, NAME, "codex"))}/${codexCatalog}`]: + JSON.stringify({ name: NAME, plugins: [{ name: "sample-plugin" }] }), + [`/other/src/${codexCatalog}`]: JSON.stringify({ + name: NAME, + plugins: [{ name: "different-plugin" }], + }), + }); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: NAME, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("codex", "test", []); + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([[NAME, "/other/src"]]), + }); + const useCase = new DoctorRegistrationUseCase( + fs, + registry, + new Map(), + new Map(), + new Map([["codex", hostReader]]), + () => "/user-cache", + fakeVersion("1.0.0") + ); + + const issues = await useCase.execute({ manifest, projectRoot: PROJECT_ROOT, allowedIds: null }); + + expect(issues).toStrictEqual([]); + }); + it("reads the host registry once per marketplace, the same cadence the sync-time guard uses — not once per tool, reused across every marketplace that tool has", async () => { const SECOND_NAME = "probe-mkt-2"; const hostReader = new FakeHostMarketplaceRegistryReader({ @@ -371,10 +444,13 @@ describe("DoctorRegistrationUseCase — user-scope marketplace source drift", () const issues = await driftIssuesFor(hostReader); - expect(issues).toHaveLength(1); - expect(issues[0]?.severity).toBe("warning"); - expect(issues[0]?.message).toContain(projectCache); - expect(issues[0]?.fix).toContain("aidd sync"); + expect(issues).toStrictEqual([ + { + severity: "warning", + message: `claude's marketplace registry (${REGISTRY_LOCATION}) still carries '${NAME}' from this project's own pre-migration cache (${projectCache})`, + fix: "Run `aidd sync` to move it to the shared, machine-scope source.", + }, + ]); }); it("warns naming `aidd sync` when the host still points at another project's pre-migration cache", async () => { diff --git a/cli/tests/contexts/framework/application/doctor-plugin.unit.test.ts b/cli/tests/contexts/framework/application/doctor-plugin.unit.test.ts index a8e0bc081..93e15f3a1 100644 --- a/cli/tests/contexts/framework/application/doctor-plugin.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-plugin.unit.test.ts @@ -140,6 +140,36 @@ describe("DoctorUseCase — plugin integrity", () => { }); }); + describe("when narrowed to a set of tools", () => { + it("ignores the plugins of a tool outside the set", async () => { + const fs = makeFs(false, EXPECTED_HASH); + const useCase = new DoctorPluginUseCase(new DetectPluginDriftUseCase(fs)); + + const issues = await useCase.execute({ + manifest: makeManifest(EXPECTED_HASH), + projectRoot: "/proj", + allowedIds: new Set(["cursor"]), + }); + + expect(issues).toStrictEqual([]); + }); + + it("still checks the plugins of a tool inside the set", async () => { + const fs = makeFs(false, EXPECTED_HASH); + const useCase = new DoctorPluginUseCase(new DetectPluginDriftUseCase(fs)); + + const issues = await useCase.execute({ + manifest: makeManifest(EXPECTED_HASH), + projectRoot: "/proj", + allowedIds: new Set(["claude"]), + }); + + expect(issues).toStrictEqual([ + { toolId: "claude", pluginName: "my-plugin", issue: "missing", filePath: PLUGIN_FILE }, + ]); + }); + }); + describe("when a user-scope plugin was never installed on this machine", () => { it("reports one not-installed-on-machine issue, not one 'missing' issue per file", async () => { const manifest = Manifest.create(); diff --git a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts index d8bb2019a..43f2dcb4d 100644 --- a/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-registration.unit.test.ts @@ -7,6 +7,7 @@ import type { AiToolId, ToolId } from "../../../../src/kernel/tool.js"; import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import type { HostPluginRegistryReader, HostPluginRegistryReading, @@ -18,17 +19,24 @@ import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-ma const PROJECT_ROOT = "/project"; const LOCAL_SETTINGS = `${PROJECT_ROOT}/.claude/settings.local.json`; +const UNDECLARED = { + severity: "warning", + message: "claude no longer declares marketplace 'aidd-framework'", + fix: "Run `aidd marketplace refresh` to write it back to .claude/settings.local.json.", +}; + +function settingsDeclaring(names: string[]): string { + const entries = Object.fromEntries(names.map((name) => [name, { source: {} }])); + return JSON.stringify({ extraKnownMarketplaces: entries }); +} async function issuesFor( - registered: string[] | null, - toolId: ToolId = "claude", - toolInstalled = true + settings: string | null, + options: { toolId?: ToolId; toolInstalled?: boolean; allowedIds?: Set | null } = {} ) { + const { toolId = "claude", toolInstalled = true, allowedIds = null } = options; const fs = new InMemoryFileAdapter(); - if (registered !== null) { - const entries = Object.fromEntries(registered.map((name) => [name, { source: {} }])); - await fs.writeFile(LOCAL_SETTINGS, JSON.stringify({ extraKnownMarketplaces: entries })); - } + if (settings !== null) await fs.writeFile(LOCAL_SETTINGS, settings); const registry = new InMemoryMarketplaceRegistry(); await registry.save( PROJECT_ROOT, @@ -55,42 +63,66 @@ async function issuesFor( ).execute({ manifest, projectRoot: PROJECT_ROOT, - allowedIds: null, + allowedIds, }); } describe("DoctorRegistrationUseCase", () => { it("says nothing when the tool still declares the marketplace", async () => { - expect(await issuesFor(["aidd-framework"])).toEqual([]); + expect(await issuesFor(settingsDeclaring(["aidd-framework"]))).toStrictEqual([]); + }); + + it("accepts the array form of the declaration", async () => { + const settings = JSON.stringify({ extraKnownMarketplaces: ["aidd-framework"] }); + expect(await issuesFor(settings)).toStrictEqual([]); }); it("reports the marketplace the file no longer declares", async () => { - const issues = await issuesFor([]); - expect(issues).toHaveLength(1); - expect(issues[0].message).toContain("aidd-framework"); - expect(issues[0].fix).toContain(".claude/settings.local.json"); + expect(await issuesFor(settingsDeclaring([]))).toStrictEqual([UNDECLARED]); }); it("reports it when the whole file is gone — nothing else would notice", async () => { - const issues = await issuesFor(null); - expect(issues).toHaveLength(1); - expect(issues[0].severity).toBe("warning"); + expect(await issuesFor(null)).toStrictEqual([UNDECLARED]); + }); + + it("reports it when the file carries no declarations key at all", async () => { + expect(await issuesFor("{}")).toStrictEqual([UNDECLARED]); + }); + + it("reports it, without throwing, when the file holds a JSON null", async () => { + await expect(issuesFor("null")).resolves.toStrictEqual([UNDECLARED]); }); // The registration is written by the tool itself, so it cannot exist while the tool does not: // reporting it missing would be reporting that something uninstalled is unconfigured. it("says nothing about a tool whose binary is out of reach", async () => { - expect(await issuesFor(null, "claude", false)).toEqual([]); + expect(await issuesFor(null, { toolInstalled: false })).toStrictEqual([]); }); it("stays silent for a tool that keeps its registrations in a tracked file", async () => { - expect(await issuesFor(null, "cursor")).toEqual([]); + expect(await issuesFor(null, { toolId: "cursor" })).toStrictEqual([]); }); // Copilot declares no place at all rather than a path, and a guard rejecting only `undefined` // lets `null` reach `join(root, null)`, which throws and takes `plugin doctor` down. it("stays silent, and does not throw, for a tool that declares no place at all", async () => { - await expect(issuesFor(null, "copilot")).resolves.toEqual([]); + await expect(issuesFor(null, { toolId: "copilot" })).resolves.toStrictEqual([]); + }); + + it("stays silent, and does not throw, for an IDE tool", async () => { + await expect(issuesFor(null, { toolId: "vscode" })).resolves.toStrictEqual([]); + }); + + describe("narrowed to a set of tools", () => { + it("ignores a tool outside the set", async () => { + expect(await issuesFor(null, { allowedIds: new Set(["cursor"]) })).toStrictEqual([]); + }); + + it("still reports a tool inside the set", async () => { + expect(await issuesFor(null, { allowedIds: new Set(["claude"]) })).toStrictEqual([ + UNDECLARED, + ]); + }); }); }); @@ -109,11 +141,11 @@ function manifestWithNativeRegistrations( return manifest; } -function manifestWithPlugin(marketplace?: string): Manifest { +function manifestWithPlugin(marketplace?: string, toolId: AiToolId = "claude"): Manifest { const manifest = Manifest.create(); - manifest.addTool("claude", "test", []); + manifest.addTool(toolId, "test", []); manifest.addPlugin( - "claude", + toolId, InstalledPlugin.fromMetadata( marketplace === undefined ? "hand-copied" : "aidd-context", "1.0.0", @@ -190,11 +222,16 @@ describe("DoctorRegistrationUseCase — native registrations against the host's expect(issues[0].fix).toContain("aidd framework install --tool claude"); }); - it("reports an info line, never an error, when nothing here can read the registry", async () => { + it("names the registry nobody has measured, never an error, when nothing here can read it", async () => { const issues = await nativeIssuesFor(manifestWithNativeRegistrations([REF]), "unreachable"); - expect(issues).toHaveLength(1); - expect(issues[0].severity).toBe("info"); + expect(issues).toStrictEqual([ + { + severity: "info", + message: "claude keeps a plugin registry, and nothing here has established its shape", + fix: "The plugin does not load until claude's own CLI has run and answered this.", + }, + ]); }); it("reports an info line when the registry file exists but could not be read", async () => { @@ -235,13 +272,28 @@ describe("DoctorRegistrationUseCase — native registrations against the host's expect(issues[0].message).toContain(REF); }); - it("is unanswerable, not an error, for a fallback plugin recording no marketplace", async () => { + it("names the plugin whose marketplace was never recorded, as unanswerable rather than an error", async () => { const issues = await nativeIssuesFor(manifestWithPlugin(undefined), { location: REGISTRY_LOCATION, refs: new Map(), }); - expect(issues).toHaveLength(1); - expect(issues[0].severity).toBe("info"); + expect(issues).toStrictEqual([ + { + severity: "info", + message: + "AIDD records no marketplace for hand-copied (claude), so its registry cannot be asked", + fix: "claude will not load it until a marketplace is recorded for it.", + }, + ]); + }); + + it("says nothing for a tool whose plugins are enabled by a file this CLI writes", async () => { + const issues = await nativeIssuesFor(manifestWithPlugin("aidd-framework", "cursor"), { + location: REGISTRY_LOCATION, + refs: new Map(), + }); + + expect(issues).toStrictEqual([]); }); }); diff --git a/cli/tests/contexts/framework/application/doctor-use-case.unit.test.ts b/cli/tests/contexts/framework/application/doctor-use-case.unit.test.ts index 4820b39a1..61426a1e4 100644 --- a/cli/tests/contexts/framework/application/doctor-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/doctor-use-case.unit.test.ts @@ -4,11 +4,13 @@ import { extractAtReferences, extractMarkdownLinkTargets, } from "../../../../src/contexts/framework/domain/formats/markdown-references.js"; +import type { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import type { ToolId } from "../../../../src/kernel/tool.js"; import { buildDoctorUseCase, buildUnitDeps, initAndInstall, + installTool, } from "../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; @@ -240,4 +242,63 @@ describe("doctor", () => { expect(mergeIssues).toHaveLength(0); }); }); + + describe("tool health", () => { + async function installedBothCategories() { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installTool(deps, PROJECT_ROOT, "vscode"); + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("manifest missing"); + return { deps, manifest }; + } + + function healthOf(manifest: Manifest, toolId: ToolId) { + return { + toolId, + fileCount: manifest.getToolFiles(toolId).length, + mergeFileCount: manifest.getMergeFiles(toolId).length, + }; + } + + it("reports one entry per installed tool with its file and merge-file counts", async () => { + const { deps, manifest } = await installedBothCategories(); + + const report = await buildDoctorUseCase(deps).execute({ projectRoot: PROJECT_ROOT }); + + expect(report.toolHealth).toStrictEqual([ + healthOf(manifest, "claude"), + healthOf(manifest, "vscode"), + ]); + }); + + it("narrows the entries to the requested category", async () => { + const { deps, manifest } = await installedBothCategories(); + + const report = await buildDoctorUseCase(deps).execute({ + projectRoot: PROJECT_ROOT, + category: "ide", + }); + + expect(report.toolHealth).toStrictEqual([healthOf(manifest, "vscode")]); + }); + }); + + describe("narrowed to a category", () => { + it("does not look for orphaned directories", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await deps.fs.writeFile( + join(PROJECT_ROOT, ".cursor", "commands", "plan.md"), + "---\nname: aidd:03:plan\ndescription: Plan feature\n---\nContent here.\n" + ); + + const report = await buildDoctorUseCase(deps).execute({ + projectRoot: PROJECT_ROOT, + category: "ai", + }); + + expect(report.issues).toStrictEqual([]); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/doctor/doctor-layout-use-case.unit.test.ts b/cli/tests/contexts/framework/application/doctor/doctor-layout-use-case.unit.test.ts new file mode 100644 index 000000000..c1ac81415 --- /dev/null +++ b/cli/tests/contexts/framework/application/doctor/doctor-layout-use-case.unit.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { DoctorLayoutUseCase } from "../../../../../src/contexts/framework/application/doctor/doctor-layout-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeAuthReader } from "../../../../helpers/ports/fake-auth-reader.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/project"; +const CLAUDE_COMMAND = ".claude/commands/plan.md"; +const SIGNAL = "---\nname: aidd:01:plan\ndescription: Plan\n---\n"; +const hasher = new DeterministicHasher(); + +function manifestTrackingClaude(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", [ + new InstallationFile({ + relativePath: CLAUDE_COMMAND, + content: SIGNAL, + hash: hasher.hash(SIGNAL), + }), + ]); + return manifest; +} + +async function issuesFor(onDisk: Record, authReader?: FakeAuthReader) { + const files = Object.fromEntries( + Object.entries(onDisk).map(([relativePath, content]) => [ + `${PROJECT_ROOT}/${relativePath}`, + content, + ]) + ); + return new DoctorLayoutUseCase(new InMemoryFileAdapter(files, hasher), authReader).execute({ + manifest: manifestTrackingClaude(), + projectRoot: PROJECT_ROOT, + }); +} + +describe("DoctorLayoutUseCase", () => { + describe("orphaned tool directories", () => { + it("warns about a tool directory holding aidd files the manifest does not track", async () => { + const issues = await issuesFor({ + [CLAUDE_COMMAND]: SIGNAL, + ".cursor/commands/plan.md": SIGNAL, + }); + + expect(issues).toStrictEqual([ + { + severity: "warning", + message: "Orphaned directory: .cursor/ (not tracked in manifest)", + fix: "Remove the directory manually, or run `aidd install ` to track it.", + }, + ]); + }); + + it("says nothing about a tracked tool directory holding aidd files", async () => { + expect(await issuesFor({ [CLAUDE_COMMAND]: SIGNAL })).toStrictEqual([]); + }); + }); + + describe("authentication", () => { + it("reports an info line when no token resolves", async () => { + const issues = await issuesFor({}, new FakeAuthReader(null)); + + expect(issues).toStrictEqual([ + { severity: "info", message: "Not authenticated", fix: "Run aidd auth login" }, + ]); + }); + + it("says nothing when a token resolves", async () => { + expect(await issuesFor({}, new FakeAuthReader("token"))).toStrictEqual([]); + }); + + it("says nothing when nothing here can read a token", async () => { + expect(await issuesFor({})).toStrictEqual([]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/doctor/doctor-merge-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/doctor/doctor-merge-files-use-case.unit.test.ts new file mode 100644 index 000000000..ced279fed --- /dev/null +++ b/cli/tests/contexts/framework/application/doctor/doctor-merge-files-use-case.unit.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { DoctorMergeFilesUseCase } from "../../../../../src/contexts/framework/application/doctor/doctor-merge-files-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/project"; +const MERGE_PATH = ".vscode/settings.json"; +const KEY = "editor.formatOnSave"; +const hasher = new DeterministicHasher(); + +const MISSING_FILE = { + severity: "error", + message: `Missing merge file: ${MERGE_PATH}`, + fix: "Run `aidd sync --force` to reinstall tracked files.", +}; +const MISSING_KEY = { + severity: "error", + message: `Missing key in ${MERGE_PATH} > ${KEY}`, + fix: "Run `aidd sync --force` to restore managed keys.", +}; +const MODIFIED_KEY = { + severity: "warning", + message: `Modified key in ${MERGE_PATH} > ${KEY}`, + fix: "Run `aidd sync --force` to restore the original value.", +}; + +function manifestMerging(toolId: ToolId): Manifest { + const manifest = Manifest.create(); + manifest.addTool( + toolId, + "1.0.0", + [], + [{ relativePath: MERGE_PATH, sectionKey: null, entries: { [KEY]: hasher.hash("true") } }] + ); + return manifest; +} + +async function issuesFor( + onDisk: string | null, + allowedIds: Set | null = null, + toolId: ToolId = "vscode" +) { + const fs = new InMemoryFileAdapter( + onDisk === null ? {} : { [`${PROJECT_ROOT}/${MERGE_PATH}`]: onDisk }, + hasher + ); + return new DoctorMergeFilesUseCase(fs, hasher).execute({ + manifest: manifestMerging(toolId), + projectRoot: PROJECT_ROOT, + allowedIds, + }); +} + +describe("DoctorMergeFilesUseCase", () => { + describe("a merge file", () => { + it("reports nothing when every managed key holds its recorded value", async () => { + expect(await issuesFor(`{ "${KEY}": true }`)).toStrictEqual([]); + }); + + it("reports one error naming the file when it is gone from disk", async () => { + expect(await issuesFor(null)).toStrictEqual([MISSING_FILE]); + }); + + it("reports one error naming the key when the file no longer holds it", async () => { + expect(await issuesFor("{}")).toStrictEqual([MISSING_KEY]); + }); + + it("reports one warning naming the key when its value changed", async () => { + expect(await issuesFor(`{ "${KEY}": false }`)).toStrictEqual([MODIFIED_KEY]); + }); + }); + + describe("narrowed to a set of tools", () => { + it("ignores a missing merge file of a tool outside the set", async () => { + expect(await issuesFor(null, new Set(["claude"]))).toStrictEqual([]); + }); + + it("still reports a missing merge file of a tool inside the set", async () => { + expect(await issuesFor(null, new Set(["vscode"]))).toStrictEqual([MISSING_FILE]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/doctor/doctor-references-use-case.unit.test.ts b/cli/tests/contexts/framework/application/doctor/doctor-references-use-case.unit.test.ts new file mode 100644 index 000000000..1025484ea --- /dev/null +++ b/cli/tests/contexts/framework/application/doctor/doctor-references-use-case.unit.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from "vitest"; +import { DoctorReferencesUseCase } from "../../../../../src/contexts/framework/application/doctor/doctor-references-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/project"; +const DOC = ".claude/rules/doc.md"; +const PLUGIN_DOC = ".claude/plugins/sample/agents/reviewer.md"; +const hasher = new DeterministicHasher(); + +function brokenIn(relativePath: string, ref: string) { + return { + severity: "warning", + message: `Broken reference in ${relativePath}: "${ref}" not found on disk`, + fix: `Restore the missing file or remove the reference in ${relativePath}`, + }; +} + +function manifestWithPluginDoc(content: string): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [PLUGIN_DOC]: hasher.hash(content).value }, + scope: "project", + }) + ); + return manifest; +} + +async function issuesFor( + onDisk: Record, + options: { + trackedFiles?: { relativePath: string; toolId: ToolId | null }[]; + manifest?: Manifest; + allowedIds?: Set | null; + } = {} +) { + const files = Object.fromEntries( + Object.entries(onDisk).map(([relativePath, content]) => [ + `${PROJECT_ROOT}/${relativePath}`, + content, + ]) + ); + return new DoctorReferencesUseCase(new InMemoryFileAdapter(files, hasher)).execute({ + manifest: options.manifest ?? Manifest.create(), + projectRoot: PROJECT_ROOT, + allowedIds: options.allowedIds ?? null, + trackedFiles: options.trackedFiles ?? [], + }); +} + +const TRACKED_DOC = [{ relativePath: DOC, toolId: "claude" as const }]; + +describe("DoctorReferencesUseCase", () => { + describe("references in a tracked markdown file", () => { + it("reports a warning for an @ reference to a file that is not on disk", async () => { + const issues = await issuesFor( + { [DOC]: "See @docs/missing.md" }, + { trackedFiles: TRACKED_DOC } + ); + + expect(issues).toStrictEqual([brokenIn(DOC, "docs/missing.md")]); + }); + + it("reports a warning for a markdown link resolved from the file's own directory", async () => { + const issues = await issuesFor( + { [DOC]: "[sibling](gone.md)" }, + { trackedFiles: TRACKED_DOC } + ); + + expect(issues).toStrictEqual([brokenIn(DOC, "gone.md")]); + }); + + it("reports nothing when every reference resolves", async () => { + const issues = await issuesFor( + { + [DOC]: "See @docs/here.md and [sibling](other.md)", + "docs/here.md": "", + ".claude/rules/other.md": "", + }, + { trackedFiles: TRACKED_DOC } + ); + + expect(issues).toStrictEqual([]); + }); + + it("never looks up a directory reference", async () => { + const issues = await issuesFor( + { [DOC]: "See @docs/nowhere/ and [dir](elsewhere/)" }, + { trackedFiles: TRACKED_DOC } + ); + + expect(issues).toStrictEqual([]); + }); + + it("ignores a link that resolves outside the project", async () => { + const issues = await issuesFor( + { [DOC]: "[up](../../../outside.md)" }, + { trackedFiles: TRACKED_DOC } + ); + + expect(issues).toStrictEqual([]); + }); + + it("reports nothing for a tracked file that is not on disk", async () => { + expect(await issuesFor({}, { trackedFiles: TRACKED_DOC })).toStrictEqual([]); + }); + }); + + describe("which files are scanned", () => { + it("scans only markdown files", async () => { + const settings = ".claude/settings.json"; + const issues = await issuesFor( + { [settings]: "@docs/missing.md" }, + { trackedFiles: [{ relativePath: settings, toolId: "claude" }] } + ); + + expect(issues).toStrictEqual([]); + }); + + it("skips a file whose markdown extension is not its last one", async () => { + const backup = ".claude/rules/doc.md.bak"; + const issues = await issuesFor( + { [backup]: "@docs/missing.md" }, + { trackedFiles: [{ relativePath: backup, toolId: "claude" }] } + ); + + expect(issues).toStrictEqual([]); + }); + + it("skips a file under a tasks directory", async () => { + const task = "aidd_docs/tasks/plan.md"; + const issues = await issuesFor( + { [task]: "@docs/missing.md" }, + { trackedFiles: [{ relativePath: task, toolId: null }] } + ); + + expect(issues).toStrictEqual([]); + }); + }); + + describe("files of an installed plugin", () => { + const content = "See @docs/missing.md"; + + it("are scanned like tracked files", async () => { + const issues = await issuesFor( + { [PLUGIN_DOC]: content }, + { manifest: manifestWithPluginDoc(content) } + ); + + expect(issues).toStrictEqual([brokenIn(PLUGIN_DOC, "docs/missing.md")]); + }); + + it("are ignored when their tool is outside the narrowed set", async () => { + const issues = await issuesFor( + { [PLUGIN_DOC]: content }, + { manifest: manifestWithPluginDoc(content), allowedIds: new Set(["cursor"]) } + ); + + expect(issues).toStrictEqual([]); + }); + + it("are still scanned when their tool is inside the narrowed set", async () => { + const issues = await issuesFor( + { [PLUGIN_DOC]: content }, + { manifest: manifestWithPluginDoc(content), allowedIds: new Set(["claude"]) } + ); + + expect(issues).toStrictEqual([brokenIn(PLUGIN_DOC, "docs/missing.md")]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/doctor/doctor-tracked-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/doctor/doctor-tracked-files-use-case.unit.test.ts new file mode 100644 index 000000000..f7b5db882 --- /dev/null +++ b/cli/tests/contexts/framework/application/doctor/doctor-tracked-files-use-case.unit.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { DoctorTrackedFilesUseCase } from "../../../../../src/contexts/framework/application/doctor/doctor-tracked-files-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/project"; +const FILE = ".claude/rules/one.md"; +const CONTENT = "one\n"; +const hasher = new DeterministicHasher(); + +const MISSING = { + severity: "error", + message: `Missing tracked file: ${FILE}`, + fix: "Restore the file or run `aidd sync` to reinstall tracked files.", +}; +const MODIFIED = { + severity: "warning", + message: `Modified tracked file: ${FILE}`, + fix: "Run `aidd sync --force` to revert to the framework version.", +}; + +function manifestTracking(toolId: ToolId): Manifest { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", [ + new InstallationFile({ relativePath: FILE, content: CONTENT, hash: hasher.hash(CONTENT) }), + ]); + return manifest; +} + +async function issuesFor( + onDisk: string | null, + allowedIds: Set | null = null, + toolId: ToolId = "claude" +) { + const fs = new InMemoryFileAdapter( + onDisk === null ? {} : { [`${PROJECT_ROOT}/${FILE}`]: onDisk }, + hasher + ); + return new DoctorTrackedFilesUseCase(fs).execute({ + manifest: manifestTracking(toolId), + projectRoot: PROJECT_ROOT, + allowedIds, + }); +} + +describe("DoctorTrackedFilesUseCase", () => { + describe("a tracked file", () => { + it("reports nothing when the disk matches the manifest", async () => { + expect(await issuesFor(CONTENT)).toStrictEqual([]); + }); + + it("reports one error naming the file when it is gone from disk", async () => { + expect(await issuesFor(null)).toStrictEqual([MISSING]); + }); + + it("reports one warning naming the file when its content changed", async () => { + expect(await issuesFor("edited\n")).toStrictEqual([MODIFIED]); + }); + }); + + describe("narrowed to a set of tools", () => { + it("ignores a missing file of a tool outside the set", async () => { + expect(await issuesFor(null, new Set(["cursor"]))).toStrictEqual([]); + }); + + it("ignores a modified file of a tool outside the set", async () => { + expect(await issuesFor("edited\n", new Set(["cursor"]))).toStrictEqual([]); + }); + + it("still reports a missing file of a tool inside the set", async () => { + expect(await issuesFor(null, new Set(["claude"]))).toStrictEqual([MISSING]); + }); + + it("still reports a modified file of a tool inside the set", async () => { + expect(await issuesFor("edited\n", new Set(["claude"]))).toStrictEqual([MODIFIED]); + }); + }); + + describe("collectTrackedFiles", () => { + it("pairs every tracked file with the tool that owns it", () => { + const collected = new DoctorTrackedFilesUseCase( + new InMemoryFileAdapter() + ).collectTrackedFiles(manifestTracking("claude"), null); + + expect(collected.map((f) => [f.relativePath, f.toolId])).toStrictEqual([[FILE, "claude"]]); + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/global/doctor-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/global/doctor-all-use-case.unit.test.ts index 1683ccf87..f3bf3df9f 100644 --- a/cli/tests/contexts/framework/application/global/doctor-all-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/global/doctor-all-use-case.unit.test.ts @@ -1,20 +1,142 @@ import { describe, expect, it } from "vitest"; import { DoctorAllUseCase } from "../../../../../src/contexts/framework/application/global/doctor-all-use-case.js"; -import { buildDoctorUseCase, buildUnitDeps } from "../../../../helpers/ports/build-unit-deps.js"; +import type { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { + buildDoctorUseCase, + buildUnitDeps, + initAndInstall, + installTool, +} from "../../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; +const NO_MANIFEST = "No AIDD manifest found. Run `aidd setup` to initialize your project."; +const PLUGIN_FILE = ".claude/plugins/sample/agents/reviewer.md"; + +async function depsWithBothCategories() { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installTool(deps, PROJECT_ROOT, "vscode"); + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("manifest missing"); + return { deps, manifest }; +} + +function healthOf(manifest: Manifest, toolId: ToolId) { + return { + toolId, + fileCount: manifest.getToolFiles(toolId).length, + mergeFileCount: manifest.getMergeFiles(toolId).length, + }; +} + +async function deleteOneTrackedFile( + deps: Awaited>, + manifest: Manifest, + toolId: ToolId +) { + const [first] = manifest.getToolFiles(toolId); + if (first === undefined) throw new Error(`${toolId} tracks no file`); + await deps.fs.deleteFile(`${PROJECT_ROOT}/${first.relativePath}`); +} + +function verdictOf(result: Awaited>) { + return [result.healthy, result.ai?.healthy, result.ide?.healthy, result.errors]; +} describe("DoctorAllUseCase", () => { - it("is not healthy when every scope errored (no manifest found)", async () => { + it("records one error per scope, and no report, when no manifest exists", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); - const doctorUseCase = buildDoctorUseCase(deps); - const useCase = new DoctorAllUseCase(doctorUseCase); + const useCase = new DoctorAllUseCase(buildDoctorUseCase(deps)); + + const result = await useCase.execute(PROJECT_ROOT); + + expect(result).toStrictEqual({ + ai: null, + ide: null, + pluginIssues: [], + healthy: false, + errors: [ + { scope: "ai", message: NO_MANIFEST }, + { scope: "ide", message: NO_MANIFEST }, + ], + }); + }); + + it("is healthy with one report per category when every tool is in sync", async () => { + const { deps, manifest } = await depsWithBothCategories(); + const useCase = new DoctorAllUseCase(buildDoctorUseCase(deps)); + + const result = await useCase.execute(PROJECT_ROOT); + + expect(result).toStrictEqual({ + ai: { + healthy: true, + toolHealth: [healthOf(manifest, "claude")], + issues: [], + pluginIssues: [], + }, + ide: { + healthy: true, + toolHealth: [healthOf(manifest, "vscode")], + issues: [], + pluginIssues: [], + }, + pluginIssues: [], + healthy: true, + errors: [], + }); + }); + + it("is not healthy when the ai scope alone found a fault", async () => { + const { deps, manifest } = await depsWithBothCategories(); + await deleteOneTrackedFile(deps, manifest, "claude"); + const useCase = new DoctorAllUseCase(buildDoctorUseCase(deps)); + + const result = await useCase.execute(PROJECT_ROOT); + + expect(verdictOf(result)).toStrictEqual([false, false, true, []]); + }); + + it("is not healthy when the ide scope alone found a fault", async () => { + const { deps, manifest } = await depsWithBothCategories(); + await deleteOneTrackedFile(deps, manifest, "vscode"); + const useCase = new DoctorAllUseCase(buildDoctorUseCase(deps)); + + const result = await useCase.execute(PROJECT_ROOT); + + expect(verdictOf(result)).toStrictEqual([false, true, false, []]); + }); + + it("carries the ai scope's plugin issues", async () => { + const { deps, manifest } = await depsWithBothCategories(); + manifest.addPlugin( + "claude", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [PLUGIN_FILE]: "abc123abc123abc123abc123abc123ab" }, + scope: "project", + }) + ); + await deps.manifestRepo.save(manifest); + const useCase = new DoctorAllUseCase(buildDoctorUseCase(deps)); const result = await useCase.execute(PROJECT_ROOT); - expect(result.ai).toBeNull(); - expect(result.ide).toBeNull(); - expect(result.errors.length).toBeGreaterThan(0); - expect(result.healthy).toBe(false); + const missing = { + toolId: "claude", + pluginName: "sample", + issue: "missing", + filePath: PLUGIN_FILE, + }; + expect([result.pluginIssues, result.ai?.pluginIssues, result.healthy]).toStrictEqual([ + [missing], + [missing], + false, + ]); }); }); diff --git a/cli/tests/contexts/framework/application/status-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/status-all-use-case.unit.test.ts index 095f39d2a..f3422d668 100644 --- a/cli/tests/contexts/framework/application/status-all-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/status-all-use-case.unit.test.ts @@ -63,7 +63,42 @@ describe("StatusAllUseCase", () => { const result = await new StatusAllUseCase(useCase).execute(PROJECT_ROOT); - expect(result.pluginDrift).toEqual([]); - expect(result.errors.map((e) => e.scope)).toEqual(["ai"]); + expect(result).toStrictEqual({ + aiTools: { tools: [], pluginDrift: [], inSync: true }, + ideTools: report(), + pluginDrift: [], + errors: [{ scope: "ai", message: "ai scope exploded" }], + }); + }); + + it("returns each scope's own report", async () => { + const aiReport = report({ tools: [{ toolId: "claude", version: "1", drifted: [] }] }); + const ideReport = report({ tools: [{ toolId: "vscode", version: "1", drifted: [] }] }); + const { useCase } = fakeStatus((o) => (o.category === "ai" ? aiReport : ideReport)); + + const result = await new StatusAllUseCase(useCase).execute(PROJECT_ROOT); + + expect(result).toStrictEqual({ + aiTools: aiReport, + ideTools: ideReport, + pluginDrift: [], + errors: [], + }); + }); + + it("substitutes an empty in-sync report for the ide scope when it failed", async () => { + const { useCase } = fakeStatus((o) => { + if (o.category === "ide") throw new Error("ide scope exploded"); + return report(); + }); + + const result = await new StatusAllUseCase(useCase).execute(PROJECT_ROOT); + + expect(result).toStrictEqual({ + aiTools: report(), + ideTools: { tools: [], pluginDrift: [], inSync: true }, + pluginDrift: [], + errors: [{ scope: "ide", message: "ide scope exploded" }], + }); }); }); diff --git a/cli/tests/contexts/framework/application/status-use-case.unit.test.ts b/cli/tests/contexts/framework/application/status-use-case.unit.test.ts index 01ea91a11..6ad249b30 100644 --- a/cli/tests/contexts/framework/application/status-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/status-use-case.unit.test.ts @@ -8,11 +8,264 @@ import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { InitUseCase } from "../../../../src/contexts/framework/application/init-use-case.js"; import { DetectPluginDriftUseCase } from "../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; import { StatusUseCase } from "../../../../src/contexts/framework/application/status-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { machineLocalFilesOf } from "../../../../src/contexts/tools/domain/registry.js"; +import { NoManifestError, ToolNotInstalledError } from "../../../../src/kernel/errors.js"; +import { InstallationFile } from "../../../../src/kernel/file.js"; import { compareSemver } from "../../../../src/kernel/semver.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; import { buildUnitDeps } from "../../../helpers/ports/build-unit-deps.js"; +import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; const PROJECT_ROOT = "/test-project"; +const TRACKED = ".claude/rules/one.md"; +const CONTENT = "one\n"; +const MERGE_PATH = ".vscode/settings.json"; +const KEY = "editor.formatOnSave"; +const hasher = new DeterministicHasher(); + +function tracking(manifest: Manifest, toolId: ToolId, relativePath: string, content: string) { + manifest.addTool(toolId, "test", [ + new InstallationFile({ relativePath, content, hash: hasher.hash(content) }), + ]); +} + +function merging(manifest: Manifest) { + manifest.addTool( + "vscode", + "test", + [], + [{ relativePath: MERGE_PATH, sectionKey: null, entries: { [KEY]: hasher.hash("true") } }] + ); +} + +function statusOver(onDisk: Record, manifest: Manifest | null) { + const files = Object.fromEntries( + Object.entries(onDisk).map(([relativePath, content]) => [ + `${PROJECT_ROOT}/${relativePath}`, + content, + ]) + ); + const fs = new InMemoryFileAdapter(files, hasher); + return new StatusUseCase( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + hasher, + new DetectPluginDriftUseCase(fs) + ); +} + +function inSyncTool(toolId: ToolId) { + return { toolId, version: "test", drifted: [] }; +} + +describe("StatusUseCase", () => { + describe("which tools are reported", () => { + it("fails when no manifest exists", async () => { + await expect(statusOver({}, null).execute({ projectRoot: PROJECT_ROOT })).rejects.toThrow( + NoManifestError + ); + }); + + it("refuses a filter naming a tool that is not installed", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + + await expect( + statusOver({}, manifest).execute({ projectRoot: PROJECT_ROOT, filterToolId: "cursor" }) + ).rejects.toThrow(ToolNotInstalledError); + }); + + it("reports only the filtered tool", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addTool("vscode", "test", []); + + const report = await statusOver({}, manifest).execute({ + projectRoot: PROJECT_ROOT, + filterToolId: "claude", + }); + + expect(report).toStrictEqual({ + tools: [inSyncTool("claude")], + pluginDrift: [], + inSync: true, + }); + }); + + it("reports only the tools of the requested category", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addTool("vscode", "test", []); + + const report = await statusOver({}, manifest).execute({ + projectRoot: PROJECT_ROOT, + category: "ide", + }); + + expect(report).toStrictEqual({ + tools: [inSyncTool("vscode")], + pluginDrift: [], + inSync: true, + }); + }); + + it("reports a tool whose directory is absent from disk as in sync", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + + const report = await statusOver({}, manifest).execute({ projectRoot: PROJECT_ROOT }); + + expect(report).toStrictEqual({ + tools: [inSyncTool("claude")], + pluginDrift: [], + inSync: true, + }); + }); + }); + + describe("tracked files", () => { + function manifestTracking() { + const manifest = Manifest.create(); + tracking(manifest, "claude", TRACKED, CONTENT); + return manifest; + } + + it("reports an untouched tracked file as in sync", async () => { + const report = await statusOver({ [TRACKED]: CONTENT }, manifestTracking()).execute({ + projectRoot: PROJECT_ROOT, + }); + + expect(report).toStrictEqual({ + tools: [inSyncTool("claude")], + pluginDrift: [], + inSync: true, + }); + }); + + it("calls a tracked file whose content changed modified", async () => { + const report = await statusOver({ [TRACKED]: "edited\n" }, manifestTracking()).execute({ + projectRoot: PROJECT_ROOT, + }); + + expect(report).toStrictEqual({ + tools: [ + { + toolId: "claude", + version: "test", + drifted: [{ relativePath: TRACKED, status: "modified" }], + }, + ], + pluginDrift: [], + inSync: false, + }); + }); + + it("calls a tracked file gone from disk deleted", async () => { + const report = await statusOver({}, manifestTracking()).execute({ + projectRoot: PROJECT_ROOT, + }); + + expect(report.tools).toStrictEqual([ + { + toolId: "claude", + version: "test", + drifted: [{ relativePath: TRACKED, status: "deleted" }], + }, + ]); + }); + + it("calls a file in the tool directory the manifest does not track added", async () => { + const report = await statusOver( + { [TRACKED]: CONTENT, ".claude/rules/two.md": "two\n" }, + manifestTracking() + ).execute({ projectRoot: PROJECT_ROOT }); + + expect(report.tools).toStrictEqual([ + { + toolId: "claude", + version: "test", + drifted: [{ relativePath: ".claude/rules/two.md", status: "added" }], + }, + ]); + }); + + it("leaves a backup file out of the additions", async () => { + const report = await statusOver( + { [TRACKED]: CONTENT, ".claude/rules/one.md.backup": "old\n" }, + manifestTracking() + ).execute({ projectRoot: PROJECT_ROOT }); + + expect(report.tools).toStrictEqual([inSyncTool("claude")]); + }); + }); + + describe("merge files", () => { + const DRIFT_PATH = `${MERGE_PATH} > ${KEY}`; + + function manifestMerging() { + const manifest = Manifest.create(); + merging(manifest); + return manifest; + } + + it("reports a merge file whose managed keys match as in sync", async () => { + const report = await statusOver( + { [MERGE_PATH]: `{ "${KEY}": true }` }, + manifestMerging() + ).execute({ projectRoot: PROJECT_ROOT }); + + expect(report).toStrictEqual({ + tools: [inSyncTool("vscode")], + pluginDrift: [], + inSync: true, + }); + }); + + it("calls every managed key of a missing merge file deleted", async () => { + const report = await statusOver({}, manifestMerging()).execute({ projectRoot: PROJECT_ROOT }); + + expect(report.tools).toStrictEqual([ + { + toolId: "vscode", + version: "test", + drifted: [{ relativePath: DRIFT_PATH, status: "deleted" }], + }, + ]); + }); + + it("calls a managed key missing from the merge file deleted", async () => { + const report = await statusOver({ [MERGE_PATH]: "{}" }, manifestMerging()).execute({ + projectRoot: PROJECT_ROOT, + }); + + expect(report.tools).toStrictEqual([ + { + toolId: "vscode", + version: "test", + drifted: [{ relativePath: DRIFT_PATH, status: "deleted" }], + }, + ]); + }); + + it("calls a managed key whose value changed modified", async () => { + const report = await statusOver( + { [MERGE_PATH]: `{ "${KEY}": false }` }, + manifestMerging() + ).execute({ projectRoot: PROJECT_ROOT }); + + expect(report.tools).toStrictEqual([ + { + toolId: "vscode", + version: "test", + drifted: [{ relativePath: DRIFT_PATH, status: "modified" }], + }, + ]); + }); + }); +}); describe("status", () => { it("reports no drift when no tools are installed", async () => { From 3498f1eb8dc3672d627d4bb087d17c50a803cffc Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:35:09 +0200 Subject: [PATCH 08/12] test(cli): kill the surviving mutants of the restore and uninstall use cases Restore, restore all, restore all plugins, tool files, merge and regular files, restore decisions, tool distribution generation, uninstall, uninstall tools, ide, plugin and mcp exclusion: 76 tests, each shown red first against the mutant it names. The mcp exclusion use case had no test before. Framework mutation score: 72.2 before the series, 95.4 after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../restore-all-plugins-use-case.unit.test.ts | 57 ++++ .../restore-all-use-case.unit.test.ts | 190 +++++++++++++ .../application/restore-use-case.unit.test.ts | 258 +++++++++++++++++- ...te-tool-distribution-use-case.unit.test.ts | 122 +++++++++ .../resolve-restore-decision.unit.test.ts | 76 ++++++ .../restore-merge-files-use-case.unit.test.ts | 68 +++++ ...estore-regular-files-use-case.unit.test.ts | 34 +++ .../restore-tool-files-use-case.unit.test.ts | 219 +++++++++++++++ .../uninstall-ide-use-case.unit.test.ts | 38 +++ .../application/uninstall-plugin.unit.test.ts | 73 ++++- .../uninstall-tools-use-case.unit.test.ts | 226 +++++++++++++++ .../uninstall-use-case.unit.test.ts | 91 ++++++ ...nstall-mcp-exclusion-use-case.unit.test.ts | 108 ++++++++ 13 files changed, 1558 insertions(+), 2 deletions(-) create mode 100644 cli/tests/contexts/framework/application/restore/generate-tool-distribution-use-case.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/restore/resolve-restore-decision.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/restore/restore-tool-files-use-case.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.unit.test.ts diff --git a/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts index 84d9787b8..616e8f2a8 100644 --- a/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-all-plugins-use-case.unit.test.ts @@ -124,3 +124,60 @@ describe("RestoreAllPluginsUseCase — native-activation tools", () => { expect(result.nativeOnlyToolIds).toEqual([]); }); }); + +function trackedPlugin(name: string): InstalledPlugin { + return InstalledPlugin.fromJSON({ + name, + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { "a/one.md": "00000000000000000000000000000000" }, + scope: "project", + }); +} + +describe("RestoreAllPluginsUseCase — restricting to one plugin", () => { + function claudeWithMixedPlugins(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin("claude", nativePlugin({})); + manifest.addPlugin("claude", trackedPlugin("tracked-plugin")); + return manifest; + } + + function useCase(): RestoreAllPluginsUseCase { + return new RestoreAllPluginsUseCase(noopFs, noopHasher, stubFetcher, emptyDistributionReader); + } + + it("names the tool when the one plugin asked for tracks zero files, whatever its others track", async () => { + const result = await useCase().execute({ + projectRoot: "/proj", + manifest: claudeWithMixedPlugins(), + fileFilter: null, + pluginName: "aidd-test", + }); + + expect(result.nativeOnlyToolIds).toStrictEqual(["claude"]); + }); + + it("does not name the tool when the plugin asked for tracks files", async () => { + const result = await useCase().execute({ + projectRoot: "/proj", + manifest: claudeWithMixedPlugins(), + fileFilter: null, + pluginName: "tracked-plugin", + }); + + expect(result.nativeOnlyToolIds).toStrictEqual([]); + }); + + it("does not name the tool when any of its plugins tracks files", async () => { + const result = await useCase().execute({ + projectRoot: "/proj", + manifest: claudeWithMixedPlugins(), + fileFilter: null, + }); + + expect(result.nativeOnlyToolIds).toStrictEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts index 56bdbe5cf..7bdedf56c 100644 --- a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts @@ -6,11 +6,15 @@ import { RestoreUseCase } from "../../../../src/contexts/framework/application/r import { DetectPluginDriftUseCase } from "../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; import { StatusUseCase } from "../../../../src/contexts/framework/application/status-use-case.js"; import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { NoManifestError } from "../../../../src/kernel/errors.js"; +import type { Prompter } from "../../../../src/kernel/ports/prompter.js"; import { buildUnitDeps, initAndInstall, + initProject, installTool, } from "../../../helpers/ports/build-unit-deps.js"; +import { CheckboxRecordingPrompter } from "../../../helpers/ports/checkbox-recording-prompter.js"; import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; import { FakePlatform } from "../../../helpers/ports/fake-platform.js"; import { OverwritePrompter, ScriptedPrompter } from "../../../helpers/ports/scripted-prompter.js"; @@ -387,3 +391,189 @@ describe("RestoreAllUseCase — native-only tools", () => { expect(result.nativeOnlyToolIds).toEqual(["claude"]); }); }); + +type RestoreOptions = Parameters[0]; +type RestoreOutcome = Awaited>; + +const NOTHING_RESTORED: RestoreOutcome = { + tools: [], + totalRestored: 0, + totalKept: 0, + totalPluginFilesRestored: 0, + restoredPluginNames: [], + unrestorable: [], + nativeOnlyToolIds: [], +}; + +function restoreAllDelegatingTo( + deps: Deps, + prompter: Prompter, + delegate: (options: RestoreOptions) => Promise +): RestoreAllUseCase { + const statusUseCase = new StatusUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + new DetectPluginDriftUseCase(deps.fs) + ); + const restoreUseCase = new RestoreUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + deps.logger, + new FakePlatform("linux"), + prompter + ); + restoreUseCase.execute = delegate; + return new RestoreAllUseCase(deps.manifestRepo, prompter, statusUseCase, restoreUseCase); +} + +function recordingDelegate(): { + asked: RestoreOptions[]; + delegate: (o: RestoreOptions) => Promise; +} { + const asked: RestoreOptions[] = []; + return { + asked, + delegate: async (options) => { + asked.push(options); + return NOTHING_RESTORED; + }, + }; +} + +describe("RestoreAllUseCase — before delegating", () => { + it("refuses a project that has no manifest", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + const useCase = restoreAllDelegatingTo( + deps, + new OverwritePrompter(), + recordingDelegate().delegate + ); + + await expect(useCase.execute(PROJECT_ROOT, false, false)).rejects.toThrow(NoManifestError); + }); + + it("hands the restore the version of the first installed tool", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const { asked, delegate } = recordingDelegate(); + + await restoreAllDelegatingTo(deps, new OverwritePrompter(), delegate).execute( + PROJECT_ROOT, + false, + false + ); + + expect(asked.map((o) => o.version)).toStrictEqual(["test"]); + }); + + it("hands the restore an unknown version when no tool is installed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const { asked, delegate } = recordingDelegate(); + + await restoreAllDelegatingTo(deps, new OverwritePrompter(), delegate).execute( + PROJECT_ROOT, + false, + false + ); + + expect(asked.map((o) => o.version)).toStrictEqual(["unknown"]); + }); + + it("reports a failing restore as a config-restore error with nothing restored", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const useCase = restoreAllDelegatingTo(deps, new OverwritePrompter(), async () => { + throw new Error("disk on fire"); + }); + + const result = await useCase.execute(PROJECT_ROOT, false, false); + + expect(result).toStrictEqual({ + totalRestored: 0, + totalKept: 0, + pluginNamesRestored: [], + errors: [{ scope: "config-restore", message: "disk on fire" }], + unrestorable: [], + nativeOnlyToolIds: [], + }); + }); +}); + +describe("RestoreAllUseCase — the interactive file picker", () => { + const KEYBINDINGS = ".vscode/keybindings.json"; + const SETTINGS = ".vscode/settings.json"; + + async function vscodeProject(): Promise { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "vscode"); + return deps; + } + + it("offers the modified and deleted tracked entries, never a file the user added", async () => { + const deps = await vscodeProject(); + await deps.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + await deps.fs.deleteFile(join(PROJECT_ROOT, ".vscode/extensions.json")); + await deps.fs.writeFile(join(PROJECT_ROOT, ".vscode/user-added.json"), "{}"); + const prompter = new CheckboxRecordingPrompter(); + + await restoreAllDelegatingTo(deps, prompter, recordingDelegate().delegate).execute( + PROJECT_ROOT, + false, + true + ); + + expect(prompter.asks).toStrictEqual([ + { + message: "Select files to restore:", + offered: [ + KEYBINDINGS, + ".vscode/extensions.json > recommendations", + ".vscode/extensions.json > unwantedRecommendations", + ], + }, + ]); + }); + + it("forwards exactly the entries the user ticked", async () => { + const deps = await vscodeProject(); + await deps.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + await deps.fs.deleteFile(join(PROJECT_ROOT, SETTINGS)); + const { asked, delegate } = recordingDelegate(); + + await restoreAllDelegatingTo( + deps, + new CheckboxRecordingPrompter([KEYBINDINGS]), + delegate + ).execute(PROJECT_ROOT, false, true); + + expect(asked.map((o) => o.files)).toStrictEqual([[KEYBINDINGS]]); + }); + + it("forwards an empty selection as no file at all", async () => { + const deps = await vscodeProject(); + await deps.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + const { asked, delegate } = recordingDelegate(); + + await restoreAllDelegatingTo(deps, new CheckboxRecordingPrompter([]), delegate).execute( + PROJECT_ROOT, + false, + true + ); + + expect(asked.map((o) => o.files)).toStrictEqual([[]]); + }); + + it("asks nothing and selects nothing when no tracked entry drifted", async () => { + const deps = await vscodeProject(); + const prompter = new CheckboxRecordingPrompter([KEYBINDINGS]); + const { asked, delegate } = recordingDelegate(); + + await restoreAllDelegatingTo(deps, prompter, delegate).execute(PROJECT_ROOT, false, true); + + expect(prompter.asks).toStrictEqual([]); + expect(asked.map((o) => o.files)).toStrictEqual([[]]); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts index 87742be04..2580bc7af 100644 --- a/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts @@ -2,7 +2,9 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { PluginAddUseCase } from "../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; import { RestoreUseCase } from "../../../../src/contexts/framework/application/restore/restore-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { InstallationFile } from "../../../../src/kernel/file.js"; import { buildUnitDeps, FIXTURE_DIR, @@ -12,7 +14,11 @@ import { } from "../../../helpers/ports/build-unit-deps.js"; import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; import { FakePlatform } from "../../../helpers/ports/fake-platform.js"; -import { KeepPrompter, OverwritePrompter } from "../../../helpers/ports/scripted-prompter.js"; +import { + KeepPrompter, + OverwritePrompter, + ScriptedPrompter, +} from "../../../helpers/ports/scripted-prompter.js"; import { seedFromDirectory } from "../../../helpers/ports/seed-from-directory.js"; const PROJECT_ROOT = "/test-project"; @@ -476,3 +482,253 @@ describe("restore", () => { }); }); }); + +function withoutPluginSources(deps: Awaited>): RestoreUseCase { + return new RestoreUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + deps.logger, + new FakePlatform("linux"), + new OverwritePrompter() + ); +} + +function withFetcherOnly(deps: Awaited>): RestoreUseCase { + return new RestoreUseCase( + deps.fs, + deps.manifestRepo, + deps.hasher, + deps.logger, + new FakePlatform("linux"), + new OverwritePrompter(), + deps.pluginFetcher + ); +} + +async function loadedManifest(deps: Awaited>): Promise { + const manifest = await deps.manifestRepo.load(); + if (manifest === null) throw new Error("the project must be initialized first"); + return manifest; +} + +describe("restore — consent to overwrite", () => { + it("refuses to overwrite a modified file when neither force nor a TTY was given", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "vscode"); + await deps.fs.writeFile(join(PROJECT_ROOT, ".vscode/keybindings.json"), "[]"); + + await expect( + makeRestoreUseCase(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow("--force"); + }); +}); + +describe("restore — plugin sources", () => { + const PLUGIN_FILE = join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"); + + async function claudeWithCorruptedPlugin() { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installPlugin(deps, "claude"); + await deps.fs.writeFile(PLUGIN_FILE, "CORRUPTED"); + return deps; + } + + it("leaves plugin files alone when no plugin source is wired", async () => { + const deps = await claudeWithCorruptedPlugin(); + + const result = await withoutPluginSources(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + }); + + expect(result.totalPluginFilesRestored).toBe(0); + expect(result.restoredPluginNames).toStrictEqual([]); + expect(result.nativeOnlyToolIds).toStrictEqual([]); + expect(deps.fs.getFile(PLUGIN_FILE)).toBe("CORRUPTED"); + }); + + it("leaves plugin files alone when only the fetcher is wired", async () => { + const deps = await claudeWithCorruptedPlugin(); + + const result = await withFetcherOnly(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + }); + + expect(result.totalPluginFilesRestored).toBe(0); + expect(deps.fs.getFile(PLUGIN_FILE)).toBe("CORRUPTED"); + }); +}); + +describe("restore — persisting the manifest", () => { + it("persists the manifest it was handed once one tool restored a file, even when another had nothing to restore", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "vscode"); + await installTool(deps, PROJECT_ROOT, "cursor"); + const handed = Manifest.fromJSON((await loadedManifest(deps)).toJSON()); + await deps.fs.writeFile(join(PROJECT_ROOT, ".vscode/keybindings.json"), "[]"); + + await makeRestoreUseCase(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + manifest: handed, + }); + + expect(deps.manifestRepo.getCurrent()).toBe(handed); + }); + + it("does not persist anything when no file drifted", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "vscode"); + const stored = await loadedManifest(deps); + const handed = Manifest.fromJSON(stored.toJSON()); + + await makeRestoreUseCase(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + manifest: handed, + }); + + expect(deps.manifestRepo.getCurrent()).toBe(stored); + }); + + it("persists the manifest when only a plugin file was restored", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + await installPlugin(deps, "claude"); + const handed = Manifest.fromJSON((await loadedManifest(deps)).toJSON()); + await deps.fs.writeFile( + join(PROJECT_ROOT, ".claude/plugins/sample-plugin/commands/greet.md"), + "CORRUPTED" + ); + + await makeRestoreUseCase(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + manifest: handed, + }); + + expect(deps.manifestRepo.getCurrent()).toBe(handed); + }); +}); + +describe("restore — totals", () => { + it("counts the files restored and kept across a tool's sections", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "vscode"); + await deps.fs.writeFile(join(PROJECT_ROOT, ".vscode/keybindings.json"), "[]"); + await deps.fs.writeFile(join(PROJECT_ROOT, ".vscode/settings.json"), '{"editor.tabSize": 3}'); + const prompter = new ScriptedPrompter([ + ScriptedPrompter.answer.conflict("overwrite"), + ScriptedPrompter.answer.conflict("keep"), + ]); + + const result = await makeRestoreUseCase(deps, prompter).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + interactive: true, + }); + + expect(result.totalRestored).toBe(1); + expect(result.totalKept).toBe(1); + expect(result.unrestorable).toStrictEqual([]); + }); +}); + +describe("restore — the framework path", () => { + it("reads only the config files the framework path actually holds", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "vscode"); + const keybindings = join(PROJECT_ROOT, ".vscode/keybindings.json"); + await deps.fs.writeFile("/partial-framework/config/vscode/keybindings.json", "[]"); + await deps.fs.deleteFile(keybindings); + + await makeRestoreUseCase(deps).execute({ + frameworkPath: "/partial-framework", + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + }); + + expect(deps.fs.getFile(keybindings)).toBe("[]"); + }); +}); + +describe("restore — the file selection", () => { + const RUN = ".claude/hooks/run.js"; + const OTHER = ".claude/hooks/other.js"; + + async function claudeTrackingGhosts() { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "claude"); + const manifest = await loadedManifest(deps); + const ghosts = [RUN, OTHER].map( + (relativePath) => + new InstallationFile({ relativePath, content: "", hash: deps.hasher.hash(relativePath) }) + ); + manifest.addTool("claude", "test", ghosts, [...manifest.getMergeFiles("claude")]); + return deps; + } + + async function unrestorableFor( + deps: Awaited>, + files: string[] + ): Promise { + const result = await makeRestoreUseCase(deps).execute({ + frameworkPath: FIXTURE_DIR, + version: "test", + projectRoot: PROJECT_ROOT, + force: true, + files, + }); + return result.unrestorable; + } + + it("admits only the file named exactly", async () => { + const deps = await claudeTrackingGhosts(); + expect(await unrestorableFor(deps, [RUN])).toStrictEqual([RUN]); + }); + + it("admits a file when any entry of the selection names it", async () => { + const deps = await claudeTrackingGhosts(); + expect(await unrestorableFor(deps, ["nothing.md", RUN])).toStrictEqual([RUN]); + }); + + it("admits every file under a directory named without a trailing slash", async () => { + const deps = await claudeTrackingGhosts(); + expect(await unrestorableFor(deps, [".claude/hooks"])).toStrictEqual([RUN, OTHER]); + }); + + it("admits every file under a directory named with a trailing slash", async () => { + const deps = await claudeTrackingGhosts(); + expect(await unrestorableFor(deps, [".claude/hooks/"])).toStrictEqual([RUN, OTHER]); + }); + + it("matches a directory only as a whole path segment", async () => { + const deps = await claudeTrackingGhosts(); + expect(await unrestorableFor(deps, [".claude/hook"])).toStrictEqual([]); + }); + + it("treats an empty selection as no selection at all", async () => { + const deps = await claudeTrackingGhosts(); + expect(await unrestorableFor(deps, [])).toStrictEqual([RUN, OTHER]); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore/generate-tool-distribution-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore/generate-tool-distribution-use-case.unit.test.ts new file mode 100644 index 000000000..e1176f1be --- /dev/null +++ b/cli/tests/contexts/framework/application/restore/generate-tool-distribution-use-case.unit.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { GenerateToolDistributionUseCase } from "../../../../../src/contexts/framework/application/restore/generate-tool-distribution-use-case.js"; +import { CONFIG_VSCODE_SETTINGS } from "../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; +import { + getToolConfig, + isAiTool, + type ToolConfig, +} from "../../../../../src/contexts/tools/domain/registry.js"; +import { FrameworkDescriptor } from "../../../../../src/contexts/translate/domain/canon.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { BundledAssetProviderAdapter } from "../../../../../src/runtime/assets/asset-loader.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakePlatform } from "../../../../helpers/ports/fake-platform.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/test-project"; +const MARKDOWN = "---\nname: sample\ndescription: A sample.\n---\n\nBody.\n"; + +const descriptor = new FrameworkDescriptor({ + version: "test", + contentSections: ["agents", "commands", "rules", "skills", "templates"].map((name) => ({ + name, + directory: name, + entryFile: null, + })), + templateRefs: [], + configRefs: [{ name: CONFIG_VSCODE_SETTINGS, path: "config/vscode/settings.json" }], +}); + +const contentFiles = new Map([ + ["agents/reviewer.md", MARKDOWN], + ["commands/greet.md", MARKDOWN], + ["rules/naming.md", MARKDOWN], + ["skills/hello/SKILL.md", MARKDOWN], + ["templates/AGENTS.md", MARKDOWN], + ["config/vscode/settings.json", '{"editor.tabSize": 2}'], +]); + +const hasher = new DeterministicHasher(); +const assets = new BundledAssetProviderAdapter(); + +function generate( + config: ToolConfig, + assetProvider?: BundledAssetProviderAdapter +): Promise { + return new GenerateToolDistributionUseCase( + new InMemoryFileAdapter({}, hasher), + hasher, + new FakePlatform("linux"), + assetProvider + ).execute({ config, descriptor, contentFiles, projectRoot: PROJECT_ROOT }); +} + +async function pathsFor(toolId: ToolId, assetProvider?: BundledAssetProviderAdapter) { + return (await generate(getToolConfig(toolId), assetProvider)).map((f) => f.relativePath).sort(); +} + +describe("GenerateToolDistributionUseCase — content sections", () => { + it("gives an IDE tool its config files and no content section", async () => { + expect(await pathsFor("vscode")).toStrictEqual([".vscode/settings.json"]); + }); + + it("gives an AI tool one file per content section its capabilities accept", async () => { + expect(await pathsFor("claude")).toStrictEqual([ + ".claude/agents/reviewer.md", + ".claude/commands/greet.md", + ".claude/rules/naming.md", + ".claude/skills/hello/SKILL.md", + ]); + }); + + it("skips a section the tool declares no capability for", async () => { + const claude = getToolConfig("claude"); + if (!isAiTool(claude)) throw new Error("claude is an AI tool"); + const { rules: _rules, ...capabilities } = claude.capabilities as Record; + + const paths = (await generate({ ...claude, capabilities })).map((f) => f.relativePath).sort(); + + expect(paths).toStrictEqual([ + ".claude/agents/reviewer.md", + ".claude/commands/greet.md", + ".claude/skills/hello/SKILL.md", + ]); + }); +}); + +describe("GenerateToolDistributionUseCase — a tool's own config assets", () => { + it("writes a JSON asset pretty-printed at the path the tool declares", async () => { + const files = await generate(getToolConfig("claude"), assets); + const content = JSON.stringify(assets.loadConfigAsset("claude", "settings.json"), null, 2); + + expect(files.find((f) => f.relativePath === ".claude/settings.json")).toStrictEqual( + new InstallationFile({ + relativePath: ".claude/settings.json", + content, + hash: hasher.hash(content), + }) + ); + }); + + it("writes a text asset verbatim", async () => { + const asset = assets.loadConfigAsset("codex", "config.toml"); + if (typeof asset !== "string") throw new Error("the codex config asset is text"); + + const files = await generate(getToolConfig("codex"), assets); + + expect(files.find((f) => f.relativePath === ".codex/config.toml")?.content).toBe(asset); + }); + + it("adds only what a capability loads for a tool that declares no config asset path", async () => { + const withoutAssets = await pathsFor("copilot"); + + expect(await pathsFor("copilot", assets)).toStrictEqual( + [...withoutAssets, ".vscode/settings.json"].sort() + ); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore/resolve-restore-decision.unit.test.ts b/cli/tests/contexts/framework/application/restore/resolve-restore-decision.unit.test.ts new file mode 100644 index 000000000..05519c562 --- /dev/null +++ b/cli/tests/contexts/framework/application/restore/resolve-restore-decision.unit.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { ResolveRestoreDecisionUseCase } from "../../../../../src/contexts/framework/application/restore/resolve-restore-decision.js"; +import { InputRequiredError } from "../../../../../src/kernel/errors.js"; +import { ScriptedPrompter } from "../../../../helpers/ports/scripted-prompter.js"; + +const PATH = "a.md"; + +function neverAsked(): ScriptedPrompter { + return new ScriptedPrompter([]); +} + +describe("ResolveRestoreDecisionUseCase", () => { + it("restores a deleted file without asking, whatever the mode", async () => { + const useCase = new ResolveRestoreDecisionUseCase(neverAsked()); + + const keep = await useCase.execute({ + relativePath: PATH, + reason: "deleted", + force: false, + interactive: false, + }); + + expect(keep).toBe(false); + }); + + it("refuses a modified file when it can neither force nor ask", async () => { + const useCase = new ResolveRestoreDecisionUseCase(neverAsked()); + + await expect( + useCase.execute({ relativePath: PATH, reason: "modified", force: false, interactive: false }) + ).rejects.toThrow( + new InputRequiredError("Use --force to overwrite modified files in non-interactive mode.") + ); + }); + + it("asks about a modified file when it may ask but not force", async () => { + const useCase = new ResolveRestoreDecisionUseCase( + new ScriptedPrompter([ScriptedPrompter.answer.conflict("keep")]) + ); + + const keep = await useCase.execute({ + relativePath: PATH, + reason: "modified", + force: false, + interactive: true, + }); + + expect(keep).toBe(true); + }); + + it("overwrites a modified file without asking when forced, even where it could ask", async () => { + const useCase = new ResolveRestoreDecisionUseCase(neverAsked()); + + const keep = await useCase.execute({ + relativePath: PATH, + reason: "modified", + force: true, + interactive: true, + }); + + expect(keep).toBe(false); + }); + + it("overwrites a modified file without asking when forced with no one to ask", async () => { + const useCase = new ResolveRestoreDecisionUseCase(neverAsked()); + + const keep = await useCase.execute({ + relativePath: PATH, + reason: "modified", + force: true, + interactive: false, + }); + + expect(keep).toBe(false); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore/restore-merge-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore/restore-merge-files-use-case.unit.test.ts index 3aa70d710..5265550cf 100644 --- a/cli/tests/contexts/framework/application/restore/restore-merge-files-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore/restore-merge-files-use-case.unit.test.ts @@ -372,3 +372,71 @@ describe("RestoreMergeFilesUseCase", () => { }); }); }); + +describe("RestoreMergeFilesUseCase — the entries it hands back", () => { + const distContent = JSON.stringify({ a: "framework-a" }); + + function distMapFor(deps: Awaited>) { + return new Map([ + [ + "settings.json", + new InstallationFile({ + relativePath: "settings.json", + content: distContent, + hash: deps.hasher.hash(distContent), + mergeStrategy: "framework-prime", + }), + ], + ]); + } + + it("re-reads the entries of a restored merge file from what landed on disk", async () => { + const deps = await buildDeps(); + const useCase = new RestoreMergeFilesUseCase(deps.fs, deps.hasher, new OverwritePrompter()); + + const result = await useCase.execute({ + mergeFiles: [ + { + relativePath: "settings.json", + sectionKey: null, + entries: { a: deps.hasher.hash(JSON.stringify("original-a")) }, + }, + ], + distMap: distMapFor(deps), + projectRoot: PROJECT_ROOT, + force: true, + interactive: false, + fileFilter: null, + }); + + expect(result?.updatedMergeFiles).toStrictEqual([ + { + relativePath: "settings.json", + sectionKey: null, + entries: { a: deps.hasher.hash(JSON.stringify("framework-a")) }, + }, + ]); + }); + + it("hands back a kept merge file's entries exactly as they were tracked", async () => { + const deps = await buildDeps(); + await deps.fs.writeFile(join(PROJECT_ROOT, "settings.json"), JSON.stringify({ a: "disk" })); + const tracked: MergeFileEntry = { + relativePath: "settings.json", + sectionKey: null, + entries: { a: deps.hasher.hash(JSON.stringify("original-a")) }, + }; + const useCase = new RestoreMergeFilesUseCase(deps.fs, deps.hasher, new KeepPrompter()); + + const result = await useCase.execute({ + mergeFiles: [tracked], + distMap: distMapFor(deps), + projectRoot: PROJECT_ROOT, + force: false, + interactive: true, + fileFilter: null, + }); + + expect(result?.updatedMergeFiles).toStrictEqual([tracked]); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore/restore-regular-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore/restore-regular-files-use-case.unit.test.ts index 81c06f43a..119075972 100644 --- a/cli/tests/contexts/framework/application/restore/restore-regular-files-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore/restore-regular-files-use-case.unit.test.ts @@ -307,3 +307,37 @@ describe("RestoreRegularFilesUseCase", () => { expect(deps.fs.getFile(join(PROJECT_ROOT, "a.md"))).toBe("disk modified content"); }); }); + +describe("RestoreRegularFilesUseCase — the files it hands back", () => { + it("hands back a restored file under its new hash and with no content", async () => { + const deps = await buildDeps(); + const useCase = new RestoreRegularFilesUseCase(deps.fs, new OverwritePrompter()); + const distMap = new Map([ + [ + "a.md", + new InstallationFile({ + relativePath: "a.md", + content: "framework content", + hash: deps.hasher.hash("framework content"), + }), + ], + ]); + + const result = await useCase.execute({ + manifestFiles: [{ relativePath: "a.md", hash: deps.hasher.hash("original content") }], + distMap, + projectRoot: PROJECT_ROOT, + force: true, + interactive: false, + fileFilter: null, + }); + + expect(result?.updatedFiles).toStrictEqual([ + new InstallationFile({ + relativePath: "a.md", + content: "", + hash: deps.hasher.hash("framework content"), + }), + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/restore/restore-tool-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore/restore-tool-files-use-case.unit.test.ts new file mode 100644 index 000000000..075d937e8 --- /dev/null +++ b/cli/tests/contexts/framework/application/restore/restore-tool-files-use-case.unit.test.ts @@ -0,0 +1,219 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { + type RestoreToolFilesOptions, + RestoreToolFilesUseCase, +} from "../../../../../src/contexts/framework/application/restore/restore-tool-files-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { + CONFIG_VSCODE_EXTENSIONS, + CONFIG_VSCODE_KEYBINDINGS, +} from "../../../../../src/contexts/tools/domain/capabilities/config-refs.js"; +import { FrameworkDescriptor } from "../../../../../src/contexts/translate/domain/canon.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import type { Prompter } from "../../../../../src/kernel/ports/prompter.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakePlatform } from "../../../../helpers/ports/fake-platform.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { KeepPrompter, OverwritePrompter } from "../../../../helpers/ports/scripted-prompter.js"; + +const PROJECT_ROOT = "/test-project"; +const KEYBINDINGS = ".vscode/keybindings.json"; +const EXTENSIONS = ".vscode/extensions.json"; +const GHOST = ".vscode/ghost.json"; +const KEYBINDINGS_CONTENT = '[{"key":"ctrl+k","command":"noop"}]'; +const EXTENSIONS_CONTENT = '{"recommendations":["a.ext"]}'; + +const descriptor = new FrameworkDescriptor({ + version: "test", + contentSections: [], + templateRefs: [], + configRefs: [ + { name: CONFIG_VSCODE_KEYBINDINGS, path: "config/vscode/keybindings.json" }, + { name: CONFIG_VSCODE_EXTENSIONS, path: "config/vscode/extensions.json" }, + ], +}); + +function contentFiles(extensions = EXTENSIONS_CONTENT): Map { + return new Map([ + ["config/vscode/keybindings.json", KEYBINDINGS_CONTENT], + ["config/vscode/extensions.json", extensions], + ]); +} + +interface Installed { + fs: InMemoryFileAdapter; + hasher: DeterministicHasher; + manifest: Manifest; +} + +function installedVscode(extraTracked: string[] = []): Installed { + const hasher = new DeterministicHasher(); + const fs = new InMemoryFileAdapter( + { + [join(PROJECT_ROOT, KEYBINDINGS)]: KEYBINDINGS_CONTENT, + [join(PROJECT_ROOT, EXTENSIONS)]: EXTENSIONS_CONTENT, + }, + hasher + ); + const manifest = Manifest.create(); + manifest.addTool( + "vscode", + "test", + [KEYBINDINGS, ...extraTracked].map( + (relativePath) => + new InstallationFile({ + relativePath, + content: "", + hash: hasher.hash(relativePath === KEYBINDINGS ? KEYBINDINGS_CONTENT : relativePath), + }) + ), + [ + { + relativePath: EXTENSIONS, + sectionKey: null, + entries: { recommendations: hasher.hash(JSON.stringify(["a.ext"])) }, + }, + ] + ); + return { fs, hasher, manifest }; +} + +function restore( + installed: Installed, + overrides: Partial = {}, + prompter: Prompter = new OverwritePrompter(), + logger = new CapturingLogger() +) { + return new RestoreToolFilesUseCase( + installed.fs, + installed.hasher, + logger, + new FakePlatform("linux"), + prompter + ).execute({ + toolId: "vscode", + manifest: installed.manifest, + descriptor, + contentFiles: contentFiles(), + projectRoot: PROJECT_ROOT, + version: "test", + force: true, + interactive: false, + fileFilter: null, + ...overrides, + }); +} + +describe("RestoreToolFilesUseCase — what it reports", () => { + it("announces the tool it is checking", async () => { + const installed = installedVscode(); + const logger = new CapturingLogger(); + + await restore(installed, {}, new OverwritePrompter(), logger); + + expect(logger.infoMessages).toStrictEqual(["Checking vscode for files to restore..."]); + }); + + it("reports nothing to restore when every tracked file and key is intact", async () => { + const result = await restore(installedVscode()); + + expect(result).toStrictEqual({ + toolId: "vscode", + nothingToRestore: true, + restored: [], + kept: [], + unrestorable: [], + }); + }); + + it("lists what it restored and what it could not, across both sections", async () => { + const installed = installedVscode([GHOST]); + await installed.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + await installed.fs.deleteFile(join(PROJECT_ROOT, EXTENSIONS)); + + const result = await restore(installed); + + expect(result).toStrictEqual({ + toolId: "vscode", + nothingToRestore: false, + restored: [KEYBINDINGS, EXTENSIONS], + kept: [], + unrestorable: [GHOST], + }); + }); + + it("lists a modified file the user chose to keep", async () => { + const installed = installedVscode(); + await installed.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + + const result = await restore( + installed, + { force: false, interactive: true }, + new KeepPrompter() + ); + + expect(result).toStrictEqual({ + toolId: "vscode", + nothingToRestore: false, + restored: [], + kept: [KEYBINDINGS], + unrestorable: [], + }); + }); +}); + +describe("RestoreToolFilesUseCase — what it records", () => { + it("keeps the version the tool was installed at, not the one the restore runs with", async () => { + const installed = installedVscode(); + await installed.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + + await restore(installed, { version: "9.9.9" }); + + expect(installed.manifest.getToolVersion("vscode")).toBe("test"); + }); + + it("keeps tracking the merge files when only a regular file was restored", async () => { + const installed = installedVscode(); + const before = [...installed.manifest.getMergeFiles("vscode")]; + await installed.fs.writeFile(join(PROJECT_ROOT, KEYBINDINGS), "[]"); + + await restore(installed); + + expect(installed.manifest.getMergeFiles("vscode")).toStrictEqual(before); + }); + + it("keeps tracking the regular files when only a merge file was restored", async () => { + const installed = installedVscode(); + const before = [...installed.manifest.getToolFiles("vscode")]; + await installed.fs.deleteFile(join(PROJECT_ROOT, EXTENSIONS)); + + await restore(installed); + + expect(installed.manifest.getToolFiles("vscode")).toStrictEqual(before); + }); + + it("tracks every key the distribution now writes into a restored merge file", async () => { + const installed = installedVscode(); + await installed.fs.deleteFile(join(PROJECT_ROOT, EXTENSIONS)); + + await restore(installed, { + contentFiles: contentFiles( + '{"recommendations":["a.ext"],"unwantedRecommendations":["b.ext"]}' + ), + }); + + expect(installed.manifest.getMergeFiles("vscode")).toStrictEqual([ + { + relativePath: EXTENSIONS, + sectionKey: null, + entries: { + recommendations: installed.hasher.hash(JSON.stringify(["a.ext"])), + unwantedRecommendations: installed.hasher.hash(JSON.stringify(["b.ext"])), + }, + }, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/uninstall-ide-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-ide-use-case.unit.test.ts index 7e5139b04..f03e81bf9 100644 --- a/cli/tests/contexts/framework/application/uninstall-ide-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-ide-use-case.unit.test.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { UninstallIdeUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-ide-use-case.js"; import { UninstallToolsUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-tools-use-case.js"; +import { NoManifestError, ToolNotInstalledError } from "../../../../src/kernel/errors.js"; import { buildUnitDeps, initProject, installTool } from "../../../helpers/ports/build-unit-deps.js"; const PROJECT_ROOT = "/test-project"; @@ -78,3 +79,40 @@ describe("UninstallIdeUseCase — merge-file entries", () => { expect(manifest?.hasTool("vscode")).toBe(false); }); }); + +describe("UninstallIdeUseCase — refusals and report", () => { + it("refuses a project that has no manifest", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + + await expect( + makeUseCase(deps).execute({ toolId: "vscode", projectRoot: PROJECT_ROOT }) + ).rejects.toThrow(NoManifestError); + }); + + it("refuses a tool that is not installed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + + await expect( + makeUseCase(deps).execute({ toolId: "vscode", projectRoot: PROJECT_ROOT }) + ).rejects.toThrow(ToolNotInstalledError); + }); + + it("reports every file it removed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await installTool(deps, PROJECT_ROOT, "vscode"); + + const result = await makeUseCase(deps).execute({ toolId: "vscode", projectRoot: PROJECT_ROOT }); + + expect(result).toStrictEqual({ + toolId: "vscode", + fileCount: 3, + deletedFiles: [ + ".vscode/keybindings.json", + ".vscode/extensions.json", + ".vscode/settings.json", + ], + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts index cb98b8ce1..34f77639f 100644 --- a/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts @@ -1,12 +1,18 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import { PluginAddUseCase } from "../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { UninstallPluginUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-plugin-use-case.js"; import { UninstallUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; -import { PluginNotFoundError } from "../../../../src/kernel/errors.js"; +import { NoManifestError, PluginNotFoundError } from "../../../../src/kernel/errors.js"; import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; @@ -65,3 +71,68 @@ describe("UninstallUseCase — plugin scope", () => { ).rejects.toThrow(PluginNotFoundError); }); }); + +describe("UninstallPluginUseCase — which plugin, on which tools", () => { + const HASH = "abc123abc123abc123abc123abc123ab"; + + function pluginNamed(name: string): InstalledPlugin { + return InstalledPlugin.fromJSON({ + name, + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: { [`commands/${name}.md`]: HASH }, + scope: "project", + }); + } + + function uninstallOver(manifest: Manifest | null) { + return new UninstallPluginUseCase( + new InMemoryFileAdapter(), + new InMemoryManifestRepository(manifest) + ); + } + + it("refuses a project that has no manifest", async () => { + await expect( + uninstallOver(null).execute({ pluginName: "sample", toolIds: [], projectRoot: PROJECT_ROOT }) + ).rejects.toThrow(NoManifestError); + }); + + it("removes the plugin only from the tools it was asked about", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addTool("codex", "1.0.0", []); + manifest.addPlugin("claude", pluginNamed("sample")); + manifest.addPlugin("codex", pluginNamed("sample")); + + const results = await uninstallOver(manifest).execute({ + pluginName: "sample", + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(results).toStrictEqual([ + { toolId: "claude", fileCount: 1, deletedFiles: ["commands/sample.md"] }, + ]); + expect(manifest.getPlugins("codex").map((p) => p.name)).toStrictEqual(["sample"]); + }); + + it("removes only the plugin bearing the name asked for", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin("claude", pluginNamed("sample")); + manifest.addPlugin("claude", pluginNamed("other")); + + const results = await uninstallOver(manifest).execute({ + pluginName: "other", + toolIds: [], + projectRoot: PROJECT_ROOT, + }); + + expect(results).toStrictEqual([ + { toolId: "claude", fileCount: 1, deletedFiles: ["commands/other.md"] }, + ]); + expect(manifest.getPlugins("claude").map((p) => p.name)).toStrictEqual(["sample"]); + }); +}); diff --git a/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts index a97234d58..d8340a1a3 100644 --- a/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts @@ -1,10 +1,18 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { UninstallToolsUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-tools-use-case.js"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { type FileHash, InstallationFile } from "../../../../src/kernel/file.js"; +import type { MergeFileEntry } from "../../../../src/kernel/merge.js"; +import type { ToolId } from "../../../../src/kernel/tool.js"; import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; const PROJECT_ROOT = "/test-project"; @@ -49,3 +57,221 @@ describe("UninstallToolsUseCase — cursor plugin file (user-scope)", () => { expect(fs.deletedPaths).not.toContain(join(PROJECT_ROOT, PLUGIN_KEY)); }); }); + +const hasher = new DeterministicHasher(); +const SETTINGS = ".claude/settings.json"; +const AGENTS = "AGENTS.md"; +const MCP = ".mcp.json"; + +function tracked(relativePath: string, content: string): InstallationFile { + return new InstallationFile({ relativePath, content: "", hash: hasher.hash(content) }); +} + +function merging( + relativePath: string, + values: Record, + sectionKey: string | null = null +): MergeFileEntry { + const entries: Record = {}; + for (const [key, value] of Object.entries(values)) { + entries[key] = hasher.hash(JSON.stringify(value)); + } + return { relativePath, sectionKey, entries }; +} + +function project(seed: Record): { fs: InMemoryFileAdapter; manifest: Manifest } { + const absolute: Record = {}; + for (const [relativePath, content] of Object.entries(seed)) { + absolute[join(PROJECT_ROOT, relativePath)] = content; + } + return { fs: new InMemoryFileAdapter(absolute, hasher), manifest: Manifest.create() }; +} + +function remove( + target: { fs: InMemoryFileAdapter; manifest: Manifest }, + toolIds: ToolId[], + logger = new CapturingLogger() +) { + return new UninstallToolsUseCase(target.fs, logger).execute({ + toolIds, + manifest: target.manifest, + projectRoot: PROJECT_ROOT, + }); +} + +describe("UninstallToolsUseCase — regular files", () => { + it("announces each tool it removes", async () => { + const target = project({}); + target.manifest.addTool("claude", "test", []); + const logger = new CapturingLogger(); + + await remove(target, ["claude"], logger); + + expect(logger.infoMessages).toStrictEqual(["Removing claude files..."]); + }); + + it("deletes the files a tool tracks and reports them, then forgets the tool", async () => { + const target = project({ [SETTINGS]: "{}", [AGENTS]: "# Agents" }); + target.manifest.addTool("claude", "test", [ + tracked(SETTINGS, "{}"), + tracked(AGENTS, "# Agents"), + ]); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([ + { toolId: "claude", fileCount: 2, deletedFiles: [SETTINGS, AGENTS] }, + ]); + expect(target.fs.listAll()).toStrictEqual([]); + expect(target.manifest.hasTool("claude")).toBe(false); + }); + + it("keeps a file another installed tool still tracks", async () => { + const target = project({ [SETTINGS]: "{}", [AGENTS]: "# Agents" }); + target.manifest.addTool("claude", "test", [ + tracked(SETTINGS, "{}"), + tracked(AGENTS, "# Agents"), + ]); + target.manifest.addTool("codex", "test", [tracked(AGENTS, "# Agents")]); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 1, deletedFiles: [SETTINGS] }]); + expect(target.fs.has(join(PROJECT_ROOT, AGENTS))).toBe(true); + }); + + it("deletes a file shared only between the tools removed together", async () => { + const target = project({ [AGENTS]: "# Agents" }); + target.manifest.addTool("claude", "test", [tracked(AGENTS, "# Agents")]); + target.manifest.addTool("codex", "test", [tracked(AGENTS, "# Agents")]); + + const results = await remove(target, ["claude", "codex"]); + + expect(results).toStrictEqual([ + { toolId: "claude", fileCount: 1, deletedFiles: [AGENTS] }, + { toolId: "codex", fileCount: 1, deletedFiles: [AGENTS] }, + ]); + expect(target.fs.has(join(PROJECT_ROOT, AGENTS))).toBe(false); + }); + + it("keeps a file another installed tool merges into", async () => { + const target = project({ [MCP]: '{"hub":{}}' }); + target.manifest.addTool("claude", "test", [tracked(MCP, '{"hub":{}}')]); + target.manifest.addTool("codex", "test", [], [merging(MCP, { hub: {} })]); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 0, deletedFiles: [] }]); + expect(target.fs.has(join(PROJECT_ROOT, MCP))).toBe(true); + }); +}); + +describe("UninstallToolsUseCase — merge files", () => { + const HUB = { command: "hub" }; + const PLAYWRIGHT = { command: "npx" }; + + it("deletes a merge file an AI tool alone owns, even one it cannot parse", async () => { + const target = project({ [MCP]: "not json" }); + target.manifest.addTool("claude", "test", [], [merging(MCP, { hub: HUB })]); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 1, deletedFiles: [MCP] }]); + expect(target.fs.has(join(PROJECT_ROOT, MCP))).toBe(false); + }); + + it("deletes a section-scoped merge file an AI tool alone owns even when it tracks no key in it", async () => { + const target = project({ [MCP]: '{"x":1}', "codex.json": "{}" }); + target.manifest.addTool("claude", "test", [], [merging(MCP, {}, "mcpServers")]); + target.manifest.addTool("codex", "test", [], [merging("codex.json", {})]); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 1, deletedFiles: [MCP] }]); + expect(target.fs.has(join(PROJECT_ROOT, MCP))).toBe(false); + }); + + it("strips its own keys from a merge file a remaining tool owns and does not count it", async () => { + const target = project({ [MCP]: JSON.stringify({ hub: HUB, playwright: PLAYWRIGHT }) }); + target.manifest.addTool("claude", "test", [], [merging(MCP, { hub: HUB })]); + target.manifest.addTool( + "codex", + "test", + [], + [merging("codex.json", {}), merging(MCP, { playwright: PLAYWRIGHT })] + ); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 0, deletedFiles: [] }]); + expect(JSON.parse(target.fs.getFile(join(PROJECT_ROOT, MCP)) ?? "")).toStrictEqual({ + playwright: PLAYWRIGHT, + }); + }); + + it("keeps a merge file when any remaining tool owns it, not only when all do", async () => { + const target = project({ [MCP]: JSON.stringify({ hub: HUB, playwright: PLAYWRIGHT }) }); + target.manifest.addTool("claude", "test", [], [merging(MCP, { hub: HUB })]); + target.manifest.addTool("codex", "test", [], [merging(MCP, { playwright: PLAYWRIGHT })]); + target.manifest.addTool("copilot", "test", [], [merging("copilot.json", {})]); + + await remove(target, ["claude"]); + + expect(JSON.parse(target.fs.getFile(join(PROJECT_ROOT, MCP)) ?? "")).toStrictEqual({ + playwright: PLAYWRIGHT, + }); + }); + + it("does not count a merge file already gone from disk", async () => { + const target = project({}); + target.manifest.addTool("claude", "test", [], [merging(MCP, { hub: HUB })]); + + const results = await remove(target, ["claude"]); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 0, deletedFiles: [] }]); + }); + + it("leaves an IDE merge file byte-identical when it tracks no key in it", async () => { + const target = project({ ".vscode/settings.json": '{"a":1}' }); + target.manifest.addTool("vscode", "test", [], [merging(".vscode/settings.json", {})]); + + const results = await remove(target, ["vscode"]); + + expect(results).toStrictEqual([{ toolId: "vscode", fileCount: 0, deletedFiles: [] }]); + expect(target.fs.getFile(join(PROJECT_ROOT, ".vscode/settings.json"))).toBe('{"a":1}'); + }); + + it("strips an IDE tool's keys and deletes the file once nothing is left", async () => { + const target = project({ ".vscode/extensions.json": '{"recommendations":["a"]}' }); + target.manifest.addTool( + "vscode", + "test", + [], + [merging(".vscode/extensions.json", { recommendations: ["a"] })] + ); + + const results = await remove(target, ["vscode"]); + + expect(results).toStrictEqual([ + { toolId: "vscode", fileCount: 1, deletedFiles: [".vscode/extensions.json"] }, + ]); + expect(target.fs.has(join(PROJECT_ROOT, ".vscode/extensions.json"))).toBe(false); + }); + + it("strips an IDE tool's keys and keeps the user's own", async () => { + const target = project({ ".vscode/settings.json": '{"editor.tabSize":2,"user.key":7}' }); + target.manifest.addTool( + "vscode", + "test", + [], + [merging(".vscode/settings.json", { "editor.tabSize": 2 })] + ); + + const results = await remove(target, ["vscode"]); + + expect(results).toStrictEqual([{ toolId: "vscode", fileCount: 0, deletedFiles: [] }]); + expect( + JSON.parse(target.fs.getFile(join(PROJECT_ROOT, ".vscode/settings.json")) ?? "") + ).toStrictEqual({ "user.key": 7 }); + }); +}); diff --git a/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts index 334fa83c6..167230af4 100644 --- a/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts @@ -7,8 +7,18 @@ import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { UninstallUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { + InputRequiredError, + NoManifestError, + ToolNotInstalledError, +} from "../../../../src/kernel/errors.js"; import type { ToolId } from "../../../../src/kernel/tool.js"; import { buildUnitDeps, initProject, installTool } from "../../../helpers/ports/build-unit-deps.js"; +import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; const PROJECT_ROOT = "/test-project"; @@ -120,3 +130,84 @@ describe("uninstall", () => { }); }); }); + +describe("uninstall — refusals", () => { + it("refuses to remove nothing, naming every tool it knows", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + + await expect( + new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ + toolIds: [], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + }) + ).rejects.toThrow( + new InputRequiredError( + "At least one tool ID is required. Valid tools: claude, cursor, copilot, opencode, codex, vscode" + ) + ); + }); + + it("refuses a project that has no manifest", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + + await expect( + new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + }) + ).rejects.toThrow(NoManifestError); + }); + + it("refuses a tool that is not installed", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + + await expect( + new UninstallUseCase(deps.fs, deps.manifestRepo, deps.logger).execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + mcpFilter: [], + }) + ).rejects.toThrow(ToolNotInstalledError); + }); +}); + +describe("uninstall — an MCP filter", () => { + it("strips the named entries and leaves the tool installed", async () => { + const hasher = new DeterministicHasher(); + const servers = { github: { command: "gh" }, playwright: { command: "npx" } }; + const fs = new InMemoryFileAdapter( + { [join(PROJECT_ROOT, ".mcp.json")]: JSON.stringify({ mcpServers: servers }) }, + hasher + ); + const manifest = Manifest.create(); + manifest.addTool( + "claude", + "test", + [], + [ + { + relativePath: ".mcp.json", + sectionKey: "mcpServers", + entries: { + github: hasher.hash(JSON.stringify(servers.github)), + playwright: hasher.hash(JSON.stringify(servers.playwright)), + }, + }, + ] + ); + const repo = new InMemoryManifestRepository(manifest); + + const results = await new UninstallUseCase(fs, repo, new CapturingLogger()).execute({ + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + mcpFilter: ["github"], + }); + + expect(results).toStrictEqual([{ toolId: "claude", fileCount: 1, deletedFiles: ["github"] }]); + expect(repo.getCurrent()?.hasTool("claude")).toBe(true); + }); +}); diff --git a/cli/tests/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.unit.test.ts new file mode 100644 index 000000000..6a122cbf3 --- /dev/null +++ b/cli/tests/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.unit.test.ts @@ -0,0 +1,108 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { UninstallMcpExclusionUseCase } from "../../../../../src/contexts/framework/application/uninstall/uninstall-mcp-exclusion-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { FileHash } from "../../../../../src/kernel/file.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/test-project"; +const MCP = ".mcp.json"; +const SECTION = "mcpServers"; +const hasher = new DeterministicHasher(); +const GITHUB = { command: "gh" }; +const PLAYWRIGHT = { command: "npx" }; +const CONTENT = JSON.stringify({ [SECTION]: { github: GITHUB, playwright: PLAYWRIGHT } }); + +function hashOf(value: unknown): FileHash { + return hasher.hash(JSON.stringify(value)); +} + +function claudeMerging( + content: string, + entries: Record, + sectionKey: string | null = SECTION +): { fs: InMemoryFileAdapter; manifest: Manifest } { + const fs = new InMemoryFileAdapter({ [join(PROJECT_ROOT, MCP)]: content }, hasher); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", [], [{ relativePath: MCP, sectionKey, entries }]); + return { fs, manifest }; +} + +function twoServers() { + return claudeMerging(CONTENT, { github: hashOf(GITHUB), playwright: hashOf(PLAYWRIGHT) }); +} + +function exclude( + project: { fs: InMemoryFileAdapter; manifest: Manifest }, + mcpFilter: string[], + logger = new CapturingLogger() +) { + return new UninstallMcpExclusionUseCase(project.fs, logger).execute({ + toolId: "claude", + manifest: project.manifest, + projectRoot: PROJECT_ROOT, + mcpFilter, + }); +} + +describe("UninstallMcpExclusionUseCase", () => { + it("announces the tool it strips entries from", async () => { + const logger = new CapturingLogger(); + + await exclude(twoServers(), ["github"], logger); + + expect(logger.infoMessages).toStrictEqual(["Removing MCP entries from claude..."]); + }); + + it("removes the named entries that exist and reports exactly those", async () => { + const project = twoServers(); + + const result = await exclude(project, ["github", "absent"]); + + expect(result).toStrictEqual({ toolId: "claude", fileCount: 1, deletedFiles: ["github"] }); + expect(JSON.parse(project.fs.getFile(join(PROJECT_ROOT, MCP)) ?? "")).toStrictEqual({ + [SECTION]: { playwright: PLAYWRIGHT }, + }); + }); + + it("stops tracking the entries it removed", async () => { + const project = twoServers(); + + await exclude(project, ["github"]); + + expect(project.manifest.getMergeFiles("claude")).toStrictEqual([ + { relativePath: MCP, sectionKey: SECTION, entries: { playwright: hashOf(PLAYWRIGHT) } }, + ]); + }); + + it("records each removal so a later install leaves the entry out", async () => { + const project = twoServers(); + + await exclude(project, ["github"]); + + expect(project.manifest.getExcludedMcp("claude")).toStrictEqual([ + { configPath: MCP, entryKey: "github" }, + ]); + }); + + it("leaves a file byte-identical when none of the named entries is in it", async () => { + const project = twoServers(); + + const result = await exclude(project, ["absent"]); + + expect(result).toStrictEqual({ toolId: "claude", fileCount: 0, deletedFiles: [] }); + expect(project.fs.getFile(join(PROJECT_ROOT, MCP))).toBe(CONTENT); + }); + + it("leaves a file tracked without a section untouched even when it holds a named key", async () => { + const content = JSON.stringify({ github: GITHUB }); + const project = claudeMerging(content, { github: hashOf(GITHUB) }, null); + + const result = await exclude(project, ["github"]); + + expect(result).toStrictEqual({ toolId: "claude", fileCount: 0, deletedFiles: [] }); + expect(project.fs.getFile(join(PROJECT_ROOT, MCP))).toBe(content); + }); +}); From 15b5067c20f4535bee7c330762b2b87431d335dc Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:35:14 +0200 Subject: [PATCH 09/12] test(cli): kill the surviving mutants of the marketplace flows Marketplace sync settings, remove and check: 60 tests, each shown red first against the mutant it names. Framework mutation score: 72.2 before the series, 95.4 after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../marketplace-check-use-case.unit.test.ts | 104 +++++ .../marketplace-remove-use-case.unit.test.ts | 191 +++++++- ...tplace-source-conflict.integration.test.ts | 50 +- ...etplace-sync-narrowing.integration.test.ts | 45 ++ ...sync-native-activation.integration.test.ts | 63 +++ ...-sync-rollback-refusal.integration.test.ts | 86 +++- ...ync-settings-migration.integration.test.ts | 442 +++++++++++++++++- ...ings-use-case-manifest-writes.unit.test.ts | 356 ++++++++++++++ .../marketplace-sync-settings.unit.test.ts | 192 +++++++- ...-sync-shared-source-reference.unit.test.ts | 66 +++ 10 files changed, 1572 insertions(+), 23 deletions(-) create mode 100644 cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts diff --git a/cli/tests/contexts/framework/application/flows/marketplace-check-use-case.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-check-use-case.unit.test.ts index 30acf33bb..677aeabc3 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-check-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-check-use-case.unit.test.ts @@ -142,3 +142,107 @@ describe("MarketplaceCheckUseCase", () => { expect(result.skipped[0]?.error).toBeDefined(); }); }); + +function fixtureMarketplace(name: string): Marketplace { + return Marketplace.create({ + name, + source: { kind: "local", path: VALID_FIXTURE }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }); +} + +function installedFrom(marketplace: string, name: string): InstalledPlugin { + return InstalledPlugin.fromJSON({ + name, + source: { kind: "github", repo: `owner/${name}` }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + marketplace, + }); +} + +async function manifestWith( + manifestRepo: InMemoryManifestRepository, + ...plugins: readonly InstalledPlugin[] +): Promise { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + for (const plugin of plugins) manifest.addPlugin("claude", plugin); + await manifestRepo.save(manifest); +} + +describe("the staleness window", () => { + it("honours a window narrower than the default", async () => { + const { useCase, registry } = await buildUseCase(); + await registry.save(PROJECT_ROOT, fixtureMarketplace("recent")); + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); + await registry.updateLastFetched(PROJECT_ROOT, "recent", "project", threeDaysAgo); + + const narrowed = await useCase.execute({ projectRoot: PROJECT_ROOT, staleMaxDays: 1 }); + const byDefault = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(narrowed.stale.map((m) => m.name)).toStrictEqual(["recent"]); + expect(byDefault.stale).toStrictEqual([]); + }); +}); + +describe("what counts as removed upstream", () => { + it("reports nothing for a plugin the catalog still lists", async () => { + const { useCase, registry, manifestRepo } = await buildUseCase(); + await manifestWith(manifestRepo, installedFrom("awesome", "dev")); + await registry.save(PROJECT_ROOT, fixtureMarketplace("awesome")); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.upstreamRemoved).toStrictEqual([]); + expect(result.skipped).toStrictEqual([]); + }); + + it("reports exactly the plugin the catalog dropped", async () => { + const { useCase, registry, manifestRepo } = await buildUseCase(); + await manifestWith( + manifestRepo, + installedFrom("awesome", "dev"), + installedFrom("awesome", "ghost") + ); + await registry.save(PROJECT_ROOT, fixtureMarketplace("awesome")); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.upstreamRemoved).toStrictEqual([ + { marketplace: "awesome", plugin: "ghost", toolId: "claude" }, + ]); + }); + + it("never diffs a plugin installed from another marketplace against this catalog", async () => { + const { useCase, registry, manifestRepo } = await buildUseCase(); + await manifestWith(manifestRepo, installedFrom("elsewhere", "ghost")); + await registry.save(PROJECT_ROOT, fixtureMarketplace("awesome")); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.upstreamRemoved).toStrictEqual([]); + }); + + it("reports nothing about installed plugins when the marketplace has no catalog to compare against", async () => { + const { useCase, registry, manifestRepo } = await buildUseCase(); + await manifestWith(manifestRepo, installedFrom("empty", "ghost")); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "empty", + source: { kind: "local", path: "/nonexistent-marketplace-dir" }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.upstreamRemoved).toStrictEqual([]); + expect(result.skipped).toStrictEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts index 90e66e960..44c8a7d5e 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts @@ -17,10 +17,55 @@ import { DeterministicHasher } from "../../../../helpers/ports/deterministic-has import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; -import { KeepPrompter } from "../../../../helpers/ports/scripted-prompter.js"; +import { KeepPrompter, ScriptedPrompter } from "../../../../helpers/ports/scripted-prompter.js"; const PROJECT_ROOT = "/test-project"; +class ConfirmRecordingPrompter extends ScriptedPrompter { + readonly confirmMessages: string[] = []; + + override async confirm(message: string, defaultValue?: boolean): Promise { + this.confirmMessages.push(message); + return super.confirm(message, defaultValue); + } +} + +class SaveCountingManifestRepository extends InMemoryManifestRepository { + saves = 0; + + override async save(manifest: Manifest): Promise { + this.saves += 1; + return super.save(manifest); + } +} + +function marketplaceNamed(name: string): Marketplace { + return Marketplace.create({ + name, + source: { kind: "github", repo: `owner/${name}` }, + scope: "project", + addedAt: "2026-04-29T10:00:00.000Z", + }); +} + +function pluginFrom(marketplace: string, name: string): InstalledPlugin { + return InstalledPlugin.fromMetadata( + name, + "1.0.0", + { kind: "github", repo: `owner/${name}` }, + false, + "project", + marketplace + ); +} + +function manifestWithPlugins(...plugins: readonly InstalledPlugin[]): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + for (const plugin of plugins) manifest.addPlugin("claude", plugin); + return manifest; +} + /** Records every path `deleteFile` is called with, so a test can prove where a plugin's * file actually got deleted from without inspecting private use-case state. */ class RecordingFileAdapter extends InMemoryFileAdapter { @@ -225,4 +270,148 @@ describe("MarketplaceRemoveUseCase", () => { expect(list).toHaveLength(1); expect(list[0]?.name).toBe(FRAMEWORK_MARKETPLACE_NAME); }); + + it("refuses the reserved name with a message naming the command that does remove it", async () => { + const { useCase } = buildUseCase(); + + await expect( + useCase.execute({ + name: FRAMEWORK_MARKETPLACE_NAME, + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }) + ).rejects.toThrow( + new InvalidMarketplaceNameError( + '"aidd-framework" is shared by every project on this machine and is not removed with `aidd marketplace remove` — it is removed with the framework itself, by `aidd clean`, once machine scope lands there.' + ) + ); + }); +}); + +describe("which marketplace a removal takes out", () => { + it("removes the named marketplace, never the first one registered", async () => { + const { useCase, registry } = buildUseCase(); + await registry.save(PROJECT_ROOT, marketplaceNamed("alpha")); + const awesome = marketplaceNamed("awesome"); + await registry.save(PROJECT_ROOT, awesome); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }); + + expect(result).toStrictEqual({ marketplace: awesome, removedPluginCount: 0, orphanCount: 0 }); + expect((await registry.list(PROJECT_ROOT)).map((m) => m.name)).toStrictEqual(["alpha"]); + }); + + it("reports zero orphans when the project has no manifest at all", async () => { + const { useCase, registry } = buildUseCase(); + const awesome = marketplaceNamed("awesome"); + await registry.save(PROJECT_ROOT, awesome); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }); + + expect(result).toStrictEqual({ marketplace: awesome, removedPluginCount: 0, orphanCount: 0 }); + }); +}); + +describe("which plugins a removal orphans", () => { + it("removes the marketplace's own plugins and leaves another marketplace's in place", async () => { + const { useCase, registry, manifestRepo } = buildUseCase(); + await manifestRepo.save( + manifestWithPlugins(pluginFrom("awesome", "sample"), pluginFrom("elsewhere", "other")) + ); + const awesome = marketplaceNamed("awesome"); + await registry.save(PROJECT_ROOT, awesome); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }); + + expect(result).toStrictEqual({ marketplace: awesome, removedPluginCount: 1, orphanCount: 1 }); + expect((await manifestRepo.load())?.getPlugins("claude").map((p) => p.name)).toStrictEqual([ + "other", + ]); + }); + + it("keeps every orphan when the person declines the cleanup, and still drops the marketplace", async () => { + const { registry, manifestRepo, fs } = buildUseCase(); + const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(false)]); + const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + await manifestRepo.save(manifestWithPlugins(pluginFrom("awesome", "sample"))); + const awesome = marketplaceNamed("awesome"); + await registry.save(PROJECT_ROOT, awesome); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: false, + }); + + expect(result).toStrictEqual({ marketplace: awesome, removedPluginCount: 0, orphanCount: 1 }); + expect((await manifestRepo.load())?.getPlugins("claude").map((p) => p.name)).toStrictEqual([ + "sample", + ]); + expect(await registry.list(PROJECT_ROOT)).toStrictEqual([]); + }); + + it("asks once, naming how many plugins the cleanup would remove", async () => { + const { registry, manifestRepo, fs } = buildUseCase(); + const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(true)]); + const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + await manifestRepo.save( + manifestWithPlugins(pluginFrom("awesome", "sample"), pluginFrom("awesome", "second")) + ); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: false, + }); + + expect(prompter.confirmMessages).toStrictEqual([ + "Remove 2 plugin(s) installed from this marketplace?", + ]); + expect(result.removedPluginCount).toBe(2); + }); + + it("neither asks nor rewrites the manifest when nothing was installed from the marketplace", async () => { + const { registry, fs } = buildUseCase(); + const manifestRepo = new SaveCountingManifestRepository( + manifestWithPlugins(pluginFrom("elsewhere", "other")) + ); + const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(true)]); + const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + + await useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: false }); + + expect(prompter.confirmMessages).toStrictEqual([]); + expect(manifestRepo.saves).toBe(0); + }); + + it("cleans up without asking when the caller auto-confirms", async () => { + const { registry, manifestRepo, fs } = buildUseCase(); + const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(false)]); + const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + await manifestRepo.save(manifestWithPlugins(pluginFrom("awesome", "sample"))); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + + const result = await useCase.execute({ + name: "awesome", + projectRoot: PROJECT_ROOT, + autoConfirm: true, + }); + + expect(prompter.confirmMessages).toStrictEqual([]); + expect(result.removedPluginCount).toBe(1); + }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts index 50b87b2d7..fa8302a70 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts @@ -42,13 +42,25 @@ interface Setup { /** Skips writing at the built dir this project just "built" to, standing in for a build that * reported success but left nothing readable where its own tool profile probes. */ readonly omitRequestedCatalog?: boolean; + readonly fs?: InMemoryFileAdapter; +} + +class RealpathRefusingFileAdapter extends InMemoryFileAdapter { + constructor(private readonly refused: string) { + super(); + } + + override async realpath(path: string): Promise { + if (path === this.refused) throw Object.assign(new Error("EACCES"), { code: "EACCES" }); + return super.realpath(path); + } } async function sync(setup: Setup = {}) { const toolId = setup.toolId ?? "claude"; const aiddName = setup.aiddName ?? "probe-mkt"; const catalogName = setup.catalogName ?? aiddName; - const fs = new InMemoryFileAdapter(); + const fs = setup.fs ?? new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(); const registry = new InMemoryMarketplaceRegistry(); const logger = new CapturingLogger(); @@ -230,6 +242,42 @@ describe("the sync guard against a marketplace name a host already holds", () => expect(activator.addedMarketplaces).toEqual(["/built/codex"]); expect(result.errors).toEqual([]); }); + + it("names both sources, the plugin difference, the registry file and the commands to run, in the refusal", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([["probe-mkt", "/other/src"]]), + }); + + const { result } = await sync({ + hostReader, + requestedPluginNames: ["sample-plugin"], + registeredCatalog: { path: "/other/src", pluginNames: ["different-plugin"] }, + }); + + expect(result.errors).toStrictEqual([ + { + scope: "claude", + message: + "Marketplace 'probe-mkt' is already registered from a different catalog: /other/src differs from the one requested, /built/claude — plugins differ (+sample-plugin, -different-plugin), per /home/.claude/plugins/known_marketplaces.json. Run `claude plugin marketplace remove probe-mkt`, then `aidd sync` again to re-register it for this project.", + }, + ]); + }); + + it("registers from the built path as given when that path cannot be resolved", async () => { + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: REGISTRY_LOCATION, + entries: new Map([["probe-mkt", "/built/claude"]]), + }); + + const { result, activator } = await sync({ + hostReader, + fs: new RealpathRefusingFileAdapter("/built/claude"), + }); + + expect(result.errors).toStrictEqual([]); + expect(activator.addedMarketplaces).toStrictEqual(["/built/claude"]); + }); }); describe("when the catalog this project just built cannot be read back", () => { diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts index 1b09fdfba..db26b9401 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts @@ -184,4 +184,49 @@ describe("marketplaceNames narrows a sync run to the marketplaces named", () => expect(activator.addedMarketplaces.sort()).toEqual(["/built/market-a", "/built/market-b"]); expect(activator.enabledPlugins.sort()).toEqual(["plugin-a@market-a", "plugin-b@market-b"]); }); + + it("records exactly the named marketplace and its ref on a first narrowed run", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifestRepo } = build(activator); + await registry.save(PROJECT_ROOT, marketplace("market-a")); + await registry.save(PROJECT_ROOT, marketplace("market-b")); + + await useCase.execute({ projectRoot: PROJECT_ROOT, marketplaceNames: ["market-b"] }); + + expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ + binary: "claude", + marketplaces: [{ alias: "market-b", hostName: "market-b" }], + pluginRefs: ["plugin-b@market-b"], + }); + }); + + it("drops a stale ref of either touched marketplace when narrowed to both", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, registry, manifest, manifestRepo } = build(activator); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { alias: "market-a", hostName: "market-a" }, + { alias: "market-b", hostName: "market-b" }, + ], + pluginRefs: ["plugin-a@market-a", "plugin-b-old@market-b"], + }); + await manifestRepo.save(manifest); + await registry.save(PROJECT_ROOT, marketplace("market-a")); + await registry.save(PROJECT_ROOT, marketplace("market-b")); + + await useCase.execute({ + projectRoot: PROJECT_ROOT, + marketplaceNames: ["market-a", "market-b"], + }); + + expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ + binary: "claude", + marketplaces: [ + { alias: "market-a", hostName: "market-a" }, + { alias: "market-b", hostName: "market-b" }, + ], + pluginRefs: ["plugin-a@market-a", "plugin-b@market-b"], + }); + }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts index 585b23197..d51217e9b 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts @@ -632,3 +632,66 @@ describe("a narrowed run preserves another alias's refs at a shared hostName (lo expect(recorded?.marketplaces).toContainEqual({ alias: ALIAS_Y, hostName: SHARED_HOST_NAME }); }); }); + +describe("what reclaiming a dead registration says", () => { + const CATALOG_NAME = "aidd-framework-catalog"; + const RECLAIM = + "Marketplace 'aidd-framework-catalog' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back."; + + function reclaimSync(activator: FakeNativePluginActivator) { + const registry = new InMemoryMarketplaceRegistry(); + const fs = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: CATALOG_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const logger = new CapturingLogger(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestWithPlugin(), + registry, + new DeterministicHasher(), + logger, + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + return { useCase, registry, logger }; + } + + it("names the vanished directory's own registration in the one warning it gives", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "dead", + }); + const { useCase, registry, logger } = reclaimSync(activator); + await registry.save(PROJECT_ROOT, marketplace()); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.warnings).toStrictEqual([RECLAIM]); + expect(logger.warnMessages).toStrictEqual([RECLAIM]); + }); + + it("warns for each reclaim step the host's CLI refused, naming the step and the reason", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + throwOnRemove: true, + registrationState: "dead", + }); + const { useCase, registry } = reclaimSync(activator); + await registry.save(PROJECT_ROOT, marketplace()); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.warnings).toStrictEqual([ + RECLAIM, + "Native plugin activation — unregister stale marketplace 'aidd-framework-catalog' skipped: marketplace remove aidd-framework-catalog failed: 'aidd-framework-catalog' is not configured or installed", + "Native plugin activation — register marketplace 'aidd-framework-catalog' skipped: marketplace is already added from a different source; remove it before adding this source", + ]); + expect(result.errors).toStrictEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts index 8a8b4ac8b..5287cc472 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts @@ -1,9 +1,13 @@ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; -import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + Marketplace, +} from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; -import { userBuiltMarketplaceDir } from "../../../../../src/kernel/paths.js"; +import { BUILT_CACHE_SUBDIR, userBuiltMarketplaceDir } from "../../../../../src/kernel/paths.js"; import type { MarketplaceScope } from "../../../../../src/kernel/scope.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; @@ -19,8 +23,8 @@ const REGISTRY_LOCATION = "/home/.claude/plugins/known_marketplaces.json"; const USER_CACHE_ROOT = "/user-cache"; const MARKETPLACE_NAME = "probe-mkt"; -function sharedPath(version: string): string { - return userBuiltMarketplaceDir(USER_CACHE_ROOT, version, MARKETPLACE_NAME, "claude"); +function sharedPath(version: string, name: string = MARKETPLACE_NAME): string { + return userBuiltMarketplaceDir(USER_CACHE_ROOT, version, name, "claude"); } async function sync(options: { @@ -28,7 +32,11 @@ async function sync(options: { registeredPath?: string; registeredVersion?: string; scope?: MarketplaceScope; + name?: string; + recreateFrameworkIfMissing?: boolean; + staleCacheFile?: string; }) { + const name = options.name ?? MARKETPLACE_NAME; const fs = new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(); const registry = new InMemoryMarketplaceRegistry(); @@ -39,30 +47,29 @@ async function sync(options: { await registry.save( PROJECT_ROOT, Marketplace.create({ - name: MARKETPLACE_NAME, + name, source: { kind: "local", path: "/source" }, scope: options.scope ?? "user", addedAt: "2026-01-01T00:00:00Z", }) ); - const builtDir = sharedPath(options.requestedVersion); + const builtDir = sharedPath(options.requestedVersion, name); await fs.writeFile( `${builtDir}/.claude-plugin/marketplace.json`, - JSON.stringify({ name: MARKETPLACE_NAME, version: options.requestedVersion, plugins: [] }) + JSON.stringify({ name, version: options.requestedVersion, plugins: [] }) ); if (options.registeredPath !== undefined) { await fs.writeFile( `${options.registeredPath}/.claude-plugin/marketplace.json`, - JSON.stringify({ name: MARKETPLACE_NAME, version: options.registeredVersion, plugins: [] }) + JSON.stringify({ name, version: options.registeredVersion, plugins: [] }) ); } + if (options.staleCacheFile !== undefined) await fs.writeFile(options.staleCacheFile, "stale"); const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); const hostReader = new FakeHostMarketplaceRegistryReader({ location: REGISTRY_LOCATION, entries: - options.registeredPath === undefined - ? new Map() - : new Map([[MARKETPLACE_NAME, options.registeredPath]]), + options.registeredPath === undefined ? new Map() : new Map([[name, options.registeredPath]]), }); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -75,8 +82,11 @@ async function sync(options: { new Map([["claude", hostReader]]), () => USER_CACHE_ROOT ); - const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - return { result, activator, logger }; + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: options.recreateFrameworkIfMissing, + }); + return { result, activator, logger, manifestRepo, fs }; } describe("the sync write path refuses to roll a host back to an older aidd-framework build", () => { @@ -133,4 +143,54 @@ describe("the sync write path refuses to roll a host back to an older aidd-frame expect(result.errors).toEqual([]); expect(result.warnings).toEqual([]); }); + + it("warns with the whole message: the registry, both versions and the command that fixes it", async () => { + const { result, logger } = await sync({ + requestedVersion: "1.0.0", + registeredPath: sharedPath("2.0.0"), + registeredVersion: "2.0.0", + }); + + const refusal = + "claude's marketplace registry (/home/.claude/plugins/known_marketplaces.json) already carries a newer aidd-framework build, 2.0.0, than this run's own 1.0.0 — not registering this run's build over it. Run `aidd update` to bring this project's CLI to at least the version the host already follows."; + expect(result.warnings).toStrictEqual([refusal]); + expect(logger.warnMessages).toStrictEqual([refusal]); + }); + + it("records the marketplace it left on the newer build as registered under the host's own name", async () => { + const { manifestRepo } = await sync({ + requestedVersion: "1.0.0", + registeredPath: sharedPath("2.0.0"), + registeredVersion: "2.0.0", + }); + + expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ + binary: "claude", + marketplaces: [{ alias: MARKETPLACE_NAME, hostName: MARKETPLACE_NAME }], + pluginRefs: [], + }); + }); + + it("still purges this project's own pre-migration cache after a refused rollback: the host is on a build, not on that cache", async () => { + const staleCacheFile = join( + PROJECT_ROOT, + BUILT_CACHE_SUBDIR, + FRAMEWORK_MARKETPLACE_NAME, + "claude", + "agents", + "some-agent.md" + ); + const { result, fs } = await sync({ + name: FRAMEWORK_MARKETPLACE_NAME, + requestedVersion: "1.0.0", + registeredPath: sharedPath("2.0.0", FRAMEWORK_MARKETPLACE_NAME), + registeredVersion: "2.0.0", + recreateFrameworkIfMissing: true, + staleCacheFile, + }); + + expect(result.warnings).toHaveLength(1); + expect(result.errors).toStrictEqual([]); + expect(fs.has(staleCacheFile)).toBe(false); + }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts index 129465d40..f0885355d 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts @@ -2,13 +2,18 @@ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { MarketplaceRegisterFrameworkUseCase } from "../../../../../src/contexts/distribution/application/marketplace-register-framework-use-case.js"; +import { + type MarketplaceRegisterFramework, + type MarketplaceRegisterFrameworkOptions, + MarketplaceRegisterFrameworkUseCase, +} from "../../../../../src/contexts/distribution/application/marketplace-register-framework-use-case.js"; import { FRAMEWORK_MARKETPLACE_NAME, Marketplace, } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { DoctorRegistrationUseCase } from "../../../../../src/contexts/framework/application/doctor/doctor-registration-use-case.js"; import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import type { EnsureBuiltMarketplace } from "../../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import type { UserSourceReferences } from "../../../../../src/contexts/framework/domain/ports/user-source-references.js"; import type { NativePluginActivator } from "../../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; @@ -46,6 +51,12 @@ function catalogFixture(builtDir: string): Record { }; } +function manifestWithClaude(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + return manifest; +} + function projectScopeEntry(source: Marketplace["source"]): Marketplace { return Marketplace.create({ name: FRAMEWORK_MARKETPLACE_NAME, @@ -167,6 +178,42 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n expect(activator.addedMarketplaces).toEqual([CODEX_BUILT_DIR]); }); + it("warns with the reclaim message naming the tool that refuses the overwrite", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, frameworkAtUserScope()); + const manifest = Manifest.create(); + manifest.addTool("codex", "test", []); + const activator = new FakeNativePluginActivator({ + available: true, + enablesPlugins: false, + conflictOnAdd: true, + }); + const fs = new InMemoryFileAdapter({ + [`${CODEX_BUILT_DIR}/.agents/plugins/marketplace.json`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const logger = new CapturingLogger(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + logger, + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(() => CODEX_BUILT_DIR) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const reclaim = + "Marketplace 'aidd-framework' is registered from a different source and codex refuses to overwrite it in place; removing and re-registering it from the shared, machine-scope build. Plugins installed from it are removed and the ones this CLI manages are put back."; + expect(result.warnings).toStrictEqual([reclaim]); + expect(logger.warnMessages).toStrictEqual([reclaim]); + }); + it("never reclaims an arbitrary, non-reserved marketplace name this way — only the framework's own", async () => { const registry = new InMemoryMarketplaceRegistry(); await registry.save( @@ -379,6 +426,94 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi expect(roots).toContain(PROJECT_ROOT); expect(roots).not.toContain("/gone-project"); }); + + function hostOnForeignCache(): { + fs: InMemoryFileAdapter; + hostReader: FakeHostMarketplaceRegistryReader; + registry: InMemoryMarketplaceRegistry; + manifest: Manifest; + activator: FakeNativePluginActivator; + } { + const foreignProjectCache = builtMarketplaceDir( + "/other-project", + FRAMEWORK_MARKETPLACE_NAME, + "claude" + ); + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const fs = new InMemoryFileAdapter({ + [`${sharedBuiltDir()}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: CURRENT_VERSION, + plugins: [], + }), + [`${foreignProjectCache}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const hostReader = new FakeHostMarketplaceRegistryReader({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([[FRAMEWORK_MARKETPLACE_NAME, foreignProjectCache]]), + }); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + return { fs, hostReader, registry, manifest, activator }; + } + + it("still repoints the host when this run has nowhere to record references at all", async () => { + const { fs, hostReader, registry, manifest, activator } = hostOnForeignCache(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => sharedBuiltDir()), + new Map([["claude", hostReader]]), + () => USER_CACHE_ROOT + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.errors).toStrictEqual([]); + expect(activator.addedMarketplaces).toStrictEqual([sharedBuiltDir()]); + }); + + it("still repoints the host, recording nothing, when the version to record under is unknown", async () => { + const { fs, hostReader, registry, manifest, activator } = hostOnForeignCache(); + const added: Array<{ version: string; projectRoot: string }> = []; + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => sharedBuiltDir()), + new Map([["claude", hostReader]]), + () => USER_CACHE_ROOT, + undefined, + noSourceReferences(added) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.errors).toStrictEqual([]); + expect(activator.addedMarketplaces).toStrictEqual([sharedBuiltDir()]); + expect(added).toStrictEqual([]); + }); }); describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migration cache", () => { @@ -654,6 +789,311 @@ describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migr expect(activator.cacheStillPresentAtAdd).toEqual([true]); expect(fs.has(OLD_CACHE_FILE)).toBe(false); }); + + function registryWithSharedFramework(): InMemoryMarketplaceRegistry { + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: FRAMEWORK_MARKETPLACE_NAME, + source: { kind: "local", path: "." }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + return registry; + } + + function staleCacheAndCatalog(): InMemoryFileAdapter { + return new InMemoryFileAdapter({ + [OLD_CACHE_FILE]: "stale content", + ...catalogFixture(CLAUDE_BUILT_DIR), + }); + } + + function recordingRegister( + calls: MarketplaceRegisterFrameworkOptions[] + ): MarketplaceRegisterFramework { + return { + execute: async (options) => { + calls.push(options); + return { registered: true, scope: "user" }; + }, + }; + } + + it("warns with the whole message when a requested tool's binary is off PATH", async () => { + const logger = new CapturingLogger(); + const registry = projectWithMigratableEntry(); + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({ [OLD_CACHE_FILE]: "stale content" }), + new InMemoryManifestRepository(manifestWithClaude()), + registry, + new DeterministicHasher(), + logger, + new Map([["claude", new FakeNativePluginActivator({ available: false })]]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(logger.warnMessages).toStrictEqual([ + "claude CLI not found on PATH — skipping native plugin activation.", + "This project's own pre-migration framework cache kept: a requested tool's CLI was not on PATH this run, so its own registration may still point at it — run `aidd sync` again once every tool's CLI is on PATH.", + ]); + }); + + it("warns with the whole message when a requested tool's build failed", async () => { + const logger = new CapturingLogger(); + const registry = projectWithMigratableEntry(); + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({ [OLD_CACHE_FILE]: "stale content" }), + new InMemoryManifestRepository(manifestWithClaude()), + registry, + new DeterministicHasher(), + logger, + new Map([["claude", new FakeNativePluginActivator({ available: true })]]), + { + execute: async () => { + throw new Error("translator refused the source"); + }, + }, + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(logger.warnMessages).toStrictEqual([ + "Native plugin activation — build 'aidd-framework' for claude skipped: translator refused the source", + "Native plugin activation — build 'aidd-framework' for claude skipped: translator refused the source", + "This project's own pre-migration framework cache kept: a requested tool's build failed this run, so its own registration may still point at it — fix the build warning above, then run `aidd sync` again.", + ]); + }); + + it("keeps the stale built tree when one of two requested tools' builds failed", async () => { + const registry = projectWithMigratableEntry(); + const manifest = manifestWithClaude(); + manifest.addTool("codex", "test", []); + const fs = staleCacheAndCatalog(); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const codexBuildFails: EnsureBuiltMarketplace = { + execute: async (options) => { + if (options.target === "codex") throw new Error("translator refused the source"); + return { builtDir: CLAUDE_BUILT_DIR, version: "test", rebuilt: true }; + }, + }; + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", activator], + ["codex", activator], + ]), + codexBuildFails, + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: true, + }); + + expect(result.errors).toStrictEqual([]); + expect(fs.has(OLD_CACHE_FILE)).toBe(true); + }); + + it("names the candidate and the root it escaped when a symlink resolves it outside the project", async () => { + const logger = new CapturingLogger(); + const registry = projectWithMigratableEntry(); + const fs = new InMemoryFileAdapter({ + "/etc/evil/still-here.txt": "not aidd's to delete", + ...catalogFixture(CLAUDE_BUILT_DIR), + }); + fs.setSymlink(OLD_CACHE_DIR, "/etc/evil"); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifestWithClaude()), + registry, + new DeterministicHasher(), + logger, + new Map([ + ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: false })], + ]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(logger.warnMessages).toStrictEqual([ + `This project's own pre-migration framework cache does not resolve inside ${PROJECT_ROOT}; left in place: ${OLD_CACHE_DIR}`, + ]); + }); + + it("says which tree it purged", async () => { + const logger = new CapturingLogger(); + const registry = projectWithMigratableEntry(); + const useCase = new MarketplaceSyncSettingsUseCase( + staleCacheAndCatalog(), + new InMemoryManifestRepository(manifestWithClaude()), + registry, + new DeterministicHasher(), + logger, + new Map([ + ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: false })], + ]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + new MarketplaceRegisterFrameworkUseCase(registry) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(logger.infoMessages).toStrictEqual([ + `This project's own pre-migration framework cache purged: ${OLD_CACHE_DIR}`, + ]); + }); + + it("leaves the stale built tree alone at user scope, where no project file is this run's to touch", async () => { + const fs = staleCacheAndCatalog(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifestWithClaude()), + registryWithSharedFramework(), + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: false })], + ]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR) + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: true, + scope: "user", + }); + + expect(result.errors).toStrictEqual([]); + expect(fs.has(OLD_CACHE_FILE)).toBe(true); + }); + + it("leaves the stale built tree alone unless the caller asked to recreate the framework entry", async () => { + const fs = staleCacheAndCatalog(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifestWithClaude()), + registryWithSharedFramework(), + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: false })], + ]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR) + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.errors).toStrictEqual([]); + expect(fs.has(OLD_CACHE_FILE)).toBe(true); + }); + + it("leaves the stale built tree alone while the framework entry is still at project scope", async () => { + const fs = staleCacheAndCatalog(); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifestWithClaude()), + projectWithMigratableEntry(), + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: false })], + ]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR) + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: true, + }); + + expect(result.errors).toStrictEqual([]); + expect(fs.has(OLD_CACHE_FILE)).toBe(true); + }); + + it("never re-registers the framework when it is already shared behind another project-scope marketplace", async () => { + const registry = registryWithSharedFramework(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "other-plugins", + source: { kind: "local", path: "/other/plugins" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const calls: MarketplaceRegisterFrameworkOptions[] = []; + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter(catalogFixture(CLAUDE_BUILT_DIR)), + new InMemoryManifestRepository(manifestWithClaude()), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + recordingRegister(calls) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(calls).toStrictEqual([]); + }); + + it("neither registers nor fails when other marketplaces exist and no framework entry does", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "other-plugins", + source: { kind: "local", path: "/other/plugins" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const calls: MarketplaceRegisterFrameworkOptions[] = []; + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter(catalogFixture(CLAUDE_BUILT_DIR)), + new InMemoryManifestRepository(manifestWithClaude()), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map(), + () => "", + recordingRegister(calls) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); + + expect(calls).toStrictEqual([]); + expect((await registry.list(PROJECT_ROOT)).map((m) => m.name)).toStrictEqual(["other-plugins"]); + }); }); describe("MarketplaceSyncSettingsUseCase + DoctorRegistrationUseCase — the full migration cycle", () => { diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts new file mode 100644 index 000000000..88266e683 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts @@ -0,0 +1,356 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import type { EnsureBuiltMarketplace } from "../../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import type { NativeRegistrations } from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/test-project"; +const MARKETPLACE = "aidd-framework"; +const PLUGIN = "aidd-telemetry"; +const SETTINGS_PATH = ".claude/settings.json"; +const SETTINGS_ABSOLUTE = resolve(PROJECT_ROOT, SETTINGS_PATH); + +class SaveCountingManifestRepository extends InMemoryManifestRepository { + saves = 0; + + override async save(manifest: Manifest): Promise { + this.saves += 1; + return super.save(manifest); + } +} + +interface Setup { + readonly toolIds?: readonly ToolId[]; + readonly marketplaceNames?: readonly string[]; + readonly failingBuilds?: readonly string[]; + readonly activator?: FakeNativePluginActivator; + readonly withPlugin?: boolean; + readonly trackedSettings?: string; + readonly settingsOnDisk?: string; + readonly otherTrackedFiles?: readonly string[]; + readonly existingRegistrations?: NativeRegistrations; +} + +function catalogPath(marketplace: string, target: string): string { + const relative = + target === "codex" ? ".agents/plugins/marketplace.json" : ".claude-plugin/marketplace.json"; + return `/built/${marketplace}/${target}/${relative}`; +} + +function buildPerMarketplace(failing: readonly string[]): EnsureBuiltMarketplace { + return { + execute: async (options) => { + if (failing.includes(options.marketplace.name)) throw new Error("no catalog at that source"); + return { + builtDir: `/built/${options.marketplace.name}/${options.target}`, + version: "test", + rebuilt: true, + }; + }, + }; +} + +async function build(setup: Setup = {}) { + const hasher = new DeterministicHasher(); + const names = setup.marketplaceNames ?? [MARKETPLACE]; + const toolIds = setup.toolIds ?? ["claude"]; + const fs = new InMemoryFileAdapter({}, hasher); + for (const name of names) { + for (const toolId of toolIds) { + fs.setFile( + catalogPath(name, toolId), + JSON.stringify({ name, version: "1.0.0", plugins: [{ name: PLUGIN }] }) + ); + } + } + if (setup.settingsOnDisk !== undefined) fs.setFile(SETTINGS_ABSOLUTE, setup.settingsOnDisk); + const manifest = Manifest.create(); + const tracked = (setup.otherTrackedFiles ?? []).map( + (relativePath) => + new InstallationFile({ relativePath, content: "# tracked", hash: hasher.hash("# tracked") }) + ); + if (setup.trackedSettings !== undefined) { + tracked.push( + new InstallationFile({ + relativePath: SETTINGS_PATH, + content: setup.trackedSettings, + hash: hasher.hash(setup.trackedSettings), + }) + ); + } + for (const toolId of toolIds) + manifest.addTool(toolId, "test", toolId === "claude" ? tracked : []); + if (setup.withPlugin === true) { + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + PLUGIN, + "1.0.0", + { kind: "github", repo: "ai-driven-dev/framework" }, + true, + "project", + MARKETPLACE + ) + ); + } + if (setup.existingRegistrations !== undefined) { + manifest.setNativeRegistrations("claude", setup.existingRegistrations); + } + const manifestRepo = new SaveCountingManifestRepository(manifest); + const registry = new InMemoryMarketplaceRegistry(); + for (const name of names) { + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name, + source: { kind: "github", repo: `ai-driven-dev/${name}` }, + scope: "project", + addedAt: "2026-09-02T00:00:00Z", + }) + ); + } + const activator = + setup.activator ?? new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map(toolIds.map((toolId) => [toolId, activator])), + buildPerMarketplace(setup.failingBuilds ?? []) + ); + return { useCase, manifestRepo, manifest, fs, hasher }; +} + +function recorded(manifest: Manifest): NativeRegistrations | undefined { + return manifest.getNativeRegistrations("claude"); +} + +function trackedSettingsHash(manifest: Manifest): string | undefined { + return manifest.getToolFiles("claude").find((file) => file.relativePath === SETTINGS_PATH)?.hash + .value; +} + +const FRAMEWORK_ONLY: NativeRegistrations = { + binary: "claude", + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [], +}; + +describe("how many times the manifest is written back", () => { + it("zero times when a run changed nothing, writing no project file either", async () => { + const activator = new FakeNativePluginActivator({ available: false }); + const { useCase, manifestRepo, fs } = await build({ toolIds: ["claude", "codex"], activator }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifestRepo.saves).toBe(0); + expect(fs.listUnder(resolve(PROJECT_ROOT))).toStrictEqual([]); + }); + + it("once when only the settings file changed", async () => { + const activator = new FakeNativePluginActivator({ available: false }); + const { useCase, manifestRepo, fs } = await build({ withPlugin: true, activator }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifestRepo.saves).toBe(1); + expect(JSON.parse(await fs.readFile(SETTINGS_ABSOLUTE))).toStrictEqual({ + enabledPlugins: { [`${PLUGIN}@${MARKETPLACE}`]: true }, + }); + }); + + it("once when only the host registration changed", async () => { + const { useCase, manifestRepo, manifest } = await build(); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifestRepo.saves).toBe(1); + expect(recorded(manifest)).toStrictEqual(FRAMEWORK_ONLY); + }); + + it("once across two identical runs: the second finds nothing to record", async () => { + const { useCase, manifestRepo } = await build({ + trackedSettings: "{}", + settingsOnDisk: "{}", + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifestRepo.saves).toBe(1); + }); + + it("never on a second run that enables the same plugin ref again", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, manifestRepo, manifest } = await build({ withPlugin: true, activator }); + await useCase.execute({ projectRoot: PROJECT_ROOT }); + const afterFirstRun = manifestRepo.saves; + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifestRepo.saves).toBe(afterFirstRun); + expect(recorded(manifest)?.pluginRefs).toStrictEqual([`${PLUGIN}@${MARKETPLACE}`]); + }); + + it("once when only the tracked settings hash moved under the host's own write", async () => { + const onDisk = JSON.stringify({ model: "opus" }); + const { useCase, manifestRepo, manifest, hasher } = await build({ + trackedSettings: "{}", + settingsOnDisk: onDisk, + existingRegistrations: FRAMEWORK_ONLY, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifestRepo.saves).toBe(1); + expect(trackedSettingsHash(manifest)).toBe(hasher.hash(onDisk).value); + }); + + it("once when the marketplaces key is evicted from the shared settings file", async () => { + const activator = new FakeNativePluginActivator({ available: false }); + const onDisk = JSON.stringify({ + extraKnownMarketplaces: { x: { source: "/p" } }, + model: "opus", + }); + const { useCase, manifestRepo, manifest, fs, hasher } = await build({ + trackedSettings: onDisk, + settingsOnDisk: onDisk, + activator, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + const written = await fs.readFile(SETTINGS_ABSOLUTE); + expect(manifestRepo.saves).toBe(1); + expect(JSON.parse(written)).toStrictEqual({ model: "opus" }); + expect(trackedSettingsHash(manifest)).toBe(hasher.hash(written).value); + }); +}); + +describe("what the tracked settings hash follows", () => { + it("stays where it was when the tracked file is gone from disk", async () => { + const { useCase, manifest, hasher } = await build({ trackedSettings: "{}" }); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.errors).toStrictEqual([]); + expect(trackedSettingsHash(manifest)).toBe(hasher.hash("{}").value); + }); + + it("never starts tracking the settings file on behalf of another tracked file", async () => { + const { useCase, manifest } = await build({ + otherTrackedFiles: ["CLAUDE.md"], + settingsOnDisk: "{}", + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(manifest.getToolFiles("claude").map((file) => file.relativePath)).toStrictEqual([ + "CLAUDE.md", + ]); + }); +}); + +describe("when a recorded registration is replaced", () => { + it("once its marketplaces differ, same binary and same refs", async () => { + const { useCase, manifest } = await build({ + existingRegistrations: { + binary: "claude", + marketplaces: [{ alias: "stale", hostName: "stale" }], + pluginRefs: [], + }, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)).toStrictEqual(FRAMEWORK_ONLY); + }); + + it("once a marketplace's hostName alone moved", async () => { + const { useCase, manifest } = await build({ + existingRegistrations: { + binary: "claude", + marketplaces: [{ alias: MARKETPLACE, hostName: "old-host" }], + pluginRefs: [], + }, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)).toStrictEqual(FRAMEWORK_ONLY); + }); + + it("once a plugin ref appears where none was recorded", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const { useCase, manifest } = await build({ + withPlugin: true, + activator, + existingRegistrations: FRAMEWORK_ONLY, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)).toStrictEqual({ + ...FRAMEWORK_ONLY, + pluginRefs: [`${PLUGIN}@${MARKETPLACE}`], + }); + }); + + it("compares every recorded marketplace, not only the first", async () => { + const { useCase, manifest } = await build({ + marketplaceNames: ["market-a", "market-b"], + existingRegistrations: { + binary: "claude", + marketplaces: [ + { alias: "market-a", hostName: "market-a" }, + { alias: "stale", hostName: "stale" }, + ], + pluginRefs: [], + }, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)).toStrictEqual({ + binary: "claude", + marketplaces: [ + { alias: "market-a", hostName: "market-a" }, + { alias: "market-b", hostName: "market-b" }, + ], + pluginRefs: [], + }); + }); + + it("records a marketplace whose build failed under its own alias", async () => { + const { useCase, manifest } = await build({ + marketplaceNames: [MARKETPLACE, "broken"], + failingBuilds: ["broken"], + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(recorded(manifest)).toStrictEqual({ + binary: "claude", + marketplaces: [ + { alias: MARKETPLACE, hostName: MARKETPLACE }, + { alias: "broken", hostName: "broken" }, + ], + pluginRefs: [], + }); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts index 44675f1f6..01a9879cb 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts @@ -1,5 +1,6 @@ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; @@ -8,6 +9,8 @@ import { ModeAMarketplaceTranslator } from "../../../../../src/contexts/framewor import type { EnsureBuiltMarketplace } from "../../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { NativePluginCliError } from "../../../../../src/kernel/errors.js"; +import type { PluginSource } from "../../../../../src/kernel/source.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; @@ -43,6 +46,18 @@ interface SyncSetup { readonly crashOnAddMarketplace?: boolean; /** Refs that fail to enable — a recoverable, best-effort `NativePluginCliError`. */ readonly failOnPlugins?: readonly string[]; + readonly activator?: FakeNativePluginActivator; + readonly marketplaceSource?: (name: string) => PluginSource; +} + +class ActivatorFailingAtUpgrade extends FakeNativePluginActivator { + constructor(private readonly failure: Error) { + super({ available: true }); + } + + override upgradeMarketplaces(): void { + throw this.failure; + } } /** A real build always leaves a catalog where `fakeEnsureBuiltMarketplace()` resolves @@ -80,7 +95,7 @@ async function sync(setup: SyncSetup = {}) { PROJECT_ROOT, Marketplace.create({ name, - source: { kind: "local", path: `/source/${name}` }, + source: setup.marketplaceSource?.(name) ?? { kind: "local", path: `/source/${name}` }, scope: "project", addedAt: "2026-01-01T00:00:00Z", }) @@ -88,12 +103,14 @@ async function sync(setup: SyncSetup = {}) { } if (setup.settings !== undefined) await fs.writeFile(SHARED_SETTINGS, setup.settings); - const activator = new FakeNativePluginActivator({ - available: setup.available ?? true, - enablesPlugins: setup.enablesPlugins ?? false, - crashOnAddMarketplace: setup.crashOnAddMarketplace ?? false, - failOnPlugins: setup.failOnPlugins ?? [], - }); + const activator = + setup.activator ?? + new FakeNativePluginActivator({ + available: setup.available ?? true, + enablesPlugins: setup.enablesPlugins ?? false, + crashOnAddMarketplace: setup.crashOnAddMarketplace ?? false, + failOnPlugins: setup.failOnPlugins ?? [], + }); const useCase = new MarketplaceSyncSettingsUseCase( fs, manifestRepo, @@ -332,4 +349,165 @@ describe("toolIds narrows which tool's CLI is driven", () => { expect(codexActivator.addedMarketplaces).toEqual([]); expect(result.activated).toEqual(["claude"]); }); + + it("leaves the other tool's CLI alone even when that tool's own tree is ready to register", async () => { + const fs = seededBuiltCatalog(); + fs.setFile( + "/built/codex/.agents/plugins/marketplace.json", + JSON.stringify({ name: "aidd-framework", version: "1.0.0", plugins: [] }) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addTool("codex", "test", []); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/source/aidd-framework" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const claudeActivator = new FakeNativePluginActivator({ available: true }); + const codexActivator = new FakeNativePluginActivator({ available: true }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", claudeActivator], + ["codex", codexActivator], + ]), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, toolIds: ["claude"] }); + + expect(claudeActivator.addedMarketplaces).toStrictEqual(["/built/claude"]); + expect(codexActivator.addedMarketplaces).toStrictEqual([]); + expect(result).toStrictEqual({ + activated: ["claude"], + binaryMissing: [], + warnings: [], + errors: [], + }); + }); +}); + +describe("what a step that could not complete leaves in warnings and errors", () => { + it("names the binary in the warning when the CLI is not on PATH", async () => { + const { logger, result } = await sync({ available: false }); + + expect(logger.warnMessages).toStrictEqual([ + "claude CLI not found on PATH — skipping native plugin activation.", + ]); + expect(result.warnings).toStrictEqual([]); + }); + + it("names the plugin ref and the CLI's own reason when enabling it fails", async () => { + const { result } = await sync({ + enablesPlugins: true, + failOnPlugins: ["aidd-context@aidd-framework"], + }); + + expect(result.warnings).toStrictEqual([ + "Native plugin activation — enable plugin 'aidd-context@aidd-framework' skipped: plugin `aidd-context@aidd-framework` was not found in marketplace", + ]); + }); + + it("warns about a refused marketplace upgrade and still enables the plugin", async () => { + const activator = new ActivatorFailingAtUpgrade(new NativePluginCliError("registry locked")); + + const { result } = await sync({ activator }); + + expect(result.warnings).toStrictEqual([ + "Native plugin activation — upgrade marketplaces skipped: registry locked", + ]); + expect(result.errors).toStrictEqual([]); + expect(activator.enabledPlugins).toStrictEqual(["aidd-context@aidd-framework"]); + }); + + it("reports an activator bug during the upgrade as an error, never as a warning", async () => { + const activator = new ActivatorFailingAtUpgrade(new Error("activator bug")); + + const { result } = await sync({ activator }); + + expect(result.errors).toStrictEqual([{ scope: "claude", message: "activator bug" }]); + expect(result.warnings).toStrictEqual([]); + }); +}); + +describe("what gets built, and how", () => { + it("asks for a marketplace-mode build of every registered tree even when the CLI is absent", async () => { + const requests: { name: string; mode: string }[] = []; + const recordingBuild: EnsureBuiltMarketplace = { + execute: async (options) => { + requests.push({ name: options.marketplace.name, mode: options.mode }); + return { builtDir: `/built/${options.target}`, version: "test", rebuilt: true }; + }, + }; + + await sync({ + marketplaceNames: ["aidd-framework", "unused"], + ensureBuilt: recordingBuild, + available: false, + }); + + expect(requests).toStrictEqual([ + { name: "aidd-framework", mode: "marketplace" }, + { name: "unused", mode: "marketplace" }, + ]); + }); +}); + +describe("a marketplace whose source the settings file cannot express", () => { + it("writes no enabled-plugins entry for it", async () => { + const { written, fs } = await sync({ + marketplaceSource: (name) => ({ kind: "url", url: `https://example.com/${name}.git` }), + }); + + expect(written).toBeUndefined(); + expect(fs.listUnder(resolve(PROJECT_ROOT))).toStrictEqual([]); + }); +}); + +describe("a project whose manifest also lists a tool with no plugin system", () => { + it("syncs the other tools and reports only the one whose CLI ran", async () => { + const fs = seededBuiltCatalog(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addTool("vscode", "test", []); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/source/aidd-framework" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result).toStrictEqual({ + activated: ["claude"], + binaryMissing: [], + warnings: [], + errors: [], + }); + }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts index 72c06224a..ae1b02f85 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts @@ -16,6 +16,7 @@ import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { FakeCurrentVersion } from "../../../../helpers/ports/fake-current-version.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; @@ -246,4 +247,69 @@ describe("the shared source's own reference, recorded by sync", () => { expect(await registry.list(PROJECT_ROOT)).toEqual([]); }); + + it("stays a silent no-op when asked to recreate with nothing wired to do it", async () => { + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({}, new DeterministicHasher()), + await manifestRepoWithClaudeInstalled(), + new InMemoryMarketplaceRegistry(), + new DeterministicHasher(), + new CapturingLogger(), + new Map(), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ + projectRoot: PROJECT_ROOT, + recreateFrameworkIfMissing: true, + }); + + expect(result).toStrictEqual({ activated: [], binaryMissing: [], warnings: [], errors: [] }); + }); +}); + +class UnloadableManifestRepository extends InMemoryManifestRepository { + override async load(): Promise { + throw new Error("manifest.json is not valid JSON"); + } +} + +describe("a project with no manifest to sync", () => { + it("returns the empty result and drives no tool when there is no manifest", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, frameworkMarketplace()); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({}, new DeterministicHasher()), + new InMemoryManifestRepository(), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result).toStrictEqual({ activated: [], binaryMissing: [], warnings: [], errors: [] }); + expect(activator.addedMarketplaces).toStrictEqual([]); + }); + + it("returns the empty result when the manifest cannot be loaded", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, frameworkMarketplace()); + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({}, new DeterministicHasher()), + new UnloadableManifestRepository(), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", new FakeNativePluginActivator({ available: true })]]), + fakeEnsureBuiltMarketplace() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result).toStrictEqual({ activated: [], binaryMissing: [], warnings: [], errors: [] }); + }); }); From 5b6728b13ba3c7e26af587da2c8fd0fee07460da Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:35:19 +0200 Subject: [PATCH 10/12] test(cli): kill the surviving mutants of the translators and shared steps Built tree, flat and marketplace translators, project hooks materializer, ensure built marketplace, catalog identity, marketplace registration, project hooks removal, user scope files, cache purges, shared source references, plugin drift, plugin files and native calls: 66 tests, each shown red first against the mutant it names. Framework mutation score: 72.2 before the series, 95.4 after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- ...cursor-materialization.integration.test.ts | 59 +++++++ ...encode-materialization.integration.test.ts | 59 +++++++ .../mode-a-marketplace-adapter.unit.test.ts | 17 ++ ...-flat-materialization-adapter.unit.test.ts | 66 ++++++++ .../project-hooks-materializer.unit.test.ts | 131 +++++++++++++++ .../resolve-plugin-translator.unit.test.ts | 25 +++ .../apply-plugin-files-use-case.unit.test.ts | 70 ++++++++ .../best-effort-native-call.unit.test.ts | 33 ++++ .../detect-plugin-drift-use-case.unit.test.ts | 85 ++++++++++ ...t-marketplace-use-case.integration.test.ts | 150 ++++++++++++++++++ ...tplace-source-conflict.integration.test.ts | 69 ++++++++ .../shared/purge-declared-cache.unit.test.ts | 71 +++++++++ ...urge-native-marketplace-cache.unit.test.ts | 96 +++++++++++ ...-marketplace-catalog-identity.unit.test.ts | 63 ++++++++ .../shared/remove-project-hooks.unit.test.ts | 70 ++++++++ .../resolve-uninstall-scope.unit.test.ts | 12 ++ ...etplace-registration-use-case.unit.test.ts | 90 ++++++++++- ...ared-source-reference-support.unit.test.ts | 57 ++++++- .../user-scope-plugin-files.unit.test.ts | 112 +++++++++++++ 19 files changed, 1327 insertions(+), 8 deletions(-) create mode 100644 cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/framework/translator/resolve-plugin-translator.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/shared/apply-plugin-files-use-case.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/shared/best-effort-native-call.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/shared/detect-plugin-drift-use-case.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/shared/purge-declared-cache.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts create mode 100644 cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts diff --git a/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts index 07f3c3a29..a2696bb00 100644 --- a/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts @@ -100,3 +100,62 @@ describe("BuiltTreeMaterializationTranslator — cursor (integration)", () => { expect(result.skipped).toEqual([]); }); }); + +describe("BuiltTreeMaterializationTranslator — when no built tree applies (integration)", () => { + const COMMAND = "---\nname: aidd:01:hello\n---\n# Hello"; + + function distWithCommand(): PluginDistribution { + return new PluginDistribution({ + manifest: { name: "sample-plugin", version: "1.0.0" }, + format: "claude", + files: [{ relativePath: "commands/hello.md", content: COMMAND }], + components: { + commands: [{ relativePath: "commands/hello.md", content: COMMAND }], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [], + }, + }); + } + + async function install(marketplace: string | undefined): Promise { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/plugins/sample-plugin/skills/demo/SKILL.md`, "built skill"); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + const translator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => HOME, + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + await translator.addPlugin( + distWithCommand(), + "cursor", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + marketplace + ); + return fs; + } + + it("materializes the distribution itself, never the built tree, when no marketplace is given", async () => { + const fs = await install(undefined); + + expect(fs.listUnder(`${HOME}/.cursor/plugins/local`)).toStrictEqual([ + `${HOME}/.cursor/plugins/local/sample-plugin/commands/hello.md`, + ]); + }); + + it("materializes the distribution itself when the named marketplace is not registered", async () => { + const fs = await install("unregistered-marketplace"); + + expect(fs.listUnder(`${HOME}/.cursor/plugins/local`)).toStrictEqual([ + `${HOME}/.cursor/plugins/local/sample-plugin/commands/hello.md`, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts index dddf5d135..6b2857c0d 100644 --- a/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/built-tree-opencode-materialization.integration.test.ts @@ -133,3 +133,62 @@ describe("BuiltTreeMaterializationTranslator — opencode (integration)", () => expect(fs.has(`${PROJECT_ROOT}/.opencode/plugin/other-plugin.js`)).toBe(false); }); }); + +describe("BuiltTreeMaterializationTranslator — what the opencode flat tree never yields", () => { + async function installFrom( + fs: InMemoryFileAdapter, + distribution: PluginDistribution + ): Promise<{ skipped: readonly unknown[] }> { + const manifest = Manifest.create(); + manifest.addTool("opencode", "test", []); + const translator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => "/home/u", + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + return translator.addPlugin( + distribution, + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework" + ); + } + + it("reports no skip for a tool that keeps hooks inside its own plugin tree", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/.opencode/agents/aidd-vcs-helper.md`, "agent body"); + + const result = await installFrom(fs, dist()); + + expect(result.skipped).toStrictEqual([]); + }); + + it("ignores built files outside .opencode and entries sitting directly under it", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/other/agents/aidd-vcs-helper.md`, "not opencode"); + fs.setFile(`${BUILT}/.opencode/aidd-vcs-readme.md`, "too shallow"); + fs.setFile(`${BUILT}/.opencode/agents/aidd-vcs-helper.md`, "agent body"); + + await installFrom(fs, dist()); + + expect(fs.listUnder(PROJECT_ROOT)).toStrictEqual([ + `${PROJECT_ROOT}/.opencode/agents/aidd-vcs-helper.md`, + ]); + }); + + it("never copies the plugin's own hooks manifest, even when the built tree carries one", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/.opencode/hooks/aidd-vcs/hooks.json`, "{}"); + fs.setFile(`${BUILT}/.opencode/hooks/aidd-vcs/journal.cjs`, "// journal"); + + await installFrom(fs, distWithHooks()); + + expect(fs.listUnder(PROJECT_ROOT)).toStrictEqual([ + `${PROJECT_ROOT}/.opencode/hooks/aidd-vcs/journal.cjs`, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/translator/mode-a-marketplace-adapter.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/mode-a-marketplace-adapter.unit.test.ts index 5bee02c23..8728a4a63 100644 --- a/cli/tests/contexts/framework/application/framework/translator/mode-a-marketplace-adapter.unit.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/mode-a-marketplace-adapter.unit.test.ts @@ -49,6 +49,23 @@ describe("ModeAMarketplaceTranslator", () => { expect(installed?.files.size).toBe(0); expect(installed?.marketplace).toBe("aidd-framework"); }); + + it("reports nothing skipped, since nothing was translated", async () => { + const { adapter } = buildAdapter(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + + const result = await adapter.addPlugin( + buildDist("aidd-context"), + "claude", + { kind: "local", path: "/plugin-source" }, + "/project", + manifest, + "aidd-framework" + ); + + expect(result).toStrictEqual({ skipped: [] }); + }); }); describe("when adding a plugin without marketplace", () => { diff --git a/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts index 736f6eb3a..5df09df4c 100644 --- a/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts @@ -7,6 +7,10 @@ import { Manifest } from "../../../../../../src/contexts/framework/domain/manife import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; import { CursorProjectScopeUnsupportedError } from "../../../../../../src/kernel/errors.js"; import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../../helpers/ports/faulting-file-adapter.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; const PROJECT_ROOT = "/test-project"; @@ -159,4 +163,66 @@ describe("ModeBFlatMaterializationTranslator", () => { expect(plugins.find((p) => p.name === "mcp-plugin")).toBeUndefined(); }); }); + + describe("when the tool's MCP config merges the plugin's servers", () => { + function mcpDist(mcpServers: Record): PluginDistribution { + const content = JSON.stringify({ mcpServers }); + return new PluginDistribution({ + manifest: { name: "mcp-plugin", version: "1.0.0" }, + format: "claude", + files: [{ relativePath: ".mcp.json", content }], + components: { + commands: [], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [{ relativePath: ".mcp.json", content }], + }, + }); + } + + it("drops the servers a previous version contributed even when the new version contributes none", async () => { + const { adapter, fs } = buildAdapter(); + const configPath = join(PROJECT_ROOT, "opencode.json"); + fs.setFile(configPath, JSON.stringify({ mcp: { "old-tool": { type: "local" } } })); + const manifest = Manifest.create(); + manifest.addTool("opencode", "test", []); + + await adapter.addPlugin( + mcpDist({}), + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined, + new Map([["old-tool", "digest-of-old-tool"]]) + ); + + expect(JSON.parse(fs.getFile(configPath) ?? "null")).toStrictEqual({ mcp: {} }); + }); + + it("propagates a failure to read the MCP config other than the file being absent", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("readFile", join(PROJECT_ROOT, "opencode.json"), errnoError("EACCES")); + const adapter = new ModeBFlatMaterializationTranslator( + fs, + new DeterministicHasher(), + () => "/stub-home" + ); + const manifest = Manifest.create(); + manifest.addTool("opencode", "test", []); + + await expect( + adapter.addPlugin( + mcpDist({ "local-tool": { command: "node", args: ["./server.js"] } }), + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined + ) + ).rejects.toThrow("EACCES: planted by the test"); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts new file mode 100644 index 000000000..6f972cc29 --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts @@ -0,0 +1,131 @@ +import "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + ProjectHooksMaterializer, + withoutHooks, +} from "../../../../../../src/contexts/framework/application/framework/translator/project-hooks-materializer.js"; +import { + type PluginComponentFile, + PluginDistribution, +} from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/test-project"; +const PLUGIN_NAME = "aidd-context"; +const HOOKS_PATH = join(PROJECT_ROOT, ".cursor", "hooks.json"); +const SCRIPT = { relativePath: "hooks/pre.js", content: "module.exports = () => {};" }; + +// biome-ignore lint/suspicious/noTemplateCurlyInString: the Claude hook placeholder the merge rewrites +const PLUGIN_ROOT_VAR = "${CLAUDE_PLUGIN_ROOT}"; + +function hooksManifest(event: string): PluginComponentFile { + return { + relativePath: "hooks/hooks.json", + content: JSON.stringify({ + hooks: { + [event]: [ + { hooks: [{ type: "command", command: `node ${PLUGIN_ROOT_VAR}/hooks/pre.js` }] }, + ], + }, + }), + }; +} + +function distWithHooks(hooks: readonly PluginComponentFile[]): PluginDistribution { + return new PluginDistribution({ + manifest: { name: PLUGIN_NAME, version: "1.0.0" }, + format: "claude", + files: [...hooks, { relativePath: "commands/hello.md", content: "# Hello" }], + components: { + commands: [{ relativePath: "commands/hello.md", content: "# Hello" }], + agents: [], + rules: [], + skills: [], + hooks: [...hooks], + mcp: [], + }, + }); +} + +describe("ProjectHooksMaterializer", () => { + it("merges the plugin's hooks manifest whichever position it holds among the hook files", async () => { + const fs = new InMemoryFileAdapter(); + + const skips = await new ProjectHooksMaterializer(fs).materialize( + distWithHooks([SCRIPT, hooksManifest("PreToolUse")]), + "cursor", + PROJECT_ROOT + ); + + expect(skips).toStrictEqual([]); + expect(JSON.parse(fs.getFile(HOOKS_PATH) ?? "null")).toStrictEqual({ + version: 1, + hooks: { preToolUse: [{ command: `node ./.cursor/hooks/${PLUGIN_NAME}/pre.js` }] }, + }); + }); + + it("reports an event the tool cannot map as a hooks skip for this plugin", async () => { + const fs = new InMemoryFileAdapter(); + + const skips = await new ProjectHooksMaterializer(fs).materialize( + distWithHooks([hooksManifest("Notification"), SCRIPT]), + "cursor", + PROJECT_ROOT + ); + + expect(skips).toStrictEqual([ + { + pluginName: PLUGIN_NAME, + component: "hooks", + toolId: "cursor", + reason: "cursor: unmapped event 'Notification' skipped", + }, + ]); + }); + + it("copies every hook script beside the project's hooks file, never the manifest itself", async () => { + const fs = new InMemoryFileAdapter(); + + await new ProjectHooksMaterializer(fs).materialize( + distWithHooks([hooksManifest("PreToolUse"), SCRIPT]), + "cursor", + PROJECT_ROOT + ); + + expect(fs.listUnder(join(PROJECT_ROOT, ".cursor", "hooks"))).toStrictEqual([ + `${PROJECT_ROOT}/.cursor/hooks/${PLUGIN_NAME}/pre.js`, + ]); + }); + + it("propagates a failure to read the project's hooks file other than its absence", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("readFile", HOOKS_PATH, errnoError("EACCES")); + + await expect( + new ProjectHooksMaterializer(fs).materialize( + distWithHooks([hooksManifest("PreToolUse"), SCRIPT]), + "cursor", + PROJECT_ROOT + ) + ).rejects.toThrow("EACCES: planted by the test"); + }); +}); + +describe("withoutHooks", () => { + it("drops every hooks file from the file list and the hooks component alike", () => { + const stripped = withoutHooks(distWithHooks([hooksManifest("PreToolUse"), SCRIPT])); + + expect(stripped.files).toStrictEqual([ + { relativePath: "commands/hello.md", content: "# Hello" }, + ]); + expect(stripped.components.hooks).toStrictEqual([]); + expect(stripped.components.commands).toStrictEqual([ + { relativePath: "commands/hello.md", content: "# Hello" }, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/framework/translator/resolve-plugin-translator.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/resolve-plugin-translator.unit.test.ts new file mode 100644 index 000000000..4e8340bec --- /dev/null +++ b/cli/tests/contexts/framework/application/framework/translator/resolve-plugin-translator.unit.test.ts @@ -0,0 +1,25 @@ +import "../../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { describe, expect, it } from "vitest"; +import type { TranslatorDeps } from "../../../../../../src/contexts/framework/application/framework/translator/plugin-translator-factory.js"; +import { resolvePluginTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/resolve-plugin-translator.js"; +import { getToolConfig } from "../../../../../../src/contexts/tools/domain/registry.js"; +import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-memory-marketplace-registry.js"; + +function deps(): TranslatorDeps { + return { + fs: new InMemoryFileAdapter(), + hasher: new DeterministicHasher(), + homedir: () => "/stub-home", + ensureBuilt: fakeEnsureBuiltMarketplace(), + marketplaceRegistry: new InMemoryMarketplaceRegistry(), + }; +} + +describe("resolvePluginTranslator", () => { + it("answers nothing for a tool that is not an AI tool", () => { + expect(resolvePluginTranslator(getToolConfig("vscode"), deps())).toBeNull(); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/apply-plugin-files-use-case.unit.test.ts b/cli/tests/contexts/framework/application/shared/apply-plugin-files-use-case.unit.test.ts new file mode 100644 index 000000000..c4409ed59 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/apply-plugin-files-use-case.unit.test.ts @@ -0,0 +1,70 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { ApplyPluginFilesUseCase } from "../../../../../src/contexts/framework/application/shared/apply-plugin-files-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import { getToolConfig } from "../../../../../src/contexts/tools/domain/registry.js"; +import { buildUnitDeps } from "../../../../helpers/ports/build-unit-deps.js"; +import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; + +const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); +const PROJECT_ROOT = "/test-project"; +const CACHE_DIR = join(PROJECT_ROOT, ".aidd", "plugin-cache"); +const SOURCE = { + kind: "git-subdir" as const, + url: "https://github.com/ai-driven-dev/framework.git", + path: "plugins/sample-plugin", +}; + +async function makeUseCase(): Promise<{ + useCase: ApplyPluginFilesUseCase; + fs: Awaited>["fs"]; +}> { + const deps = await buildUnitDeps(PROJECT_ROOT); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + deps.pluginFetcher.register(SOURCE, PLUGIN_FIXTURE); + const useCase = new ApplyPluginFilesUseCase( + deps.fs, + deps.hasher, + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ); + return { useCase, fs: deps.fs }; +} + +function manifestWith(plugin: InstalledPlugin): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + manifest.addPlugin("claude", plugin); + return manifest; +} + +describe("ApplyPluginFilesUseCase — without built-tree materialization wired in", () => { + it("restores a marketplace plugin through the source transform and tracks what it wrote", async () => { + const { useCase, fs } = await makeUseCase(); + const plugin = InstalledPlugin.fromMetadata( + "sample-plugin", + "1.0.0", + SOURCE, + false, + "project", + "aidd-framework" + ); + const manifest = manifestWith(plugin); + + const restored = await useCase.execute({ + toolId: "claude", + plugin, + toolConfig: getToolConfig("claude"), + projectRoot: PROJECT_ROOT, + cacheDir: CACHE_DIR, + manifest, + }); + + const tracked = [...manifest.getPlugins("claude")[0].files.keys()].sort(); + expect(tracked.length).toBeGreaterThan(0); + expect(restored).toBe(tracked.length); + expect(tracked.every((relativePath) => fs.has(join(PROJECT_ROOT, relativePath)))).toBe(true); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/best-effort-native-call.unit.test.ts b/cli/tests/contexts/framework/application/shared/best-effort-native-call.unit.test.ts new file mode 100644 index 000000000..f87f64776 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/best-effort-native-call.unit.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { bestEffortNativeCall } from "../../../../../src/contexts/framework/application/shared/best-effort-native-call.js"; +import { NativePluginCliError } from "../../../../../src/kernel/errors.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; + +describe("bestEffortNativeCall", () => { + it("warns and reports a call the host's own CLI refused", () => { + const logger = new CapturingLogger(); + + const completed = bestEffortNativeCall( + logger, + () => { + throw new NativePluginCliError("exit 1"); + }, + "claude marketplace remove" + ); + + expect(completed).toBe(false); + expect(logger.warnMessages).toStrictEqual(["claude marketplace remove failed: exit 1"]); + }); + + it("propagates any failure that is not the host CLI refusing", () => { + expect(() => + bestEffortNativeCall( + new CapturingLogger(), + () => { + throw new Error("activator bug"); + }, + "claude marketplace remove" + ) + ).toThrow("activator bug"); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/detect-plugin-drift-use-case.unit.test.ts b/cli/tests/contexts/framework/application/shared/detect-plugin-drift-use-case.unit.test.ts new file mode 100644 index 000000000..df860a474 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/detect-plugin-drift-use-case.unit.test.ts @@ -0,0 +1,85 @@ +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { DetectPluginDriftUseCase } from "../../../../../src/contexts/framework/application/shared/detect-plugin-drift-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/proj"; +const USER_PLUGINS_DIR = join(homedir(), ".cursor", "plugins", "local"); +const HASHER = new DeterministicHasher(); +const HASH_OF_ONE = HASHER.hash("one").value; +const HASH_OF_TWO = HASHER.hash("two").value; + +function manifestWith(plugins: Record>): Manifest { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + for (const [name, files] of Object.entries(plugins)) { + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name, + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files, + scope: "user", + }) + ); + } + return manifest; +} + +describe("DetectPluginDriftUseCase", () => { + it("narrows the report to the plugin it was asked about", async () => { + const manifest = manifestWith({ + "aidd-one": { "aidd-one/a.md": HASH_OF_ONE }, + "aidd-two": { "aidd-two/a.md": HASH_OF_ONE }, + }); + const useCase = new DetectPluginDriftUseCase(new InMemoryFileAdapter({}, HASHER)); + + const drifts = await useCase.execute({ + manifest, + projectRoot: PROJECT_ROOT, + toolIds: ["cursor"], + pluginName: "aidd-two", + }); + + expect(drifts).toStrictEqual([ + { toolId: "cursor", pluginName: "aidd-two", files: [], notInstalledOnMachine: true }, + ]); + }); + + it("reports one missing and one changed file as drift, never as a plugin absent from this machine", async () => { + const manifest = manifestWith({ + "aidd-one": { "aidd-one/gone.md": HASH_OF_ONE, "aidd-one/changed.md": HASH_OF_ONE }, + }); + const fs = new InMemoryFileAdapter( + { [join(USER_PLUGINS_DIR, "aidd-one", "changed.md")]: "two" }, + HASHER + ); + const useCase = new DetectPluginDriftUseCase(fs); + + const drifts = await useCase.execute({ + manifest, + projectRoot: PROJECT_ROOT, + toolIds: ["cursor"], + }); + + expect(HASH_OF_TWO).not.toBe(HASH_OF_ONE); + expect(drifts).toStrictEqual([ + { + toolId: "cursor", + pluginName: "aidd-one", + files: [ + { relativePath: "aidd-one/gone.md", kind: "missing" }, + { relativePath: "aidd-one/changed.md", kind: "hash-mismatch" }, + ], + notInstalledOnMachine: false, + }, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts b/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts index e4cfe3d20..55f6023ce 100644 --- a/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/shared/ensure-built-marketplace-use-case.integration.test.ts @@ -289,6 +289,156 @@ describe("EnsureBuiltMarketplaceUseCase", () => { await uc.execute(opts); expect(builds).toBe(1); }); + + it("memoizes per target: a second target of the same marketplace builds again", async () => { + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const codex = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + const cursor = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "cursor", + mode: "marketplace", + }); + expect(builds).toBe(2); + expect(codex.builtDir).not.toBe(cursor.builtDir); + }); + + it("reports the catalog version it built", async () => { + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + expect(r.version).toBe("1.0.0"); + }); + + it("reports a catalog without a version as unversioned", async () => { + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", undefined), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }); + expect(r.version).toBe("unversioned"); + }); + + it("asks the resolver for exactly the marketplace, project and refresh flag it was given", async () => { + const asked: ResolveMarketplaceOptions[] = []; + const recordingResolve: ResolveMarketplace = { + execute: async (options) => { + asked.push(options); + return { marketplace: options.marketplace, localPath: "/src/framework", catalog: null }; + }, + }; + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + recordingResolve, + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const marketplace = makeMarketplace(); + await uc.execute({ projectRoot: PROJECT, marketplace, target: "codex", mode: "marketplace" }); + expect(asked).toStrictEqual([{ marketplace, projectRoot: PROJECT, forceRefresh: undefined }]); + }); + + it("refuses a target and mode pair no build exists for", async () => { + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", "1.0.0"), + () => undefined, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + await expect( + uc.execute({ + projectRoot: PROJECT, + marketplace: makeMarketplace(), + target: "codex", + mode: "marketplace", + }) + ).rejects.toThrow("No framework build for target 'codex' mode 'marketplace'."); + }); +}); + +describe("EnsureBuiltMarketplaceUseCase — when a published source's sentinel can be believed", () => { + let fs: InMemoryFileAdapter; + let builds: number; + let buildFor: FrameworkBuildFor; + const builtDir = resolve(builtMarketplaceDir(PROJECT, "aidd-framework", "codex")); + + beforeEach(() => { + fs = new InMemoryFileAdapter(); + builds = 0; + buildFor = (_target, _mode, outDir) => + ({ + execute: async () => { + builds += 1; + await fs.writeFile(join(outDir, "plugins/aidd-vcs/SKILL.md"), "built content"); + return { outDir, plugins: [], totalFiles: 1 }; + }, + }) satisfies FrameworkBuild; + }); + + async function ensure(catalogVersion: string | undefined): Promise { + const uc = new EnsureBuiltMarketplaceUseCase( + fs, + fakeResolve("/src/framework", catalogVersion), + buildFor, + fakeVersion("5.0.0"), + () => "/user-cache" + ); + const r = await uc.execute({ + projectRoot: PROJECT, + marketplace: makeRemoteMarketplace(), + target: "codex", + mode: "marketplace", + }); + return r.rebuilt; + } + + it("rebuilds when no sentinel was ever written", async () => { + expect(await ensure("1.0.0")).toBe(true); + expect(builds).toBe(1); + }); + + it("rebuilds when the sentinel names another CLI version", async () => { + fs.setFile(join(builtDir, ".build-version"), "4.0.0:1.0.0"); + expect(await ensure("1.0.0")).toBe(true); + expect(builds).toBe(1); + }); + + it("rebuilds a catalog without a version even when the sentinel says unversioned too", async () => { + fs.setFile(join(builtDir, ".build-version"), "5.0.0:unversioned"); + expect(await ensure(undefined)).toBe(true); + expect(builds).toBe(1); + }); }); // outDir here is always builtMarketplaceDir(), an aidd-owned disposable cache, so a collision diff --git a/cli/tests/contexts/framework/application/shared/host-marketplace-source-conflict.integration.test.ts b/cli/tests/contexts/framework/application/shared/host-marketplace-source-conflict.integration.test.ts index 561710695..aed8566b6 100644 --- a/cli/tests/contexts/framework/application/shared/host-marketplace-source-conflict.integration.test.ts +++ b/cli/tests/contexts/framework/application/shared/host-marketplace-source-conflict.integration.test.ts @@ -1,3 +1,4 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import { describe, expect, it } from "vitest"; import { hostMarketplaceSourceConflict, @@ -5,6 +6,10 @@ import { } from "../../../../../src/contexts/framework/application/shared/host-marketplace-source-conflict.js"; import { userBuiltMarketplaceDir } from "../../../../../src/kernel/paths.js"; import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; const NAME = "aidd-framework"; @@ -53,4 +58,68 @@ describe("hostMarketplaceSourceConflict — resolving every path through the sam requestedVersion: "1.0.0", }); }); + + it("still decides the drift when the registered source no longer resolves on disk", async () => { + const userCacheRoot = "/home/.config/aidd"; + const requestedSource = userBuiltMarketplaceDir(userCacheRoot, "1.0.0", NAME, "claude"); + const registeredSource = userBuiltMarketplaceDir(userCacheRoot, "2.0.0", NAME, "claude"); + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", registeredSource, errnoError("ENOENT")); + const reader = new FakeHostMarketplaceRegistryReader({ + location: LOCATION, + entries: new Map([[NAME, registeredSource]]), + }); + + const check = await hostMarketplaceSourceConflict( + fs, + "claude", + reader, + requestedSource, + { name: NAME, pluginNames: [] }, + { userCacheRoot, projectRoot: "/project", marketplaceName: NAME, target: "claude" } + ); + + expect(check).toStrictEqual({ + name: NAME, + registeredSource, + requestedSource, + location: LOCATION, + drift: { kind: "version-behind", registeredVersion: "2.0.0", requestedVersion: "1.0.0" }, + }); + }); +}); + +describe("hostMarketplaceSourceConflict — without a drift context", () => { + it("reports the different catalog the host's registry points at", async () => { + const fs = new InMemoryFileAdapter({ + "/registered/.claude-plugin/marketplace.json": JSON.stringify({ + name: NAME, + plugins: [{ name: "aidd-dev" }], + }), + }); + const reader = new FakeHostMarketplaceRegistryReader({ + location: LOCATION, + entries: new Map([[NAME, "/registered"]]), + }); + + const check = await hostMarketplaceSourceConflict(fs, "claude", reader, "/requested", { + name: NAME, + pluginNames: ["aidd-dev", "aidd-vcs"], + }); + + expect(check).toStrictEqual({ + name: NAME, + registeredSource: "/registered", + requestedSource: "/requested", + registeredIdentity: { name: NAME, pluginNames: ["aidd-dev"] }, + requestedIdentity: { name: NAME, pluginNames: ["aidd-dev", "aidd-vcs"] }, + location: LOCATION, + }); + }); +}); + +describe("isDriftFound", () => { + it("is false for no check at all", () => { + expect(isDriftFound(undefined)).toBe(false); + }); }); diff --git a/cli/tests/contexts/framework/application/shared/purge-declared-cache.unit.test.ts b/cli/tests/contexts/framework/application/shared/purge-declared-cache.unit.test.ts new file mode 100644 index 000000000..fa34e516e --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/purge-declared-cache.unit.test.ts @@ -0,0 +1,71 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + purgeCacheIfEmptyAndConfirmed, + resolveCacheCandidate, +} from "../../../../../src/contexts/framework/application/shared/purge-declared-cache.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const CACHE_ROOT = "/cache"; +const CANDIDATE = join(CACHE_ROOT, "mkt"); +const LABEL = "codex: cache for 'mkt'"; + +describe("resolveCacheCandidate", () => { + it("answers nothing, silently, when the cache root does not exist", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", CACHE_ROOT, errnoError("ENOENT")); + const logger = new CapturingLogger(); + + const candidate = await resolveCacheCandidate(fs, logger, CACHE_ROOT, "mkt", LABEL); + + expect(candidate).toBeNull(); + expect(logger.allMessages).toStrictEqual([]); + }); + + it("answers nothing, silently, when the candidate does not exist", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", CANDIDATE, errnoError("ENOENT")); + const logger = new CapturingLogger(); + + const candidate = await resolveCacheCandidate(fs, logger, CACHE_ROOT, "mkt", LABEL); + + expect(candidate).toBeNull(); + expect(logger.allMessages).toStrictEqual([]); + }); +}); + +describe("purgeCacheIfEmptyAndConfirmed", () => { + it("purges an empty, confirmed cache and says so", async () => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + + await purgeCacheIfEmptyAndConfirmed(fs, logger, CANDIDATE, true, LABEL); + + expect(logger.infoMessages).toStrictEqual([`${LABEL} purged: ${CANDIDATE}`]); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("treats a cache that no longer exists as nothing to purge", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("listDirectory", CANDIDATE, errnoError("ENOENT")); + const logger = new CapturingLogger(); + + await purgeCacheIfEmptyAndConfirmed(fs, logger, CANDIDATE, true, LABEL); + + expect(logger.allMessages).toStrictEqual([]); + }); + + it("propagates a listing failure other than absence", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("listDirectory", CANDIDATE, errnoError("EACCES")); + + await expect( + purgeCacheIfEmptyAndConfirmed(fs, new CapturingLogger(), CANDIDATE, true, LABEL) + ).rejects.toThrow("EACCES: planted by the test"); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts b/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts new file mode 100644 index 000000000..3c75c93ea --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts @@ -0,0 +1,96 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + purgeAllNativeCaches, + purgeNativeMarketplaceCache, + type UndoneToolRegistrations, +} from "../../../../../src/contexts/framework/application/shared/purge-native-marketplace-cache.js"; +import type { ToolId } from "../../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const HOME = "/home/u"; +const CACHE_ROOT = "/cache"; +const HOST_NAME = "aidd-framework"; +const CANDIDATE = join(CACHE_ROOT, HOST_NAME); + +function undone(binary: string): UndoneToolRegistrations { + return { + registrations: { + binary, + marketplaces: [{ alias: HOST_NAME, hostName: HOST_NAME }], + pluginRefs: [], + }, + removedHostNames: new Set([HOST_NAME]), + }; +} + +describe("purgeAllNativeCaches", () => { + it("skips a tool whose profile declares no plugin cache", async () => { + const fs = new InMemoryFileAdapter({ [join(HOME, ".cursor", "plugins", "a.json")]: "{}" }); + const logger = new CapturingLogger(); + + await purgeAllNativeCaches( + fs, + logger, + HOME, + new Map(), + new Map([["cursor", undone("cursor")]]) + ); + + expect(logger.allMessages).toStrictEqual([]); + expect(fs.listAll()).toStrictEqual([join(HOME, ".cursor", "plugins", "a.json")]); + }); +}); + +describe("purgeNativeMarketplaceCache", () => { + it("names the cache path when its real location escapes the cache root", async () => { + const fs = new InMemoryFileAdapter(); + fs.setSymlink(CANDIDATE, "/elsewhere"); + const logger = new CapturingLogger(); + + await purgeNativeMarketplaceCache(fs, logger, undefined, CACHE_ROOT, "codex", HOST_NAME, true); + + expect(logger.warnMessages).toStrictEqual([ + `codex: cache path for '${HOST_NAME}' does not resolve inside ${CACHE_ROOT}; left in place: ${CANDIDATE}`, + ]); + }); + + it("keeps and names a cache the host never confirmed removing, for a host without a registry", async () => { + const logger = new CapturingLogger(); + + await purgeNativeMarketplaceCache( + new InMemoryFileAdapter(), + logger, + undefined, + CACHE_ROOT, + "codex", + HOST_NAME, + false + ); + + expect(logger.warnMessages).toStrictEqual([ + `codex: cache for '${HOST_NAME}' left in place, its own removal was not confirmed: ${CANDIDATE}`, + ]); + }); + + it("purges the cache and says so once the host's registry is gone", async () => { + const fs = new InMemoryFileAdapter({ [join(CANDIDATE, "plugin.json")]: "{}" }); + const logger = new CapturingLogger(); + const reader = new FakeHostMarketplaceRegistryReader({ + location: "/home/u/.claude/plugins/known_marketplaces.json", + absent: true, + }); + + await purgeNativeMarketplaceCache(fs, logger, reader, CACHE_ROOT, "claude", HOST_NAME, true); + + expect(fs.listAll()).toStrictEqual([]); + expect(logger.infoMessages).toStrictEqual([ + `claude: cache for '${HOST_NAME}' purged: ${CANDIDATE}`, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts b/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts new file mode 100644 index 000000000..458a093e3 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts @@ -0,0 +1,63 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + marketplaceCatalogProbePath, + readMarketplaceCatalogIdentity, +} from "../../../../../src/contexts/framework/application/shared/read-marketplace-catalog-identity.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const DIR = "/marketplace"; +const CATALOG_PATH = join(DIR, ".claude-plugin", "marketplace.json"); + +function fsWithCatalog(catalog: unknown): InMemoryFileAdapter { + return new InMemoryFileAdapter({ [CATALOG_PATH]: JSON.stringify(catalog) }); +} + +describe("readMarketplaceCatalogIdentity", () => { + it("reads the name and plugin names the tool's own catalog file declares", async () => { + const fs = fsWithCatalog({ name: "aidd-framework", plugins: [{ name: "a" }, { name: "b" }] }); + + const identity = await readMarketplaceCatalogIdentity(fs, "claude", DIR); + + expect(identity).toStrictEqual({ name: "aidd-framework", pluginNames: ["a", "b"] }); + }); + + it("answers no plugin names for a catalog that declares none", async () => { + const fs = fsWithCatalog({ name: "aidd-framework" }); + + const identity = await readMarketplaceCatalogIdentity(fs, "claude", DIR); + + expect(identity).toStrictEqual({ name: "aidd-framework", pluginNames: [] }); + }); + + it("skips a plugin entry that is not an object or names nothing", async () => { + const fs = fsWithCatalog({ + name: "aidd-framework", + plugins: [null, "text", { name: 3 }, { version: "1.0.0" }, { name: "a" }], + }); + + const identity = await readMarketplaceCatalogIdentity(fs, "claude", DIR); + + expect(identity).toStrictEqual({ name: "aidd-framework", pluginNames: ["a"] }); + }); + + it("answers nothing for a catalog whose name is not a string", async () => { + const fs = fsWithCatalog({ name: 5, plugins: [{ name: "a" }] }); + + expect(await readMarketplaceCatalogIdentity(fs, "claude", DIR)).toBeUndefined(); + }); + + it("answers nothing for a tool that is not an AI tool", async () => { + const fs = fsWithCatalog({ name: "aidd-framework" }); + + expect(await readMarketplaceCatalogIdentity(fs, "vscode", DIR)).toBeUndefined(); + }); +}); + +describe("marketplaceCatalogProbePath", () => { + it("names nothing for a tool that is not an AI tool", () => { + expect(marketplaceCatalogProbePath("vscode", DIR)).toBeUndefined(); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts b/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts new file mode 100644 index 000000000..b19a2d229 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts @@ -0,0 +1,70 @@ +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { removeProjectHooks } from "../../../../../src/contexts/framework/application/shared/remove-project-hooks.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const PROJECT_ROOT = "/test-project"; +const PLUGIN = "aidd-context"; +const OTHER = "aidd-dev"; +const HOOKS_PATH = join(PROJECT_ROOT, ".cursor", "hooks.json"); +const SCRIPT_PATH = join(PROJECT_ROOT, ".cursor", "hooks", PLUGIN, "pre.js"); + +function entry(plugin: string): { command: string } { + return { command: `node ./.cursor/hooks/${plugin}/pre.js` }; +} + +function hooksFile(...plugins: string[]): string { + return JSON.stringify({ version: 1, hooks: { preToolUse: plugins.map(entry) } }); +} + +describe("removeProjectHooks", () => { + it("leaves a tool that keeps hooks in its plugin directory untouched and reports nothing undone", async () => { + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: hooksFile(PLUGIN) }); + + expect(await removeProjectHooks(fs, PLUGIN, "opencode", PROJECT_ROOT)).toBe(false); + expect(fs.getFile(HOOKS_PATH)).toBe(hooksFile(PLUGIN)); + }); + + it("reports nothing undone when neither the hooks file nor the script directory exists", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("deleteDirectory", `${PROJECT_ROOT}/.cursor/hooks/${PLUGIN}/`, errnoError("EPERM")); + + expect(await removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT)).toBe(false); + }); + + it("unmerges only this plugin's entries and reports something undone when just the hooks file exists", async () => { + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: hooksFile(PLUGIN, OTHER) }); + + const undone = await removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT); + + expect(undone).toBe(true); + expect(JSON.parse(fs.getFile(HOOKS_PATH) ?? "null")).toStrictEqual({ + version: 1, + hooks: { preToolUse: [entry(OTHER)] }, + }); + }); + + it("removes the script directory and reports something undone when only the scripts exist", async () => { + const fs = new InMemoryFileAdapter({ [SCRIPT_PATH]: "module.exports = () => {};" }); + + const undone = await removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT); + + expect(undone).toBe(true); + expect(fs.listAll()).toStrictEqual([]); + }); + + it("propagates a failure to read the hooks file other than its absence", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("readFile", HOOKS_PATH, errnoError("EACCES")); + + await expect(removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT)).rejects.toThrow( + "EACCES: planted by the test" + ); + }); +}); diff --git a/cli/tests/contexts/framework/application/shared/resolve-uninstall-scope.unit.test.ts b/cli/tests/contexts/framework/application/shared/resolve-uninstall-scope.unit.test.ts index 7d74cd139..5508453f5 100644 --- a/cli/tests/contexts/framework/application/shared/resolve-uninstall-scope.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/resolve-uninstall-scope.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveUninstallScopeOrder } from "../../../../../src/contexts/framework/application/shared/resolve-uninstall-scope.js"; import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; const REF = "aidd-telemetry@aidd-framework"; const PROJECT_ROOT = "/test-project"; @@ -41,4 +42,15 @@ describe("resolveUninstallScopeOrder", () => { expect(order).toEqual(["project", "user"]); }); + + it("falls back when the host's registry could not be read at all", async () => { + const reader = new FakeHostPluginRegistryReader({ + location: "/registry", + unreadable: "not valid JSON", + }); + + const order = await resolveUninstallScopeOrder(reader, REF, PROJECT_ROOT, "user"); + + expect(order).toStrictEqual(["user", "project"]); + }); }); diff --git a/cli/tests/contexts/framework/application/shared/setup-marketplace-registration-use-case.unit.test.ts b/cli/tests/contexts/framework/application/shared/setup-marketplace-registration-use-case.unit.test.ts index abf73bd9e..c9f079fd9 100644 --- a/cli/tests/contexts/framework/application/shared/setup-marketplace-registration-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/setup-marketplace-registration-use-case.unit.test.ts @@ -4,7 +4,11 @@ import type { MarketplaceRegisterFramework } from "../../../../../src/contexts/d import { MarketplaceSourceMode } from "../../../../../src/contexts/distribution/domain/marketplace-source-mode.js"; import { SetupMarketplaceSourceUseCase } from "../../../../../src/contexts/framework/application/setup/setup-marketplace-source-use-case.js"; import { SetupMarketplaceRegistrationUseCase } from "../../../../../src/contexts/framework/application/shared/setup-marketplace-registration-use-case.js"; +import type { UserSourceReferences } from "../../../../../src/contexts/framework/domain/ports/user-source-references.js"; import { SetupFlow } from "../../../../../src/contexts/framework/domain/setup-flow.js"; +import { UserSourceReferencesAdapter } from "../../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import { CatalogFetchAuthError } from "../../../../../src/kernel/errors.js"; +import type { TokenProvider } from "../../../../../src/runtime/auth/ports/token-provider.js"; import type { LatestReleaseResolver } from "../../../../../src/runtime/self-update/latest-release-resolver.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { FakeCurrentVersion } from "../../../../helpers/ports/fake-current-version.js"; @@ -15,11 +19,11 @@ import { KeepPrompter } from "../../../../helpers/ports/scripted-prompter.js"; const PROJECT_ROOT = "/test-project"; const SKIP_SWITCH = "AIDD_SKIP_MARKETPLACE_REFRESH"; -function makeNoOpLatestResolver(): LatestReleaseResolver { +function makeNoOpLatestResolver(isRepoPublic = true): LatestReleaseResolver { return { resolveLatest: vi.fn().mockResolvedValue(null), listRootReleases: vi.fn().mockResolvedValue([]), - isRepoPublic: vi.fn().mockResolvedValue(true), + isRepoPublic: vi.fn().mockResolvedValue(isRepoPublic), }; } @@ -27,23 +31,37 @@ function makeRegisterFramework(): MarketplaceRegisterFramework { return { execute: vi.fn().mockResolvedValue({ registered: true, scope: "user" }) }; } -function makeUseCase(environment: InMemoryEnvironment): { +interface Collaborators { + tokenProvider?: TokenProvider; + releaseResolver?: LatestReleaseResolver; + userSourceReferences?: UserSourceReferences; +} + +function makeUseCase( + environment: InMemoryEnvironment, + collaborators: Collaborators = {} +): { useCase: SetupMarketplaceRegistrationUseCase; refresh: MarketplaceRefresh; + register: MarketplaceRegisterFramework; } { const refresh: MarketplaceRefresh = { execute: vi.fn().mockResolvedValue({ results: [], failedCount: 0 }), }; + const register = makeRegisterFramework(); const useCase = new SetupMarketplaceRegistrationUseCase( new InMemoryFileAdapter(), new SetupMarketplaceSourceUseCase(new KeepPrompter(), makeNoOpLatestResolver()), - makeRegisterFramework(), + register, refresh, new FakeCurrentVersion(), new CapturingLogger(), - environment + environment, + collaborators.tokenProvider, + collaborators.releaseResolver, + collaborators.userSourceReferences ); - return { useCase, refresh }; + return { useCase, refresh, register }; } async function register(environment: InMemoryEnvironment): Promise { @@ -73,4 +91,64 @@ describe("SetupMarketplaceRegistrationUseCase", () => { expect(refresh.execute).toHaveBeenCalledOnce(); }); }); + + describe("remote auth guard", () => { + it("refuses a private remote source when no token can be resolved", async () => { + const { useCase } = makeUseCase(new InMemoryEnvironment(), { + tokenProvider: { resolve: async () => null }, + releaseResolver: makeNoOpLatestResolver(false), + }); + const flow = new SetupFlow({ projectRoot: PROJECT_ROOT }); + + const attempt = useCase.registerIfPresent(flow, MarketplaceSourceMode.remote("owner/repo")); + + await expect(attempt).rejects.toThrow(CatalogFetchAuthError); + await expect(attempt).rejects.toThrow( + 'Authentication required to fetch catalog from "https://github.com/owner/repo". Run `aidd auth login` first or use `--source local --path `.' + ); + }); + }); + + describe("registration options", () => { + it("registers a local source at the project root, forcing the registration", async () => { + const { useCase, register } = makeUseCase(new InMemoryEnvironment()); + const flow = new SetupFlow({ projectRoot: PROJECT_ROOT }); + + await useCase.registerIfPresent(flow, MarketplaceSourceMode.local("/framework-source")); + + expect(register.execute).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + pluginSource: { kind: "local", path: "/framework-source" }, + force: true, + }); + }); + + it("registers a remote source by repository and ref", async () => { + const { useCase, register } = makeUseCase(new InMemoryEnvironment()); + const flow = new SetupFlow({ projectRoot: PROJECT_ROOT }); + + await useCase.registerIfPresent(flow, MarketplaceSourceMode.remote("owner/repo", "v1.0.0")); + + expect(register.execute).toHaveBeenCalledWith({ + projectRoot: PROJECT_ROOT, + pluginSource: { kind: "github", repo: "owner/repo", ref: "v1.0.0" }, + force: true, + }); + }); + }); + + describe("shared source reference", () => { + it("records this project's claim on the shared source for a project-scope setup", async () => { + const userSourceReferences = new UserSourceReferencesAdapter( + new InMemoryFileAdapter({ [`${PROJECT_ROOT}/.aidd/manifest.json`]: "{}" }), + () => "/user-config" + ); + const { useCase } = makeUseCase(new InMemoryEnvironment(), { userSourceReferences }); + const flow = new SetupFlow({ projectRoot: PROJECT_ROOT }); + + await useCase.registerIfPresent(flow, MarketplaceSourceMode.local("/framework-source")); + + expect(await userSourceReferences.listAllReferencingProjects()).toStrictEqual([PROJECT_ROOT]); + }); + }); }); diff --git a/cli/tests/contexts/framework/application/shared/shared-source-reference-support.unit.test.ts b/cli/tests/contexts/framework/application/shared/shared-source-reference-support.unit.test.ts index fab57e16d..58e5bc8d5 100644 --- a/cli/tests/contexts/framework/application/shared/shared-source-reference-support.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/shared-source-reference-support.unit.test.ts @@ -2,8 +2,16 @@ import { describe, expect, it } from "vitest"; import { describeFullRemovalInstruction, describeGuardedPluginRefMessage, + frameworkSourceIsShared, refAnotherProjectStillNeeds, + resolveProjectRootForReferences, + toleratingUnreadableSourceReferences, } from "../../../../../src/contexts/framework/application/shared/shared-source-reference-support.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; const BASE = { ref: "aidd-dev@aidd-framework", @@ -34,6 +42,47 @@ describe("refAnotherProjectStillNeeds", () => { it("is false when this run never resolved the shared source's own hostName for this tool", () => { expect(refAnotherProjectStillNeeds({ ...BASE, sharedSourceHostName: undefined })).toBe(false); }); + + it("never matches a ref against the literal text 'undefined' when no hostName was resolved", () => { + expect( + refAnotherProjectStillNeeds({ + ...BASE, + ref: "aidd-dev@undefined", + sharedSourceHostName: undefined, + }) + ).toBe(false); + }); +}); + +describe("frameworkSourceIsShared", () => { + it("is false for the framework marketplace at project scope", () => { + expect(frameworkSourceIsShared("aidd-framework", "project")).toBe(false); + }); + + it("is false for another marketplace at user scope", () => { + expect(frameworkSourceIsShared("other-marketplace", "user")).toBe(false); + }); +}); + +describe("resolveProjectRootForReferences", () => { + it("propagates a resolution failure other than the project being absent", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", "/project", errnoError("EACCES")); + + await expect(resolveProjectRootForReferences(fs, "/project")).rejects.toThrow( + "EACCES: planted by the test" + ); + }); +}); + +describe("toleratingUnreadableSourceReferences", () => { + it("propagates any failure that is not an unreadable registry", async () => { + await expect( + toleratingUnreadableSourceReferences(new CapturingLogger(), "fallback", async () => { + throw new Error("a bug, not a corrupted file"); + }) + ).rejects.toThrow("a bug, not a corrupted file"); + }); }); // Nothing else in the suite pins this sentence's grammar, so a swapped singular/plural branch @@ -48,13 +97,17 @@ describe("describeGuardedPluginRefMessage", () => { expect(message).toContain("1 other project still references"); }); - it("uses plural wording for more than one other project", () => { + it("uses plural wording and lists every project for more than one other project", () => { const message = describeGuardedPluginRefMessage({ binary: "codex", ref: "aidd-vcs@aidd-framework", otherProjects: ["/other-project", "/third-project"], }); - expect(message).toContain("2 other projects still reference"); + expect(message).toBe( + "codex: 'aidd-vcs@aidd-framework' left enabled — codex enables a plugin machine-wide, and " + + "2 other projects still reference the shared source: /other-project, /third-project — " + + "which is why it stays; full removal is `aidd clean` in each of them, then `aidd clean --scope user`." + ); }); // Full removal names both commands in order — `aidd clean` in each other project before diff --git a/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts b/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts new file mode 100644 index 000000000..b032a1c86 --- /dev/null +++ b/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts @@ -0,0 +1,112 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { userScopeFilesSafeToDelete } from "../../../../../src/contexts/framework/application/shared/user-scope-plugin-files.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +const HOME = "/home/u"; +const BOUNDARY = join(HOME, ".cursor", "plugins", "local"); +const HASH = "abc123abc123abc123abc123abc123ab"; + +function plugin(files: Record): InstalledPlugin { + return InstalledPlugin.fromJSON({ + name: "aidd-test", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files, + scope: "user", + }); +} + +describe("userScopeFilesSafeToDelete", () => { + it("answers nothing for a tool without a user-scope plugin directory", async () => { + const logger = new CapturingLogger(); + + const safe = await userScopeFilesSafeToDelete( + new InMemoryFileAdapter(), + logger, + plugin({ "aidd-test/a.md": HASH }), + "claude", + HOME + ); + + expect([...safe]).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("names and leaves in place a file whose path escapes the user-scope directory", async () => { + const logger = new CapturingLogger(); + + const safe = await userScopeFilesSafeToDelete( + new InMemoryFileAdapter(), + logger, + plugin({ "../escape.md": HASH }), + "cursor", + HOME + ); + + expect([...safe]).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([ + `cursor: 'aidd-test' file '../escape.md' does not resolve inside ${BOUNDARY}; left in place.`, + ]); + }); + + it("answers nothing, silently, when the user-scope directory itself does not exist", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", BOUNDARY, errnoError("ENOENT")); + const logger = new CapturingLogger(); + + const safe = await userScopeFilesSafeToDelete( + fs, + logger, + plugin({ "aidd-test/a.md": HASH }), + "cursor", + HOME + ); + + expect([...safe]).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("names and leaves in place a file that no longer exists", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", join(BOUNDARY, "aidd-test", "gone.md"), errnoError("ENOENT")); + const logger = new CapturingLogger(); + + const safe = await userScopeFilesSafeToDelete( + fs, + logger, + plugin({ "aidd-test/gone.md": HASH, "aidd-test/a.md": HASH }), + "cursor", + HOME + ); + + expect([...safe]).toStrictEqual([["aidd-test/a.md", HASH]]); + expect(logger.warnMessages).toStrictEqual([ + `cursor: 'aidd-test' file 'aidd-test/gone.md' does not resolve inside ${BOUNDARY}; left in place.`, + ]); + }); + + it("propagates a resolution failure other than absence", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", join(BOUNDARY, "aidd-test", "a.md"), errnoError("EACCES")); + + await expect( + userScopeFilesSafeToDelete( + fs, + new CapturingLogger(), + plugin({ "aidd-test/a.md": HASH }), + "cursor", + HOME + ) + ).rejects.toThrow("EACCES: planted by the test"); + }); +}); From 5fd59985112806439c5a05599e6eb68f24b0ae85 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:39:52 +0200 Subject: [PATCH 11/12] test(cli): raise the framework mutation floor to 93 Measured 95.4 after the survivor series (5388 killed, 225 survived, 36 uncovered of 5649), up from 72.2 (4029 killed, 50 timed out, 1186 survived, 384 uncovered). The floor is the measured score minus two. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- cli/mutation-scopes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index dfa9e4f57..2f579bc90 100644 --- a/cli/mutation-scopes.json +++ b/cli/mutation-scopes.json @@ -30,7 +30,7 @@ }, "framework": { "mutate": "src/contexts/framework/**/*.ts", - "break": 69 + "break": 93 }, "presentation": { "mutate": "src/presentation/**/*.ts", From 9d5ee0b412a0a1af102eacbe662b6a0df93bd4aa Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Wed, 9 Sep 2026 19:56:54 +0200 Subject: [PATCH 12/12] test(cli): make two framework survivor tests separator-agnostic The in-memory file adapter normalises every key to forward slashes, so a listing is compared to the normalised form rather than to a platform join. The user source references adapter joins its own path, so the expected error messages are built with the same join and the unparsable-JSON reason is matched as a substring rather than a slash-specific regex. Both failed only on Windows. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../shared/purge-native-marketplace-cache.unit.test.ts | 2 +- .../user-source-references-adapter.unit.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts b/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts index 3c75c93ea..673bf4101 100644 --- a/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts @@ -43,7 +43,7 @@ describe("purgeAllNativeCaches", () => { ); expect(logger.allMessages).toStrictEqual([]); - expect(fs.listAll()).toStrictEqual([join(HOME, ".cursor", "plugins", "a.json")]); + expect(fs.listAll()).toStrictEqual([`${HOME}/.cursor/plugins/a.json`]); }); }); diff --git a/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts b/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts index 68c61e3de..2e0b5a387 100644 --- a/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts +++ b/cli/tests/contexts/framework/infrastructure/user-source-references-adapter.unit.test.ts @@ -1,10 +1,11 @@ +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { UserSourceReferencesAdapter } from "../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; import { UnreadableUserSourceReferencesError } from "../../../../src/kernel/errors.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; const USER_CONFIG_DIR = "/fake-home/.config/aidd"; -const REFERENCES_PATH = `${USER_CONFIG_DIR}/references.json`; +const REFERENCES_PATH = join(USER_CONFIG_DIR, "references.json"); function adapter(fs: InMemoryFileAdapter = new InMemoryFileAdapter()): UserSourceReferencesAdapter { return new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); @@ -234,7 +235,7 @@ describe("the shared source's own project references", () => { const refs = adapter(fs); await expect(refs.listAllReferencingProjects()).rejects.toThrow( - /registry at \/fake-home\/\.config\/aidd\/references\.json: Unexpected token/ + `registry at ${REFERENCES_PATH}: Unexpected token` ); });