From efd866e33b92248dfbfe24f0129b02b7b1978336 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 10 Sep 2026 10:03:43 +0200 Subject: [PATCH 1/4] test(cli): kill the surviving mutants of the kernel Every error class now has its exact name and message pinned, and the pure kernel helpers that had no test of their own (jsonc stripping, confined file names, the JSON narrowers, describeError, isMergeContentEmpty, removeRedundantGitkeeps) get one. The frontmatter parser, semver precedence, plugin-source messages and the built-cache path parsers are asserted structure by structure, so a dropped anchor, a swapped join or a missing branch turns a test red. Kernel scope, every mutant replayed: 75.2 to 97.1 (killed 1449, timeout 32, survived 45, uncovered 0 of 1526). The 45 left are documented equivalents plus seven stryker records as survived although the hand-applied mutation turns the named test red. Floor raised from 71 to 95. 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 +- cli/tests/kernel/describe-error.unit.test.ts | 32 + cli/tests/kernel/errors.unit.test.ts | 620 +++++++++++++++++- cli/tests/kernel/file.unit.test.ts | 64 ++ .../kernel/markdown-yaml-like.unit.test.ts | 123 ++++ .../flat-paths-hooks-prefix.unit.test.ts | 10 + .../kernel/merge-content-empty.unit.test.ts | 36 + cli/tests/kernel/paths.unit.test.ts | 70 ++ .../reading/aidd-config-dir.unit.test.ts | 12 + .../reading/confined-file-name.unit.test.ts | 32 + .../kernel/reading/json-file.unit.test.ts | 35 + cli/tests/kernel/reading/jsonc.unit.test.ts | 58 ++ .../kernel/semver-precedence.unit.test.ts | 67 ++ cli/tests/kernel/source-messages.unit.test.ts | 62 ++ 14 files changed, 1193 insertions(+), 30 deletions(-) create mode 100644 cli/tests/kernel/describe-error.unit.test.ts create mode 100644 cli/tests/kernel/file.unit.test.ts create mode 100644 cli/tests/kernel/markdown-yaml-like.unit.test.ts create mode 100644 cli/tests/kernel/materialization/flat-paths-hooks-prefix.unit.test.ts create mode 100644 cli/tests/kernel/merge-content-empty.unit.test.ts create mode 100644 cli/tests/kernel/paths.unit.test.ts create mode 100644 cli/tests/kernel/reading/aidd-config-dir.unit.test.ts create mode 100644 cli/tests/kernel/reading/confined-file-name.unit.test.ts create mode 100644 cli/tests/kernel/reading/json-file.unit.test.ts create mode 100644 cli/tests/kernel/reading/jsonc.unit.test.ts create mode 100644 cli/tests/kernel/semver-precedence.unit.test.ts create mode 100644 cli/tests/kernel/source-messages.unit.test.ts diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index 1cbb4ac59..f01adb371 100644 --- a/cli/mutation-scopes.json +++ b/cli/mutation-scopes.json @@ -3,7 +3,7 @@ "scopes": { "kernel": { "mutate": "src/kernel/**/*.ts", - "break": 71 + "break": 95 }, "tools": { "mutate": [ diff --git a/cli/tests/kernel/describe-error.unit.test.ts b/cli/tests/kernel/describe-error.unit.test.ts new file mode 100644 index 000000000..360ff52db --- /dev/null +++ b/cli/tests/kernel/describe-error.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { describeError, errorMessage } from "../../src/kernel/describe-error.js"; + +describe("describeError", () => { + it("answers the code of a filesystem failure", () => { + expect(describeError(Object.assign(new Error("open failed"), { code: "ENOENT" }))).toBe( + "ENOENT" + ); + }); + + it("answers the message when the code is not a string", () => { + expect(describeError(Object.assign(new Error("open failed"), { code: 2 }))).toBe("open failed"); + }); + + it("answers the message of an Error carrying no code", () => { + expect(describeError(new SyntaxError("Unexpected token"))).toBe("Unexpected token"); + }); + + it("stringifies a thrown value that is not an Error, whatever fields it carries", () => { + expect(describeError({ code: "ENOENT" })).toBe("[object Object]"); + }); +}); + +describe("errorMessage", () => { + it("answers an Error's message", () => { + expect(errorMessage(new Error("bad json"))).toBe("bad json"); + }); + + it("stringifies a thrown value that is not an Error", () => { + expect(errorMessage(42)).toBe("42"); + }); +}); diff --git a/cli/tests/kernel/errors.unit.test.ts b/cli/tests/kernel/errors.unit.test.ts index 4f07f5756..08e52e368 100644 --- a/cli/tests/kernel/errors.unit.test.ts +++ b/cli/tests/kernel/errors.unit.test.ts @@ -1,20 +1,9 @@ import { describe, expect, it } from "vitest"; -import { - AiddFilesDetectedError, - AlreadyInitializedError, - AuthStorageError, - FlatTargetExistsError, - HttpRedirectError, - InputRequiredError, - JsonParseError, - NoManifestError, - OutDirNotDirectoryError, - ToolNotInstalledError, -} from "../../src/kernel/errors.js"; +import * as errors from "../../src/kernel/errors.js"; describe("NoManifestError", () => { it("includes aidd setup hint in message", () => { - const error = new NoManifestError(); + const error = new errors.NoManifestError(); expect(error.message).toContain("aidd setup"); expect(error.name).toBe("NoManifestError"); }); @@ -22,7 +11,7 @@ describe("NoManifestError", () => { describe("AiddFilesDetectedError", () => { it("includes setup hint in message", () => { - const error = new AiddFilesDetectedError(); + const error = new errors.AiddFilesDetectedError(); expect(error.message).toContain("AIDD files detected but no manifest found"); expect(error.message).toContain("aidd setup"); expect(error.name).toBe("AiddFilesDetectedError"); @@ -31,7 +20,7 @@ describe("AiddFilesDetectedError", () => { describe("FlatTargetExistsError", () => { it("has correct error name", () => { - const error = new FlatTargetExistsError( + const error = new errors.FlatTargetExistsError( "/out/.github/agents/my-plugin/foo.agent.md", "my-plugin" ); @@ -39,7 +28,7 @@ describe("FlatTargetExistsError", () => { }); it("includes the conflicting path in the message", () => { - const error = new FlatTargetExistsError( + const error = new errors.FlatTargetExistsError( "/out/.github/agents/my-plugin/foo.agent.md", "my-plugin" ); @@ -47,7 +36,7 @@ describe("FlatTargetExistsError", () => { }); it("includes the plugin name in the message", () => { - const error = new FlatTargetExistsError( + const error = new errors.FlatTargetExistsError( "/out/.github/agents/my-plugin/foo.agent.md", "my-plugin" ); @@ -55,7 +44,7 @@ describe("FlatTargetExistsError", () => { }); it("mentions --force hint in message", () => { - const error = new FlatTargetExistsError( + const error = new errors.FlatTargetExistsError( "/out/.github/agents/my-plugin/foo.agent.md", "my-plugin" ); @@ -65,17 +54,17 @@ describe("FlatTargetExistsError", () => { describe("OutDirNotDirectoryError", () => { it("has correct error name", () => { - const error = new OutDirNotDirectoryError("/tmp/some-out"); + const error = new errors.OutDirNotDirectoryError("/tmp/some-out"); expect(error.name).toBe("OutDirNotDirectoryError"); }); it("includes the outDir path in the message", () => { - const error = new OutDirNotDirectoryError("/tmp/some-out"); + const error = new errors.OutDirNotDirectoryError("/tmp/some-out"); expect(error.message).toContain("/tmp/some-out"); }); it("does not mention source directory in the message", () => { - const error = new OutDirNotDirectoryError("/tmp/some-out"); + const error = new errors.OutDirNotDirectoryError("/tmp/some-out"); expect(error.message).not.toContain("--source"); expect(error.message).toContain("not a directory"); }); @@ -83,20 +72,20 @@ describe("OutDirNotDirectoryError", () => { describe("AlreadyInitializedError", () => { it("has default message when no argument provided", () => { - const error = new AlreadyInitializedError(); + const error = new errors.AlreadyInitializedError(); expect(error.name).toBe("AlreadyInitializedError"); expect(error.message).toContain("Already initialized"); }); it("uses provided message when given", () => { - const error = new AlreadyInitializedError("Custom message here."); + const error = new errors.AlreadyInitializedError("Custom message here."); expect(error.message).toBe("Custom message here."); }); }); describe("InputRequiredError", () => { it("carries the provided message", () => { - const error = new InputRequiredError("Prompt answer is required."); + const error = new errors.InputRequiredError("Prompt answer is required."); expect(error.name).toBe("InputRequiredError"); expect(error.message).toBe("Prompt answer is required."); }); @@ -104,13 +93,13 @@ describe("InputRequiredError", () => { describe("ToolNotInstalledError", () => { it("includes tool ID in message without context", () => { - const error = new ToolNotInstalledError("claude"); + const error = new errors.ToolNotInstalledError("claude"); expect(error.name).toBe("ToolNotInstalledError"); expect(error.message).toContain("claude"); }); it("includes context and tool ID when context is provided", () => { - const error = new ToolNotInstalledError("cursor", "The target tool"); + const error = new errors.ToolNotInstalledError("cursor", "The target tool"); expect(error.message).toContain("cursor"); expect(error.message).toContain("The target tool"); }); @@ -118,7 +107,7 @@ describe("ToolNotInstalledError", () => { describe("HttpRedirectError", () => { it("includes the URL in the message and sets error name", () => { - const error = new HttpRedirectError("https://example.com/redirect"); + const error = new errors.HttpRedirectError("https://example.com/redirect"); expect(error.name).toBe("HttpRedirectError"); expect(error.message).toContain("https://example.com/redirect"); expect(error.url).toBe("https://example.com/redirect"); @@ -127,7 +116,7 @@ describe("HttpRedirectError", () => { describe("JsonParseError", () => { it("includes the path and cause in the message", () => { - const error = new JsonParseError("/some/file.json", "Unexpected token"); + const error = new errors.JsonParseError("/some/file.json", "Unexpected token"); expect(error.name).toBe("JsonParseError"); expect(error.message).toContain("/some/file.json"); expect(error.message).toContain("Unexpected token"); @@ -136,8 +125,581 @@ describe("JsonParseError", () => { describe("AuthStorageError", () => { it("carries the provided message", () => { - const error = new AuthStorageError("Failed to write auth file"); + const error = new errors.AuthStorageError("Failed to write auth file"); expect(error.name).toBe("AuthStorageError"); expect(error.message).toBe("Failed to write auth file"); }); }); + +describe("every error in the catalog", () => { + const catalogue: readonly { + readonly build: () => Error; + readonly name: string; + readonly message: string; + }[] = [ + { + build: () => new errors.CapabilityConfigError("cap broke"), + name: "CapabilityConfigError", + message: "cap broke", + }, + { + build: () => new errors.CursorProjectScopeUnsupportedError(), + name: "CursorProjectScopeUnsupportedError", + message: + "Cursor plugins only support user-scope install (~/.cursor/plugins/local/). Project-scope is not auto-loaded by Cursor.", + }, + { + build: () => new errors.InvalidPluginScopeError("cursor", "project", "user"), + name: "InvalidPluginScopeError", + message: + "Tool 'cursor' does not support scope 'project'. Supported scope: 'user'. Re-run with --scope user or omit the flag.", + }, + { + build: () => new errors.AuthenticationError("gh"), + name: "AuthenticationError", + message: "Authentication failed (gh). Run `aidd auth login` to authenticate.", + }, + { + build: () => new errors.UpdateError(), + name: "UpdateError", + message: + "Update failed. If you saw a 403 error above, ensure your GitHub token includes both repo and read:packages scopes.\nUpdate your token at https://github.com/settings/tokens, then re-run `aidd auth login`.", + }, + { + build: () => new errors.ElevatedPermissionUpdateError("npm install -g x@latest"), + name: "ElevatedPermissionUpdateError", + message: [ + "Update failed: the global package directory is not writable (EPERM/EACCES).", + "Pick one:", + " 1. Run the terminal as Administrator (Windows) or with sudo (macOS/Linux), then re-run `aidd update`.", + " 2. Move global installs to a user-writable prefix, then re-run the update:", + " Windows: npm config set prefix %APPDATA%\\npm", + " macOS/Linux: npm config set prefix ~/.npm-global", + " 3. Run the update directly: npm install -g x@latest", + ].join("\n"), + }, + { + build: () => new errors.ManifestValidationError("bad manifest"), + name: "ManifestValidationError", + message: "bad manifest", + }, + { + build: () => new errors.McpConfigError("bad mcp"), + name: "McpConfigError", + message: "bad mcp", + }, + { + build: () => new errors.FrameworkResolutionError("no framework"), + name: "FrameworkResolutionError", + message: "no framework", + }, + { + build: () => new errors.CategoryMismatchError(["vscode"], "ai", ["claude", "cursor"]), + name: "CategoryMismatchError", + message: "vscode is not an AI tool. Valid AI tools: claude, cursor", + }, + { + build: () => new errors.CategoryMismatchError(["claude", "cursor"], "ide", ["vscode"]), + name: "CategoryMismatchError", + message: "claude, cursor are not IDE tools. Valid IDE tools: vscode", + }, + { + build: () => new errors.UnregisteredToolError("zed"), + name: "UnregisteredToolError", + message: "Tool 'zed' is not registered.", + }, + { + build: () => new errors.ToolNotInManifestError("zed"), + name: "ToolNotInManifestError", + message: "Tool 'zed' is not installed in the manifest.", + }, + { + build: () => new errors.InvalidManifestDataError("version missing"), + name: "InvalidManifestDataError", + message: "Invalid manifest data: version missing", + }, + { + build: () => new errors.InvalidManifestDataError(), + name: "InvalidManifestDataError", + message: "Invalid manifest data.", + }, + { + build: () => new errors.InvalidManifestToolIdError("zed"), + name: "InvalidManifestToolIdError", + message: "Invalid tool id in manifest: 'zed'.", + }, + { + build: () => new errors.InvalidMcpServerConfigError("db"), + name: "InvalidMcpServerConfigError", + message: 'MCP server "db" must have either a "command" or "url" field', + }, + { + build: () => new errors.OpencodeDualConfigError(), + name: "OpencodeDualConfigError", + message: "Both opencode.json and opencode.jsonc exist. Remove one.", + }, + { + build: () => new errors.PackageManagerDetectionError(["npm i -g x", "pnpm add -g x"]), + name: "PackageManagerDetectionError", + message: "Could not detect package manager. Run manually:\n npm i -g x\n pnpm add -g x", + }, + { + build: () => new errors.InvalidPluginSourceError("no kind"), + name: "InvalidPluginSourceError", + message: "Invalid plugin source: no kind", + }, + { + build: () => new errors.InvalidPluginSourceError(), + name: "InvalidPluginSourceError", + message: "Invalid plugin source.", + }, + { + build: () => new errors.InvalidPluginNameError("My Plugin"), + name: "InvalidPluginNameError", + message: + 'Invalid plugin name: "My Plugin". Use lowercase alphanumeric characters and hyphens only.', + }, + { + build: () => new errors.InvalidPluginVersionError("latest"), + name: "InvalidPluginVersionError", + message: 'Invalid plugin version: "latest". Expected semver format (e.g. 1.0.0).', + }, + { + build: () => new errors.MalformedPluginScopeError("aidd-dev", "global"), + name: "MalformedPluginScopeError", + message: + 'Plugin "aidd-dev" carries an invalid scope: "global". Expected "project" or "user".', + }, + { + build: () => new errors.UnresolvableUserScopeError("cursor"), + name: "UnresolvableUserScopeError", + message: + 'Manifest records a user-scope plugin for "cursor", but this tool\'s profile declares no user-scope plugins directory. Refusing to guess a base directory rather than silently resolving under the project root.', + }, + { + build: () => new errors.InvalidPluginManifestError("name missing"), + name: "InvalidPluginManifestError", + message: "Invalid plugin manifest: name missing", + }, + { + build: () => new errors.InvalidPluginManifestError(), + name: "InvalidPluginManifestError", + message: "Invalid plugin manifest.", + }, + { + build: () => new errors.MalformedMarketplaceCatalogError("/c.json", "not json", true), + name: "MalformedMarketplaceCatalogError", + message: + "Invalid plugin manifest: catalog at \"/c.json\" is malformed (not json). Run 'aidd marketplace refresh --force' to re-fetch a clean copy.", + }, + { + build: () => new errors.MalformedMarketplaceCatalogError("/c.json", "not json", false), + name: "MalformedMarketplaceCatalogError", + message: + 'Invalid plugin manifest: catalog at "/c.json" is malformed (not json). Fix or re-create the marketplace catalog file.', + }, + { + build: () => new errors.PluginNotFoundError("aidd-dev"), + name: "PluginNotFoundError", + message: "Plugin 'aidd-dev' is not installed.", + }, + { + build: () => new errors.DuplicatePluginError("aidd-dev"), + name: "DuplicatePluginError", + message: "Plugin 'aidd-dev' is already installed.", + }, + { + build: () => new errors.PluginFetchError("clone refused"), + name: "PluginFetchError", + message: "Failed to fetch plugin: clone refused", + }, + { + build: () => new errors.InvalidMarketplaceNameError("My Market"), + name: "InvalidMarketplaceNameError", + message: + 'Invalid marketplace name: "My Market". Use lowercase alphanumeric characters and hyphens only.', + }, + { + build: () => new errors.InvalidMarketplaceScopeError("global"), + name: "InvalidMarketplaceScopeError", + message: 'Invalid marketplace scope: "global". Expected "project" or "user".', + }, + { + build: () => new errors.MarketplaceAlreadyRegisteredError("aidd"), + name: "MarketplaceAlreadyRegisteredError", + message: "Marketplace 'aidd' is already registered.", + }, + { + build: () => new errors.MarketplaceNotFoundError("aidd"), + name: "MarketplaceNotFoundError", + message: "Marketplace 'aidd' is not registered.", + }, + { + build: () => new errors.TrustDeniedError("aidd"), + name: "TrustDeniedError", + message: "Trust denied for marketplace 'aidd'. Aborting.", + }, + { + build: () => new errors.PluginNotInMarketplaceError("aidd-dev"), + name: "PluginNotInMarketplaceError", + message: "Plugin 'aidd-dev' was not found in any registered marketplace.", + }, + { + build: () => new errors.VersionMismatchError("aidd-dev", "1.0.0", "2.0.0"), + name: "VersionMismatchError", + message: + "Plugin 'aidd-dev': requested version '1.0.0' does not match catalog version '2.0.0'.", + }, + { + build: () => new errors.AmbiguousPluginMatchError("aidd-dev", ["a", "b"]), + name: "AmbiguousPluginMatchError", + message: "Plugin 'aidd-dev' matches multiple marketplaces: a, b. Use --from .", + }, + { + build: () => new errors.NoMarketplacesRegisteredError(), + name: "NoMarketplacesRegisteredError", + message: "No marketplaces registered. Use `aidd marketplace add ` first.", + }, + { + build: () => new errors.UnreadableMarketplaceRegistryError("/r.json", "EACCES"), + name: "UnreadableMarketplaceRegistryError", + message: + "Cannot read the marketplace registry at /r.json: EACCES. Repair the file, or delete it to start from an empty registry.", + }, + { + build: () => new errors.UnreadableUserSourceReferencesError("/refs.json", "EACCES"), + name: "UnreadableUserSourceReferencesError", + message: + "Cannot read the shared-source reference registry at /refs.json: EACCES. Repair the file, or delete it to start from an empty registry.", + }, + { + build: () => new errors.InteractiveOnlyError("aidd setup"), + name: "InteractiveOnlyError", + message: "'aidd setup' requires an interactive terminal.", + }, + { + build: () => + new errors.SyncFailedError([ + { scope: "claude", message: "x" }, + { scope: "codex", message: "y" }, + ]), + name: "SyncFailedError", + message: "Sync failed for: claude, codex. See the warnings above.", + }, + { + build: () => new errors.CatalogFetchNotFoundError("https://x/c.json"), + name: "CatalogFetchNotFoundError", + message: "Catalog not found (HTTP 404): https://x/c.json", + }, + { + build: () => new errors.CatalogFetchAuthError("https://x/c.json"), + name: "CatalogFetchAuthError", + message: + 'Authentication required to fetch catalog from "https://x/c.json". Run `aidd auth login` first or use `--source local --path `.', + }, + { + build: () => new errors.CatalogFetchError("https://x/c.json", "timeout"), + name: "CatalogFetchError", + message: 'Failed to fetch catalog from "https://x/c.json": timeout', + }, + { + build: () => new errors.MissingPluginMetadataError(), + name: "MissingPluginMetadataError", + message: + "Cannot register github marketplace plugin: catalog entry is missing plugin metadata.", + }, + { + build: () => new errors.JsonSchemaValidationError(["a", "b"]), + name: "JsonSchemaValidationError", + message: "Manifest validation failed: a; b", + }, + { + build: () => new errors.FrameworkPlaceholderInPluginError("aidd-dev", "skills/x.md"), + name: "FrameworkPlaceholderInPluginError", + message: + "Framework placeholder '@{{TOOLS}}/' is not allowed inside plugin 'aidd-dev' (file: skills/x.md).", + }, + { + build: () => new errors.InvalidBuildPathsError("/src", "/src/out"), + name: "InvalidBuildPathsError", + message: + "Refusing to build: --out '/src/out' and --source '/src' must not contain each other.", + }, + { + build: () => new errors.InvalidSourceMarketplaceError("no plugins"), + name: "InvalidSourceMarketplaceError", + message: "Invalid source marketplace: no plugins.", + }, + { + build: () => new errors.OutDirNotDirectoryError("/out"), + name: "OutDirNotDirectoryError", + message: "Refusing to build: --out '/out' does not exist or is not a directory.", + }, + { + build: () => new errors.FlatTargetExistsError("/out/a.md", "aidd-dev"), + name: "FlatTargetExistsError", + message: + "Flat build conflict: '/out/a.md' already exists (plugin 'aidd-dev'). Re-run with --force to overwrite.", + }, + { + build: () => new errors.MarketplaceOutDirNotEmptyError("/out"), + name: "MarketplaceOutDirNotEmptyError", + message: + "Refusing to build: '/out' is not empty. Re-run with --force to overwrite files this build produces, or choose an empty --out directory.", + }, + { + build: () => new errors.UnknownToolCategoryError("editor"), + name: "UnknownToolCategoryError", + message: "Unknown category: editor", + }, + { + build: () => new errors.MarketplaceSourceKindError("remote"), + name: "MarketplaceSourceKindError", + message: "Not a remote source", + }, + { + build: () => new errors.MarketplaceSourceKindError("local"), + name: "MarketplaceSourceKindError", + message: "Not a local source", + }, + { + build: () => new errors.EmptyLocalSourcePathError(), + name: "EmptyLocalSourcePathError", + message: "Local source path must not be empty.", + }, + { + build: () => new errors.InvalidSetupToolIdError("zed", ["claude", "cursor"]), + name: "InvalidSetupToolIdError", + message: 'Invalid tool ID: "zed". Valid IDs: claude, cursor', + }, + { + build: () => new errors.UserScopeUnavailableError(), + name: "UserScopeUnavailableError", + message: + "--scope user is not wired for this command yet — no user-scope manifest repository was provided at construction.", + }, + { + build: () => new errors.UserScopeIdeToolsError(["vscode", "zed"]), + name: "UserScopeIdeToolsError", + message: + "--scope user installs no project files, so an IDE tool (vscode, zed) has nothing to install at user scope. Drop --ide, or run `aidd setup --ide ` separately at project scope.", + }, + { + build: () => new errors.UserScopeUnsupportedAiToolsError(["cursor", "opencode"]), + name: "UserScopeUnsupportedAiToolsError", + message: + "--scope user drives native activation machine-wide, and cursor, opencode declares no user-scope settings this CLI can point at. Drop it from --ai, or run `aidd setup --ai ` separately at project scope.", + }, + { + build: () => new errors.UserScopeNoToolsError(), + name: "UserScopeNoToolsError", + message: + "--scope user with no --ai registers the shared source for no tool at all. Pass `--ai ` naming which tool to activate machine-wide.", + }, + { + build: () => new errors.UserScopePluginModeError(), + name: "UserScopePluginModeError", + message: + "--scope user has no manifest entry a plugin can be recorded against yet, so --plugins has nothing to enable. Drop --plugins, or run `aidd plugin install` separately at project scope.", + }, + { + build: () => new errors.UserScopeFilterUnsupportedError("--plugin", "sync"), + name: "UserScopeFilterUnsupportedError", + message: + "--scope user tracks nothing --plugin can narrow — it names every requested tool, not one plugin or one file. Drop --plugin, or run `aidd sync` at project scope.", + }, + { + build: () => new errors.InvalidPluginModeConfigError("bad mode"), + name: "InvalidPluginModeConfigError", + message: "bad mode", + }, + { + build: () => new errors.InvalidInstallScopeError("global"), + name: "InvalidInstallScopeError", + message: "Invalid scope 'global'. Expected 'project' or 'user'.", + }, + { + build: () => new errors.UnknownAiToolIdError("zed", ["claude", "cursor"]), + name: "UnknownAiToolIdError", + message: "Unknown AI tool: zed. Valid AI tools: claude, cursor", + }, + { + build: () => new errors.NativePluginCliError("claude exited 2"), + name: "NativePluginCliError", + message: "claude exited 2", + }, + { + build: () => new errors.MarketplaceSourceConflictError("name taken"), + name: "MarketplaceSourceConflictError", + message: "name taken", + }, + { + build: () => new errors.UnreadableBuiltCatalogError("/built/c.json"), + name: "UnreadableBuiltCatalogError", + message: + "Cannot read the marketplace catalog this project just built, at /built/c.json — nothing was registered for it. Run `aidd sync` again once the source is fixed.", + }, + { + build: () => new errors.HttpError(500, "https://x"), + name: "HttpError", + message: "Unexpected HTTP 500 from https://x", + }, + { + build: () => new errors.HttpNotFoundError("https://x"), + name: "HttpNotFoundError", + message: "Resource not found (HTTP 404): https://x", + }, + { + build: () => new errors.HttpRedirectError("https://x"), + name: "HttpRedirectError", + message: "HTTP redirect without location header from https://x", + }, + { + build: () => new errors.JsonParseError("/f.json", "Unexpected token"), + name: "JsonParseError", + message: "Cannot parse existing JSON at /f.json: Unexpected token", + }, + { + build: () => new errors.AuthStorageError("no write"), + name: "AuthStorageError", + message: "no write", + }, + { + build: () => new errors.GhCliError("gh exited 1"), + name: "GhCliError", + message: "gh exited 1", + }, + { + build: () => new errors.AssetNotFoundError("schema.json"), + name: "AssetNotFoundError", + message: "Bundled asset not found: 'schema.json'", + }, + { + build: () => new errors.NoManifestError(), + name: "NoManifestError", + message: "No AIDD manifest found. Run `aidd setup` to initialize your project.", + }, + { + build: () => new errors.AiddFilesDetectedError(), + name: "AiddFilesDetectedError", + message: + "AIDD files detected but no manifest found.\nRun `aidd setup` to register existing files.", + }, + { + build: () => new errors.AlreadyInitializedError(), + name: "AlreadyInitializedError", + message: "Already initialized. Use `aidd update` to upgrade.", + }, + { + build: () => new errors.InputRequiredError("answer needed"), + name: "InputRequiredError", + message: "answer needed", + }, + { + build: () => new errors.ToolNotInstalledError("claude"), + name: "ToolNotInstalledError", + message: "claude is not installed", + }, + { + build: () => new errors.ToolNotInstalledError("cursor", "The target tool"), + name: "ToolNotInstalledError", + message: "The target tool 'cursor' is not installed.", + }, + { + build: () => new errors.UnknownTelemetrySinkSchemaVersionError(7), + name: "UnknownTelemetrySinkSchemaVersionError", + message: "Unknown telemetry sink schema version '7' — refusing to guess its shape.", + }, + { + build: () => new errors.OpencodeExportError("export timed out"), + name: "OpencodeExportError", + message: "export timed out", + }, + { + build: () => new errors.InvalidReportDayError("--from", "yesterday"), + name: "InvalidReportDayError", + message: "Invalid --from 'yesterday'. Expected a UTC day, as YYYY-MM-DD.", + }, + { + build: () => new errors.InvalidReportSpanError("0", 90), + name: "InvalidReportSpanError", + message: "Invalid --days '0'. Expected an integer between 1 and 90.", + }, + { + build: () => new errors.UnreadableIdentityFileError("/id.json", "EISDIR"), + name: "UnreadableIdentityFileError", + message: "Could not read the identity file at /id.json (EISDIR).", + }, + { + build: () => + new errors.TelemetryProjectScopeRequiresYesError("telemetry on", ".aidd/config.json"), + name: "TelemetryProjectScopeRequiresYesError", + message: + "telemetry on writes the git-tracked .aidd/config.json, turning telemetry on for everyone who clones. Pass --yes to confirm.", + }, + { + build: () => new errors.EmptyDisplayNameError(), + name: "EmptyDisplayNameError", + message: "`aidd telemetry identity use --name` needs a non-empty value.", + }, + { + build: () => new errors.IdentityRequiredToLinkError(), + name: "IdentityRequiredToLinkError", + message: "No identity to link onto yet. Run `aidd telemetry identity use` first.", + }, + { + build: () => new errors.EmptyIdentifierError("link"), + name: "EmptyIdentifierError", + message: "`aidd telemetry identity link` needs a non-empty value.", + }, + { + build: () => new errors.TelemetrySinkUnwritableError("/sink", new Error("EACCES")), + name: "TelemetrySinkUnwritableError", + message: "Telemetry sink directory is not writable: /sink (EACCES)", + }, + { + build: () => new errors.TelemetrySinkUnwritableError("/sink", "disk full"), + name: "TelemetrySinkUnwritableError", + message: "Telemetry sink directory is not writable: /sink (disk full)", + }, + { + build: () => new errors.IdentityWriteError("/id.json", new Error("ENOSPC")), + name: "IdentityWriteError", + message: "Could not write the identity file at /id.json (ENOSPC).", + }, + { + build: () => new errors.IdentityWriteError("/id.json", "EACCES", "remove"), + name: "IdentityWriteError", + message: "Could not remove the identity file at /id.json (EACCES).", + }, + ]; + + it.each(catalogue.map((entry) => [entry.name, entry] as const))( + "%s says exactly what went wrong", + (_name, entry) => { + const error = entry.build(); + expect({ name: error.name, message: error.message }).toStrictEqual({ + name: entry.name, + message: entry.message, + }); + } + ); + + it("keeps a malformed catalog an invalid plugin manifest, so existing catches still hold", () => { + expect(new errors.MalformedMarketplaceCatalogError("/c.json", "x", true)).toBeInstanceOf( + errors.InvalidPluginManifestError + ); + }); + + it("carries the status code and the URL an HTTP failure came from", () => { + const error = new errors.HttpError(503, "https://x/y"); + expect({ statusCode: error.statusCode, url: error.url }).toStrictEqual({ + statusCode: 503, + url: "https://x/y", + }); + }); + + it("carries the URL a 404 came from", () => { + expect(new errors.HttpNotFoundError("https://x/y").url).toBe("https://x/y"); + }); +}); diff --git a/cli/tests/kernel/file.unit.test.ts b/cli/tests/kernel/file.unit.test.ts new file mode 100644 index 000000000..3bd6b6787 --- /dev/null +++ b/cli/tests/kernel/file.unit.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { ManifestValidationError } from "../../src/kernel/errors.js"; +import { FileHash, InstallationFile, removeRedundantGitkeeps } from "../../src/kernel/file.js"; + +const HASH = new FileHash("d41d8cd98f00b204e9800998ecf8427e"); + +function file(relativePath: string): InstallationFile { + return new InstallationFile({ relativePath, content: "", hash: HASH }); +} + +function pathsOf(files: readonly InstallationFile[]): string[] { + return files.map((f) => f.relativePath); +} + +describe("FileHash", () => { + it("names the value it refused and the shape it wanted", () => { + expect(() => new FileHash("abc")).toThrow( + new ManifestValidationError('Invalid MD5 hash: "abc". Expected 32 lowercase hex characters.') + ); + }); +}); + +describe("InstallationFile", () => { + it("defaults to no merge strategy", () => { + expect(file("a.md").mergeStrategy).toBe("none"); + }); +}); + +describe("removeRedundantGitkeeps", () => { + it("drops a .gitkeep from a directory that holds another file", () => { + const kept = removeRedundantGitkeeps([file("dir/.gitkeep"), file("dir/a.md")]); + expect(pathsOf(kept)).toStrictEqual(["dir/a.md"]); + }); + + it("keeps a .gitkeep in a directory holding nothing else", () => { + const kept = removeRedundantGitkeeps([file("empty/.gitkeep"), file("other/a.md")]); + expect(pathsOf(kept)).toStrictEqual(["empty/.gitkeep", "other/a.md"]); + }); + + it("drops a .gitkeep two levels down when a sibling file sits beside it", () => { + const kept = removeRedundantGitkeeps([file("a/b/.gitkeep"), file("a/b/c.md")]); + expect(pathsOf(kept)).toStrictEqual(["a/b/c.md"]); + }); + + it("does not let a file in a subdirectory count for its parent", () => { + const kept = removeRedundantGitkeeps([file("dir/.gitkeep"), file("dir/sub/a.md")]); + expect(pathsOf(kept)).toStrictEqual(["dir/.gitkeep", "dir/sub/a.md"]); + }); + + it("does not let a file in the parent count for a subdirectory", () => { + const kept = removeRedundantGitkeeps([file("dir/sub/.gitkeep"), file("dir/a.md")]); + expect(pathsOf(kept)).toStrictEqual(["dir/sub/.gitkeep", "dir/a.md"]); + }); + + it("treats a file merely named like a gitkeep as an ordinary file", () => { + const kept = removeRedundantGitkeeps([file("dir/my.gitkeep"), file("dir/a.md")]); + expect(pathsOf(kept)).toStrictEqual(["dir/my.gitkeep", "dir/a.md"]); + }); + + it("returns the files it was given when none is a .gitkeep", () => { + const kept = removeRedundantGitkeeps([file("a.md"), file("dir/b.md")]); + expect(pathsOf(kept)).toStrictEqual(["a.md", "dir/b.md"]); + }); +}); diff --git a/cli/tests/kernel/markdown-yaml-like.unit.test.ts b/cli/tests/kernel/markdown-yaml-like.unit.test.ts new file mode 100644 index 000000000..27812aed6 --- /dev/null +++ b/cli/tests/kernel/markdown-yaml-like.unit.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { parseFrontmatter, serializeFrontmatter } from "../../src/kernel/markdown.js"; + +function frontmatterOf(lines: readonly string[]): Record { + return parseFrontmatter(`---\n${lines.join("\n")}\n---\nbody`).frontmatter; +} + +describe("parseFrontmatter, line by line", () => { + describe("a key", () => { + it("must start the line: an indented line under a scalar is ignored", () => { + expect(frontmatterOf(["parent: value", " child: v", " other:"])).toStrictEqual({ + parent: "value", + }); + }); + + it("may carry trailing spaces before its list", () => { + expect(frontmatterOf(["list: ", " - a"])).toStrictEqual({ list: ["a"] }); + }); + + it("reads a value with no space after the colon", () => { + expect(frontmatterOf(["key:value"])).toStrictEqual({ key: "value" }); + }); + + it("drops the spaces around a value", () => { + expect(frontmatterOf(["key: value "])).toStrictEqual({ key: "value" }); + }); + }); + + describe("a list", () => { + it("drops the spaces around an item", () => { + expect(frontmatterOf(["tags:", " - a "])).toStrictEqual({ tags: ["a"] }); + }); + + it("ends at the next key, even one whose value looks like an item", () => { + expect(frontmatterOf(["tags:", " - a", "note: keep - this"])).toStrictEqual({ + tags: ["a"], + note: "keep - this", + }); + }); + }); + + describe("a block scalar", () => { + it("folds > onto one line with single spaces", () => { + expect(frontmatterOf(["d: >", " one", " two", "next: x"])).toStrictEqual({ + d: "one two", + next: "x", + }); + }); + + it("folds >- the same way", () => { + expect(frontmatterOf(["d: >-", " one", " two"])).toStrictEqual({ d: "one two" }); + }); + + it("keeps | line by line", () => { + expect(frontmatterOf(["d: |", " one", " two"])).toStrictEqual({ d: "one\ntwo" }); + }); + + it("keeps |- line by line", () => { + expect(frontmatterOf(["d: |-", " one", " two"])).toStrictEqual({ d: "one\ntwo" }); + }); + + it("drops a blank line closing the block", () => { + expect(frontmatterOf(["d: >", " one", " "])).toStrictEqual({ d: "one" }); + }); + + it("drops a blank line closing a literal block", () => { + expect(frontmatterOf(["d: |", " one", " "])).toStrictEqual({ d: "one" }); + }); + }); + + describe("a scalar", () => { + it("reads ~ as null", () => { + expect(frontmatterOf(["v: ~"])).toStrictEqual({ v: null }); + }); + + it("keeps a number as text", () => { + expect(frontmatterOf(["count: 42"])).toStrictEqual({ count: "42" }); + }); + + it("keeps a bracketed value that is not JSON as text", () => { + expect(frontmatterOf(["tools: [a, b]"])).toStrictEqual({ tools: "[a, b]" }); + }); + + it("unquotes a single-quoted value and undoubles its apostrophes", () => { + expect(frontmatterOf(["name: 'it''s'"])).toStrictEqual({ name: "it's" }); + }); + + it("unquotes a double-quoted value and unescapes its quotes", () => { + expect(frontmatterOf(['name: "say \\"hi\\""'])).toStrictEqual({ name: 'say "hi"' }); + }); + + it("keeps a value that only ends with a quote", () => { + expect(frontmatterOf(["a: abc'", 'b: abc"'])).toStrictEqual({ a: "abc'", b: 'abc"' }); + }); + + it("keeps a value that only starts with a quote", () => { + expect(frontmatterOf(["a: 'abc", 'b: "abc'])).toStrictEqual({ a: "'abc", b: '"abc' }); + }); + + it("keeps a lone quote", () => { + expect(frontmatterOf(["a: '", 'b: "'])).toStrictEqual({ a: "'", b: '"' }); + }); + }); + + it("never reads the body as frontmatter", () => { + const { frontmatter, body } = parseFrontmatter("---\nname: x\n---\nfoo: bar\n"); + expect({ frontmatter, body }).toStrictEqual({ frontmatter: { name: "x" }, body: "foo: bar\n" }); + }); +}); + +describe("serializeFrontmatter, line by line", () => { + it("quotes a value that only starts with a bracket", () => { + expect(serializeFrontmatter({ a: "[open" }, "body")).toBe("---\na: '[open'\n---\nbody"); + }); + + it("quotes a value that only ends with a bracket", () => { + expect(serializeFrontmatter({ a: "closed]" }, "body")).toBe("---\na: 'closed]'\n---\nbody"); + }); + + it("drops only the leading newline of a bare body, never one inside it", () => { + expect(serializeFrontmatter({}, "a\nb")).toBe("a\nb"); + }); +}); diff --git a/cli/tests/kernel/materialization/flat-paths-hooks-prefix.unit.test.ts b/cli/tests/kernel/materialization/flat-paths-hooks-prefix.unit.test.ts new file mode 100644 index 000000000..2df83d66d --- /dev/null +++ b/cli/tests/kernel/materialization/flat-paths-hooks-prefix.unit.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { flatHooksPathWithLoaderEntry } from "../../../src/kernel/materialization/flat-paths.js"; + +describe("flatHooksPathWithLoaderEntry", () => { + it("strips only a leading hooks/ segment, never one deeper in the path", () => { + expect( + flatHooksPathWithLoaderEntry(".opencode/hooks/", null, "aidd-dev", "scripts/hooks/x.js") + ).toBe(".opencode/hooks/aidd-dev/scripts/hooks/x.js"); + }); +}); diff --git a/cli/tests/kernel/merge-content-empty.unit.test.ts b/cli/tests/kernel/merge-content-empty.unit.test.ts new file mode 100644 index 000000000..947f9d3c7 --- /dev/null +++ b/cli/tests/kernel/merge-content-empty.unit.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { isMergeContentEmpty } from "../../src/kernel/merge.js"; + +describe("isMergeContentEmpty", () => { + describe("at the top level", () => { + it("is empty for an object with no keys", () => { + expect(isMergeContentEmpty("{}", null)).toBe(true); + }); + + it("is not empty once any key remains", () => { + expect(isMergeContentEmpty('{"a":1}', null)).toBe(false); + }); + }); + + describe("under a section key", () => { + it("is empty when the section is the only key and holds nothing", () => { + expect(isMergeContentEmpty('{"mcpServers":{}}', "mcpServers")).toBe(true); + }); + + it("is empty when the section is absent and nothing else is there", () => { + expect(isMergeContentEmpty("{}", "mcpServers")).toBe(true); + }); + + it("is not empty when the section still holds an entry", () => { + expect(isMergeContentEmpty('{"mcpServers":{"a":{}}}', "mcpServers")).toBe(false); + }); + + it("is not empty when another key sits beside the section", () => { + expect(isMergeContentEmpty('{"mcpServers":{},"other":1}', "mcpServers")).toBe(false); + }); + }); + + it("is not empty when the content is not JSON", () => { + expect(isMergeContentEmpty("not json", null)).toBe(false); + }); +}); diff --git a/cli/tests/kernel/paths.unit.test.ts b/cli/tests/kernel/paths.unit.test.ts new file mode 100644 index 000000000..af7ec81ca --- /dev/null +++ b/cli/tests/kernel/paths.unit.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + marketplaceCacheDir, + parseBuiltMarketplaceDir, + parseBuiltMarketplaceDirAtAnyRoot, + parseUserBuiltMarketplaceDir, + samePathSegment, +} from "../../src/kernel/paths.js"; + +describe("marketplaceCacheDir()", () => { + it("nests the marketplace under the project's cache", () => { + expect(marketplaceCacheDir("/p", "mkt").replace(/\\/g, "/")).toBe( + "/p/.aidd/cache/marketplaces/mkt" + ); + }); +}); + +describe("parseBuiltMarketplaceDir()", () => { + it("is undefined for a path carrying one segment too many", () => { + expect(parseBuiltMarketplaceDir("/p", "/p/.aidd/cache/built/mkt/claude/extra", "linux")).toBe( + undefined + ); + }); +}); + +describe("parseUserBuiltMarketplaceDir()", () => { + it("tolerates a trailing separator on the path", () => { + expect( + parseUserBuiltMarketplaceDir("/cfg", "/cfg/cache/built/1.0.0/mkt/claude/", "linux") + ).toStrictEqual({ + version: "1.0.0", + marketplaceName: "mkt", + target: "claude", + }); + }); +}); + +describe("parseBuiltMarketplaceDirAtAnyRoot()", () => { + it("reads back a relative project root of a single segment", () => { + expect( + parseBuiltMarketplaceDirAtAnyRoot("p/.aidd/cache/built/mkt/claude", "linux") + ).toStrictEqual({ + projectRoot: "p", + marketplaceName: "mkt", + target: "claude", + }); + }); + + it("is undefined when only part of the marker matches", () => { + expect(parseBuiltMarketplaceDirAtAnyRoot("/p/.aidd/cache/other/mkt/claude", "linux")).toBe( + undefined + ); + }); + + it("matches the marker segment by segment, never as one string", () => { + expect( + parseBuiltMarketplaceDirAtAnyRoot("/p/.aidd/cache/built/mkt/claude", "linux") + ).toStrictEqual({ + projectRoot: "/p", + marketplaceName: "mkt", + target: "claude", + }); + }); +}); + +describe("samePathSegment()", () => { + it("tells two different names apart on win32 too", () => { + expect(samePathSegment("alpha", "beta", "win32")).toBe(false); + }); +}); diff --git a/cli/tests/kernel/reading/aidd-config-dir.unit.test.ts b/cli/tests/kernel/reading/aidd-config-dir.unit.test.ts new file mode 100644 index 000000000..3b6a331d9 --- /dev/null +++ b/cli/tests/kernel/reading/aidd-config-dir.unit.test.ts @@ -0,0 +1,12 @@ +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveAiddConfigDir, resolveHomeDir } from "../../../src/kernel/reading/home-dir.js"; + +describe("resolveAiddConfigDir", () => { + it.skipIf(process.platform === "win32")( + "sits under .config in the home directory on POSIX", + () => { + expect(resolveAiddConfigDir()).toBe(join(resolveHomeDir(), ".config", "aidd")); + } + ); +}); diff --git a/cli/tests/kernel/reading/confined-file-name.unit.test.ts b/cli/tests/kernel/reading/confined-file-name.unit.test.ts new file mode 100644 index 000000000..365e778b1 --- /dev/null +++ b/cli/tests/kernel/reading/confined-file-name.unit.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { isBareFileName } from "../../../src/kernel/reading/confined-file-name.js"; + +describe("isBareFileName", () => { + it("accepts a plain file name", () => { + expect(isBareFileName("manifest.json")).toBe(true); + }); + + it("refuses an empty name", () => { + expect(isBareFileName("")).toBe(false); + }); + + it("refuses the directory itself", () => { + expect(isBareFileName(".")).toBe(false); + }); + + it("refuses the parent directory", () => { + expect(isBareFileName("..")).toBe(false); + }); + + it("refuses a name that walks out of the directory", () => { + expect(isBareFileName("../manifest.json")).toBe(false); + }); + + it("refuses a nested path", () => { + expect(isBareFileName("sub/manifest.json")).toBe(false); + }); + + it("refuses an absolute path", () => { + expect(isBareFileName("/etc/passwd")).toBe(false); + }); +}); diff --git a/cli/tests/kernel/reading/json-file.unit.test.ts b/cli/tests/kernel/reading/json-file.unit.test.ts new file mode 100644 index 000000000..010004ae4 --- /dev/null +++ b/cli/tests/kernel/reading/json-file.unit.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { asPlainObjectOrEmpty, isErrnoException } from "../../../src/kernel/reading/json-file.js"; + +describe("asPlainObjectOrEmpty", () => { + it("passes a plain object through unchanged", () => { + const value = { a: 1 }; + expect(asPlainObjectOrEmpty(value)).toBe(value); + }); + + it("reads null as an empty object", () => { + expect(asPlainObjectOrEmpty(null)).toStrictEqual({}); + }); + + it("reads an array as an empty object", () => { + expect(asPlainObjectOrEmpty([1])).toStrictEqual({}); + }); + + it("reads a primitive as an empty object", () => { + expect(asPlainObjectOrEmpty("text")).toStrictEqual({}); + }); +}); + +describe("isErrnoException", () => { + it("recognises an Error carrying a code", () => { + expect(isErrnoException(Object.assign(new Error("gone"), { code: "ENOENT" }))).toBe(true); + }); + + it("refuses an Error carrying no code", () => { + expect(isErrnoException(new Error("gone"))).toBe(false); + }); + + it("refuses a plain object carrying a code", () => { + expect(isErrnoException({ code: "ENOENT" })).toBe(false); + }); +}); diff --git a/cli/tests/kernel/reading/jsonc.unit.test.ts b/cli/tests/kernel/reading/jsonc.unit.test.ts new file mode 100644 index 000000000..f8b004366 --- /dev/null +++ b/cli/tests/kernel/reading/jsonc.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { stripJsonComments } from "../../../src/kernel/reading/jsonc.js"; + +describe("stripJsonComments", () => { + describe("a line comment", () => { + it("is dropped up to the end of its line, the line ending kept", () => { + expect(stripJsonComments("a //c\nb")).toBe("a \nb"); + }); + + it("is dropped when it closes the document with no line ending", () => { + expect(stripJsonComments("1 // c")).toBe("1 "); + }); + }); + + describe("a block comment", () => { + it("is dropped whole, its inner asterisks included", () => { + expect(stripJsonComments("/* a * b */1")).toBe("1"); + }); + + it("is dropped whole when a slash follows its opening marker", () => { + expect(stripJsonComments("/*/ x */1")).toBe("1"); + }); + + it("is dropped to the end of the document when never closed", () => { + expect(stripJsonComments("1 /* x")).toBe("1 "); + }); + }); + + describe("a lone slash", () => { + it("is not a comment and stays", () => { + expect(stripJsonComments("1/2")).toBe("1/2"); + }); + }); + + describe("inside a string", () => { + it("an escaped quote does not end the string, so a comment marker after it stays", () => { + expect(stripJsonComments('{"a":"x\\"y // kept"}')).toBe('{"a":"x\\"y // kept"}'); + }); + + it("a backslash closing the document is kept as it is", () => { + expect(stripJsonComments('"x\\')).toBe('"x\\'); + }); + }); + + describe("a trailing comma", () => { + it("is dropped before a closing brace", () => { + expect(stripJsonComments('{"a":1,\n}')).toBe('{"a":1\n}'); + }); + + it("is dropped before a closing bracket", () => { + expect(stripJsonComments("[1,2,]")).toBe("[1,2]"); + }); + + it("is kept when a value follows", () => { + expect(stripJsonComments("[1, 2]")).toBe("[1, 2]"); + }); + }); +}); diff --git a/cli/tests/kernel/semver-precedence.unit.test.ts b/cli/tests/kernel/semver-precedence.unit.test.ts new file mode 100644 index 000000000..bad58cd61 --- /dev/null +++ b/cli/tests/kernel/semver-precedence.unit.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { compareSemver, isSemver } from "../../src/kernel/semver.js"; + +describe("isSemver(), component width", () => { + it("accepts a multi-digit major", () => { + expect(isSemver("10.0.0")).toBe(true); + }); + + it("accepts a multi-digit patch", () => { + expect(isSemver("1.0.10")).toBe(true); + }); +}); + +describe("compareSemver(), release components", () => { + it("orders by patch when major and minor agree", () => { + expect([compareSemver("1.0.1", "1.0.2"), compareSemver("1.0.2", "1.0.1")]).toStrictEqual([ + -1, 1, + ]); + }); + + it("reads an unparseable version as 0.0.0", () => { + expect(compareSemver("garbage", "0.0.0")).toBe(0); + }); +}); + +describe("compareSemver(), pre-release identifiers", () => { + it("is 0 for two identical pre-release versions", () => { + expect(compareSemver("1.0.0-rc.1", "1.0.0-rc.1")).toBe(0); + }); + + it("orders numeric identifiers numerically in both directions", () => { + expect(compareSemver("1.0.0-rc.10", "1.0.0-rc.2")).toBe(1); + }); + + it("orders lexical identifiers in both directions", () => { + expect(compareSemver("1.0.0-beta", "1.0.0-alpha")).toBe(1); + }); + + it("orders a numeric identifier below a non-numeric one", () => { + expect([ + compareSemver("1.0.0-1", "1.0.0-alpha"), + compareSemver("1.0.0-alpha", "1.0.0-1"), + ]).toStrictEqual([-1, 1]); + }); + + it("orders a numeric identifier below a non-numeric one that sorts first as text", () => { + expect(compareSemver("1.0.0--x", "1.0.0-1")).toBe(1); + }); + + it("reads an identifier as numeric only when it is digits throughout", () => { + expect([ + compareSemver("1.0.0-alpha1", "1.0.0-alpha2"), + compareSemver("1.0.0-1a", "1.0.0-1b"), + ]).toStrictEqual([-1, -1]); + }); + + it("compares multi-digit identifiers by value, not by their first digit", () => { + expect(compareSemver("1.0.0-rc.20", "1.0.0-rc.100")).toBe(-1); + }); + + it("orders the shorter list below on a shared prefix", () => { + expect([ + compareSemver("1.0.0-rc", "1.0.0-rc.1"), + compareSemver("1.0.0-rc.1", "1.0.0-rc"), + ]).toStrictEqual([-1, 1]); + }); +}); diff --git a/cli/tests/kernel/source-messages.unit.test.ts b/cli/tests/kernel/source-messages.unit.test.ts new file mode 100644 index 000000000..68c43b575 --- /dev/null +++ b/cli/tests/kernel/source-messages.unit.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { InvalidPluginSourceError } from "../../src/kernel/errors.js"; +import { + parsePluginSource, + parsePluginSourceShorthand, + serializePluginSource, +} from "../../src/kernel/source.js"; + +describe("what a refused source is told", () => { + it.each([ + [{ kind: "github", repo: "not-a-repo" }, '"repo" must match owner/repo format.'], + [{ kind: "url" }, '"url" must be a non-empty string.'], + [{ kind: "git-subdir", path: "p" }, '"url" must be a non-empty string.'], + [{ kind: "git-subdir", url: "u" }, '"path" must be a non-empty string.'], + [{ kind: "npm" }, '"package" must be a non-empty string.'], + [ + { kind: "npm", package: "My-Plugin" }, + '"package" must be a valid npm package name (e.g. my-plugin or @scope/my-plugin). Got: "My-Plugin"', + ], + [ + { kind: "npm", package: "my-plugin!" }, + '"package" must be a valid npm package name (e.g. my-plugin or @scope/my-plugin). Got: "my-plugin!"', + ], + [ + { kind: "github", repo: "owner/repo", sha: "a".repeat(41) }, + '"sha" must be a 40-character lowercase hex string.', + ], + [42, "expected an object."], + ["github:owner/repo", 'string source "github:owner/repo" is not a recognized path or repo.'], + ])("refuses %j saying: %s", (raw, detail) => { + expect(() => parsePluginSource(raw)).toThrow(new InvalidPluginSourceError(detail)); + }); +}); + +describe("what a parsed source carries, field by field", () => { + it("records no ref on a gitlab shorthand given none", () => { + expect(parsePluginSourceShorthand("gitlab:my-org/my-plugin")).toStrictEqual({ + kind: "url", + url: "https://gitlab.com/my-org/my-plugin.git", + }); + }); + + it("serializes a minimal url source with no absent field", () => { + expect(serializePluginSource({ kind: "url", url: "https://x/p.git" })).toStrictEqual({ + kind: "url", + url: "https://x/p.git", + }); + }); + + it("serializes a minimal git-subdir source with no absent field", () => { + expect( + serializePluginSource({ kind: "git-subdir", url: "https://x/r.git", path: "pkg" }) + ).toStrictEqual({ kind: "git-subdir", url: "https://x/r.git", path: "pkg" }); + }); + + it("serializes a minimal npm source with no absent field", () => { + expect(serializePluginSource({ kind: "npm", package: "pkg" })).toStrictEqual({ + kind: "npm", + package: "pkg", + }); + }); +}); From 0ef2da804cbdb827d9f5ab3e877eae5a98060c8e Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 10 Sep 2026 10:32:01 +0200 Subject: [PATCH 2/4] test(cli): kill the surviving mutants of the runtime GitAdapter gets its first test, against a real temporary repository: hook install and removal under this CLI and under lefthook or husky, core.hooksPath, the trailer count and every answer of readCommitTrailerSetup. The auth adapters, the self-updater, the file adapter, the prompter, the HTTP client, the update check, the release resolver, the project-root and git-environment helpers and the composition root (createDeps, wireTools, the build registry) are asserted on their exact commands, messages, headers and shapes. The removal test found a defect rather than a mutant: prepare-commit-msg lost its executable bit. The fix ships on its own in #818, which this branch sits on. Runtime scope, every mutant replayed: 69.8 to 92.2 (killed 1182, timeout 16, survived 88, uncovered 13 of 1299). Floor raised from 67 to 90. Two POSIX-only checks skip where the platform cannot show them: a mode bit on Windows, and a read-only directory on Windows or as root. Refs #799 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- cli/mutation-scopes.json | 2 +- .../assets/asset-loader-schemas.unit.test.ts | 21 ++ .../auth/auth-provider-adapter.unit.test.ts | 2 +- .../auth/auth-reader-branches.unit.test.ts | 122 ++++++ .../auth-storage-shape.integration.test.ts | 123 ++++++ .../auth/auth-storage.integration.test.ts | 2 +- .../auth/gh-cli-verify.integration.test.ts | 99 +++++ .../auth/gh-token-adapter.unit.test.ts | 38 ++ .../atomic-write.integration.test.ts | 40 ++ .../file-adapter-edges.integration.test.ts | 153 ++++++++ .../git/git-adapter.integration.test.ts | 351 +++++++++++++++++- .../runtime/git/git-environment.unit.test.ts | 20 + .../git/inject-token-hosts.unit.test.ts | 24 ++ .../http/http-client-wire.integration.test.ts | 103 +++++ .../project-root/project-root.unit.test.ts | 40 ++ ...pter-adapter-rendering.integration.test.ts | 152 ++++++++ .../check-update-notice.unit.test.ts | 69 ++++ ...github-release-resolver-shape.unit.test.ts | 58 +++ ...-updater-adapter-edges.integration.test.ts | 177 +++++++++ .../wiring/create-deps.integration.test.ts | 61 +++ .../framework-build-modes.integration.test.ts | 69 ++++ ...stalled-plugins-from-manifest.unit.test.ts | 48 +++ .../runtime/wiring/wire-tools.unit.test.ts | 16 + 23 files changed, 1786 insertions(+), 4 deletions(-) create mode 100644 cli/tests/runtime/assets/asset-loader-schemas.unit.test.ts create mode 100644 cli/tests/runtime/auth/auth-reader-branches.unit.test.ts create mode 100644 cli/tests/runtime/auth/auth-storage-shape.integration.test.ts create mode 100644 cli/tests/runtime/auth/gh-cli-verify.integration.test.ts create mode 100644 cli/tests/runtime/auth/gh-token-adapter.unit.test.ts create mode 100644 cli/tests/runtime/filesystem/atomic-write.integration.test.ts create mode 100644 cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts create mode 100644 cli/tests/runtime/git/git-environment.unit.test.ts create mode 100644 cli/tests/runtime/git/inject-token-hosts.unit.test.ts create mode 100644 cli/tests/runtime/http/http-client-wire.integration.test.ts create mode 100644 cli/tests/runtime/project-root/project-root.unit.test.ts create mode 100644 cli/tests/runtime/prompter/prompter-adapter-rendering.integration.test.ts create mode 100644 cli/tests/runtime/self-update/check-update-notice.unit.test.ts create mode 100644 cli/tests/runtime/self-update/github-release-resolver-shape.unit.test.ts create mode 100644 cli/tests/runtime/self-update/self-updater-adapter-edges.integration.test.ts create mode 100644 cli/tests/runtime/wiring/create-deps.integration.test.ts create mode 100644 cli/tests/runtime/wiring/framework-build-modes.integration.test.ts create mode 100644 cli/tests/runtime/wiring/installed-plugins-from-manifest.unit.test.ts create mode 100644 cli/tests/runtime/wiring/wire-tools.unit.test.ts diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index f01adb371..499a0ae20 100644 --- a/cli/mutation-scopes.json +++ b/cli/mutation-scopes.json @@ -38,7 +38,7 @@ }, "runtime": { "mutate": "src/runtime/**/*.ts", - "break": 67 + "break": 90 }, "tools-claude": { "mutate": "src/contexts/tools/domain/profiles/claude/**/*.ts", diff --git a/cli/tests/runtime/assets/asset-loader-schemas.unit.test.ts b/cli/tests/runtime/assets/asset-loader-schemas.unit.test.ts new file mode 100644 index 000000000..ef1a1af2d --- /dev/null +++ b/cli/tests/runtime/assets/asset-loader-schemas.unit.test.ts @@ -0,0 +1,21 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { BundledAssetProviderAdapter } from "../../../src/runtime/assets/asset-loader.js"; +import { REPOSITORY_ROOT } from "../../helpers/repository-root.js"; + +const SCHEMAS = join(REPOSITORY_ROOT, "cli", "assets", "schemas"); + +describe("BundledAssetProviderAdapter.loadSchema", () => { + it.each([ + ["plugin-manifest", "claude-code-plugin-manifest.json"], + ["marketplace", "copilot-plugin-marketplace.json"], + ["claude-marketplace", "claude-marketplace-manifest.json"], + ["codex-marketplace", "codex-marketplace-manifest.json"], + ["codex-plugin-manifest", "codex-plugin-manifest.json"], + ] as const)("reads %s from the bundled schema file %s", (name, file) => { + expect(new BundledAssetProviderAdapter().loadSchema(name)).toStrictEqual( + JSON.parse(readFileSync(join(SCHEMAS, file), "utf8")) + ); + }); +}); diff --git a/cli/tests/runtime/auth/auth-provider-adapter.unit.test.ts b/cli/tests/runtime/auth/auth-provider-adapter.unit.test.ts index 3b25c372d..bbad28d1b 100644 --- a/cli/tests/runtime/auth/auth-provider-adapter.unit.test.ts +++ b/cli/tests/runtime/auth/auth-provider-adapter.unit.test.ts @@ -146,7 +146,7 @@ describe("what `auth status` reports", () => { PROJECT_ROOT ); - await expect(adapter.status()).rejects.toThrow(AuthenticationError); + await expect(adapter.status()).rejects.toThrow(new AuthenticationError("invalid config")); }); }); diff --git a/cli/tests/runtime/auth/auth-reader-branches.unit.test.ts b/cli/tests/runtime/auth/auth-reader-branches.unit.test.ts new file mode 100644 index 000000000..2d9a74f45 --- /dev/null +++ b/cli/tests/runtime/auth/auth-reader-branches.unit.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import type { AuthConfig } from "../../../src/runtime/auth/auth.js"; +import { AuthReaderAdapter } from "../../../src/runtime/auth/auth-reader-adapter.js"; +import type { AuthStorage } from "../../../src/runtime/auth/auth-storage.js"; +import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; + +const PROJECT = "/project/.aidd/auth.json"; +const USER = "/home/user/.config/aidd/auth.json"; + +function storage(project: AuthConfig | null, user: AuthConfig | null): AuthStorage { + return { + userConfigPath: () => USER, + projectConfigPath: () => PROJECT, + read: async (path: string) => (path === PROJECT ? project : path === USER ? user : null), + write: async () => {}, + delete: async () => {}, + save: async () => {}, + readActive: async () => null, + } as AuthStorage; +} + +function stored(token: string | undefined): AuthConfig { + return { version: 1, method: "stored", level: "project", token, createdAt: "2026-01-01" }; +} + +function external(token?: string): AuthConfig { + return { version: 1, method: "external", level: "project", token, createdAt: "2026-01-01" }; +} + +function withoutEnvToken(run: () => Promise): Promise { + const saved = process.env.AIDD_TOKEN; + delete process.env.AIDD_TOKEN; + return run().finally(() => { + if (saved !== undefined) process.env.AIDD_TOKEN = saved; + }); +} + +describe("AuthReaderAdapter, which record answers", () => { + it("says where the token came from, and never the token", async () => { + const logger = new CapturingLogger(); + const reader = new AuthReaderAdapter(storage(stored("ghp_p"), null), "/project", logger); + + await withoutEnvToken(() => reader.resolve()); + + expect(logger.debugMessages).toStrictEqual(["Token resolved from project auth.json (stored)"]); + }); + + it("names the user record when that one answered", async () => { + const logger = new CapturingLogger(); + const reader = new AuthReaderAdapter(storage(null, stored("ghp_u")), "/project", logger); + + await withoutEnvToken(() => reader.resolve()); + + expect(logger.debugMessages).toStrictEqual(["Token resolved from user auth.json (stored)"]); + }); + + it("names the external provider when it answered", async () => { + const logger = new CapturingLogger(); + const reader = new AuthReaderAdapter(storage(external(), null), "/project", logger, { + resolve: () => "gh_tok", + }); + + expect(await withoutEnvToken(() => reader.resolve())).toBe("gh_tok"); + expect(logger.debugMessages).toStrictEqual([ + "Token resolved from project auth.json (external)", + ]); + }); + + it("says so when nothing answered", async () => { + const logger = new CapturingLogger(); + const reader = new AuthReaderAdapter(storage(null, null), "/project", logger); + + expect(await withoutEnvToken(() => reader.resolve())).toBeNull(); + expect(logger.debugMessages).toStrictEqual(["No token available"]); + }); + + it("names the command line when the token came from it", async () => { + const logger = new CapturingLogger(); + const reader = new AuthReaderAdapter(storage(null, null), "/project", logger, undefined, "cli"); + + expect(await reader.resolve()).toBe("cli"); + expect(logger.debugMessages).toStrictEqual(["Token given on the command line"]); + }); + + it("falls past a stored record carrying no token", async () => { + const reader = new AuthReaderAdapter(storage(stored(undefined), stored("ghp_u")), "/project"); + + expect(await withoutEnvToken(() => reader.resolve())).toBe("ghp_u"); + }); + + it("never asks the external provider for a stored record", async () => { + const reader = new AuthReaderAdapter(storage(stored(undefined), null), "/project", undefined, { + resolve: () => "gh_tok", + }); + + expect(await withoutEnvToken(() => reader.resolve())).toBeNull(); + }); + + it("asks the provider for an external record even when a stray token sits in it", async () => { + const reader = new AuthReaderAdapter(storage(external("stale"), null), "/project", undefined, { + resolve: () => "gh_tok", + }); + + expect(await withoutEnvToken(() => reader.resolve())).toBe("gh_tok"); + }); + + it("falls past an external record when the provider has nothing", async () => { + const logger = new CapturingLogger(); + const reader = new AuthReaderAdapter(storage(external(), stored("ghp_u")), "/project", logger, { + resolve: () => null, + }); + + expect(await withoutEnvToken(() => reader.resolve())).toBe("ghp_u"); + expect(logger.debugMessages).toStrictEqual(["Token resolved from user auth.json (stored)"]); + }); + + it("answers nothing for an external record when no provider was wired", async () => { + const reader = new AuthReaderAdapter(storage(external(), null), "/project"); + + expect(await withoutEnvToken(() => reader.resolve())).toBeNull(); + }); +}); diff --git a/cli/tests/runtime/auth/auth-storage-shape.integration.test.ts b/cli/tests/runtime/auth/auth-storage-shape.integration.test.ts new file mode 100644 index 000000000..80bcc0dc1 --- /dev/null +++ b/cli/tests/runtime/auth/auth-storage-shape.integration.test.ts @@ -0,0 +1,123 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { AuthStorageError } from "../../../src/kernel/errors.js"; +import { AuthStorage } from "../../../src/runtime/auth/auth-storage.js"; +import { makeAuthConfig } from "../../helpers/auth.js"; + +describe("AuthStorage, the shape of a record", () => { + let tempDir: string; + let path: string; + let storage: AuthStorage; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "aidd-auth-shape-")); + path = join(tempDir, "auth.json"); + storage = new AuthStorage(); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it.each([ + ["null", "null"], + ["a string", '"ghp_x"'], + ["a number", "1"], + ["an array", "[]"], + ["another version", JSON.stringify({ ...makeAuthConfig(), version: 2 })], + ["an unknown method", JSON.stringify({ ...makeAuthConfig(), method: "gh" })], + ["an unknown level", JSON.stringify({ ...makeAuthConfig(), level: "global" })], + ["no createdAt", JSON.stringify({ ...makeAuthConfig(), createdAt: undefined })], + ])("reads %s as no record at all", async (_shape, content) => { + await writeFile(path, content); + + expect(await storage.read(path)).toBeNull(); + }); + + it("reads back exactly what it wrote, pretty-printed", async () => { + const config = makeAuthConfig(); + + await storage.write(path, config); + + expect(await readFile(path, "utf-8")).toBe(JSON.stringify(config, null, 2)); + }); + + it("records an external credential with its provider and no token", async () => { + const saved = Object.create(storage) as AuthStorage; + saved.userConfigPath = () => path; + + await saved.save({ + credential: { method: "external", provider: "gh" }, + level: "user", + projectRoot: tempDir, + }); + + const written = JSON.parse(await readFile(path, "utf-8")) as Record; + expect(written).toStrictEqual({ + version: 1, + method: "external", + level: "user", + createdAt: written.createdAt, + provider: "gh", + }); + }); + + it("records a stored credential with its token and no provider", async () => { + const saved = Object.create(storage) as AuthStorage; + saved.userConfigPath = () => path; + + await saved.save({ + credential: { method: "stored", token: "ghp_x" }, + level: "user", + projectRoot: tempDir, + }); + + const written = JSON.parse(await readFile(path, "utf-8")) as Record; + expect(written).toStrictEqual({ + version: 1, + method: "stored", + level: "user", + createdAt: written.createdAt, + token: "ghp_x", + }); + }); + + it("synthesises a stored user-level record from AIDD_TOKEN", async () => { + const saved = process.env.AIDD_TOKEN; + process.env.AIDD_TOKEN = "ghp_env"; + try { + const active = await storage.readActive(tempDir); + expect(active).toStrictEqual({ + version: 1, + method: "stored", + level: "user", + token: "ghp_env", + createdAt: active?.createdAt, + }); + } finally { + if (saved === undefined) delete process.env.AIDD_TOKEN; + else process.env.AIDD_TOKEN = saved; + } + }); + + it("refuses to grant an empty Windows account", async () => { + const platform = Object.getOwnPropertyDescriptor(process, "platform"); + const username = process.env.USERNAME; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + process.env.USERNAME = ""; + try { + await expect(storage.write(path, makeAuthConfig())).rejects.toThrow( + new AuthStorageError( + "Failed to set restrictive permissions on " + + `${path}: USERNAME is not set, so no account can be granted access` + ) + ); + } finally { + if (platform !== undefined) Object.defineProperty(process, "platform", platform); + if (username === undefined) delete process.env.USERNAME; + else process.env.USERNAME = username; + } + }); +}); diff --git a/cli/tests/runtime/auth/auth-storage.integration.test.ts b/cli/tests/runtime/auth/auth-storage.integration.test.ts index 0f6b06aa3..e7a5e025a 100644 --- a/cli/tests/runtime/auth/auth-storage.integration.test.ts +++ b/cli/tests/runtime/auth/auth-storage.integration.test.ts @@ -98,7 +98,7 @@ describe("AuthStorage", () => { expect(execFileSync).toHaveBeenCalledTimes(1); const [command, args] = vi.mocked(execFileSync).mock.calls[0] ?? []; expect(command).toBe("icacls"); - expect(args).toEqual([path, "/inheritance:r", "/grant:r", expect.stringContaining(":(R,W)")]); + expect(args).toStrictEqual([path, "/inheritance:r", "/grant:r", "tester:(R,W)"]); }); it("names the account from the environment, not the %USERNAME% only a shell would expand", async () => { diff --git a/cli/tests/runtime/auth/gh-cli-verify.integration.test.ts b/cli/tests/runtime/auth/gh-cli-verify.integration.test.ts new file mode 100644 index 000000000..9dd61f328 --- /dev/null +++ b/cli/tests/runtime/auth/gh-cli-verify.integration.test.ts @@ -0,0 +1,99 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; +import { AuthenticationError } from "../../../src/kernel/errors.js"; +import { GhCliAdapter } from "../../../src/runtime/auth/gh-cli-adapter.js"; + +vi.mock("node:child_process", () => ({ + spawnSync: vi.fn(), +})); + +const mockSpawnSync = vi.mocked(spawnSync); + +function answer(overrides: Partial>): ReturnType { + return { + pid: 1, + output: [], + stdout: "", + stderr: "", + status: 0, + signal: null, + error: undefined, + ...overrides, + } as ReturnType; +} + +describe("GhCliAdapter, the commands it runs", () => { + it("reads the token through `gh auth token`, with a bounded wait", () => { + mockSpawnSync.mockReturnValue(answer({ stdout: "ghp_abc\n" })); + + new GhCliAdapter().resolve(); + + expect(mockSpawnSync.mock.calls.at(-1)).toStrictEqual([ + "gh", + ["auth", "token"], + { timeout: 3000, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }, + ]); + }); + + it("names the login through `gh api user`, with a bounded wait", async () => { + mockSpawnSync.mockReturnValue(answer({ stdout: "octocat\n" })); + + await new GhCliAdapter().verify(); + + expect(mockSpawnSync.mock.calls.at(-1)).toStrictEqual([ + "gh", + ["api", "user", "--jq", ".login"], + { timeout: 5000, encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }, + ]); + }); +}); + +describe("GhCliAdapter.verify", () => { + it("answers the login gh prints, trimmed", async () => { + mockSpawnSync.mockReturnValue(answer({ stdout: " octocat\n" })); + + expect(await new GhCliAdapter().verify()).toBe("octocat"); + }); + + it("refuses when gh is not installed", async () => { + mockSpawnSync.mockReturnValue( + answer({ error: new Error("ENOENT"), status: null, stdout: "octocat\n" }) + ); + + await expect(new GhCliAdapter().verify()).rejects.toThrow(new AuthenticationError("gh CLI")); + }); + + it("refuses when gh exits non-zero", async () => { + mockSpawnSync.mockReturnValue( + answer({ status: 1, stderr: "not logged in", stdout: "octocat\n" }) + ); + + await expect(new GhCliAdapter().verify()).rejects.toThrow(new AuthenticationError("gh CLI")); + }); + + it("refuses an empty login", async () => { + mockSpawnSync.mockReturnValue(answer({ stdout: " \n" })); + + await expect(new GhCliAdapter().verify()).rejects.toThrow(new AuthenticationError("gh CLI")); + }); +}); + +describe("GhCliAdapter.resolve, reading stderr", () => { + it("copes with gh answering no stderr stream at all", () => { + mockSpawnSync.mockReturnValue(answer({ status: 2, stderr: undefined })); + + expect(() => new GhCliAdapter().resolve()).toThrow("gh auth token exited with code 2"); + }); + + it("says the exit code is unknown when gh left none", () => { + mockSpawnSync.mockReturnValue(answer({ status: null, stderr: "" })); + + expect(() => new GhCliAdapter().resolve()).toThrow("gh auth token exited with code unknown"); + }); + + it("trims the stderr it reports", () => { + mockSpawnSync.mockReturnValue(answer({ status: 1, stderr: " boom \n" })); + + expect(() => new GhCliAdapter().resolve()).toThrow("gh auth token failed: boom"); + }); +}); diff --git a/cli/tests/runtime/auth/gh-token-adapter.unit.test.ts b/cli/tests/runtime/auth/gh-token-adapter.unit.test.ts new file mode 100644 index 000000000..145a306cf --- /dev/null +++ b/cli/tests/runtime/auth/gh-token-adapter.unit.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { AuthenticationError } from "../../../src/kernel/errors.js"; +import { GhTokenAdapter } from "../../../src/runtime/auth/gh-token-adapter.js"; +import type { + HttpGet, + HttpGetOptions, + HttpResponse, +} from "../../../src/runtime/http/http-client.js"; + +function http(body: unknown): HttpGet & { calls: { url: string; options?: HttpGetOptions }[] } { + const calls: { url: string; options?: HttpGetOptions }[] = []; + return { + calls, + get: async (url, options): Promise => { + calls.push({ url, options }); + return { body, statusCode: 200, contentType: "application/json" }; + }, + }; +} + +describe("GhTokenAdapter", () => { + it("asks GitHub who the token belongs to, with the token", async () => { + const client = http({ login: "octocat" }); + + const login = await new GhTokenAdapter(client).verifyToken("ghp_x"); + + expect({ login, calls: client.calls }).toStrictEqual({ + login: "octocat", + calls: [{ url: "https://api.github.com/user", options: { token: "ghp_x" } }], + }); + }); + + it("refuses an answer carrying no login", async () => { + await expect(new GhTokenAdapter(http({ id: 1 })).verifyToken("ghp_x")).rejects.toThrow( + new AuthenticationError("GitHub API") + ); + }); +}); diff --git a/cli/tests/runtime/filesystem/atomic-write.integration.test.ts b/cli/tests/runtime/filesystem/atomic-write.integration.test.ts new file mode 100644 index 000000000..0a053b985 --- /dev/null +++ b/cli/tests/runtime/filesystem/atomic-write.integration.test.ts @@ -0,0 +1,40 @@ +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { atomicWriteFile } from "../../../src/runtime/filesystem/atomic-write.js"; +import { HasherAdapter } from "../../../src/runtime/filesystem/hasher-adapter.js"; +import { PlatformAdapter } from "../../../src/runtime/platform/platform-adapter.js"; + +describe("atomicWriteFile", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "aidd-atomic-write-")); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("leaves exactly the file, holding the content as UTF-8", async () => { + const path = join(tempDir, "out.txt"); + + await atomicWriteFile(path, "héllo"); + + expect(await readdir(tempDir)).toStrictEqual(["out.txt"]); + expect(await readFile(path, "utf-8")).toBe("héllo"); + }); +}); + +describe("HasherAdapter", () => { + it("hashes the UTF-8 bytes of the content", () => { + expect(new HasherAdapter().hash("héllo").value).toBe("be50e8478cf24ff3595bc7307fb91b50"); + }); +}); + +describe("PlatformAdapter", () => { + it("answers the platform this process runs on", () => { + expect(new PlatformAdapter().current()).toBe(process.platform); + }); +}); diff --git a/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts b/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts new file mode 100644 index 000000000..1b540e71a --- /dev/null +++ b/cli/tests/runtime/filesystem/file-adapter-edges.integration.test.ts @@ -0,0 +1,153 @@ +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { JsonParseError } from "../../../src/kernel/errors.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"; + +describe("FileAdapter, at the edges", () => { + let tempDir: string; + let logger: CapturingLogger; + let fs: FileAdapter; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "aidd-fs-edges-")); + logger = new CapturingLogger(); + fs = new FileAdapter(new HasherAdapter(), logger); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + describe("listFilesRecursive()", () => { + it("lists every file below the directory by absolute path", async () => { + await mkdir(join(tempDir, "a", "b"), { recursive: true }); + await writeFile(join(tempDir, "a", "one.txt"), ""); + await writeFile(join(tempDir, "a", "b", "two.txt"), ""); + + expect((await fs.listFilesRecursive(join(tempDir, "a"))).sort()).toStrictEqual([ + join(tempDir, "a", "b", "two.txt"), + join(tempDir, "a", "one.txt"), + ]); + }); + + it("answers nothing for a directory that is not there", async () => { + expect(await fs.listFilesRecursive(join(tempDir, "gone"))).toStrictEqual([]); + }); + + it("skips a symlink and says which one", async () => { + await writeFile(join(tempDir, "real.txt"), ""); + await symlink(join(tempDir, "real.txt"), join(tempDir, "link.txt")); + + expect(await fs.listFilesRecursive(tempDir)).toStrictEqual([join(tempDir, "real.txt")]); + expect(logger.warnMessages).toStrictEqual([`Skipping symlink: ${join(tempDir, "link.txt")}`]); + }); + }); + + describe("listFilesRecursive(), with no logger", () => { + it("still skips the symlink, silently", async () => { + await writeFile(join(tempDir, "real.txt"), ""); + await symlink(join(tempDir, "real.txt"), join(tempDir, "link.txt")); + + expect(await new FileAdapter(new HasherAdapter()).listFilesRecursive(tempDir)).toStrictEqual([ + join(tempDir, "real.txt"), + ]); + }); + }); + + describe("listDirectory()", () => { + it("says which symlink it skipped", async () => { + await writeFile(join(tempDir, "real.txt"), ""); + await symlink(join(tempDir, "real.txt"), join(tempDir, "link.txt")); + + await fs.listDirectory(tempDir); + + expect(logger.warnMessages).toStrictEqual([`Skipping symlink: ${join(tempDir, "link.txt")}`]); + }); + }); + + describe("chmodExecutable()", () => { + it("makes the file executable", async () => { + const path = join(tempDir, "run.sh"); + await writeFile(path, "", { mode: 0o644 }); + + await fs.chmodExecutable(path); + + expect(await fs.isExecutable(path)).toBe(true); + }); + }); + + describe("deleteEmptyDirectories()", () => { + it("copes with a directory that is not there", async () => { + await expect(fs.deleteEmptyDirectories(join(tempDir, "gone"))).resolves.toBeUndefined(); + }); + + // A read-only directory blocks removal on POSIX alone, and never for root. + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "stops at a directory it cannot remove", + async () => { + const locked = join(tempDir, "locked"); + const inner = join(locked, "inner"); + await mkdir(inner, { recursive: true }); + await chmod(locked, 0o555); + try { + await fs.deleteEmptyDirectories(inner); + expect(await fs.fileExists(inner)).toBe(true); + } finally { + await chmod(locked, 0o755); + } + } + ); + }); + + describe("mergeJsonFile()", () => { + it("refuses to merge into a file it cannot parse, naming the file", async () => { + const path = join(tempDir, "settings.json"); + await writeFile(path, "{ not json"); + + await expect(fs.mergeJsonFile(path, "{}", "framework-prime")).rejects.toThrow(JsonParseError); + await expect(fs.mergeJsonFile(path, "{}", "framework-prime")).rejects.toThrow( + `Cannot parse existing JSON at ${path}: ` + ); + }); + + it("drops a prototype key from the incoming content", async () => { + const path = join(tempDir, "settings.json"); + await writeFile(path, JSON.stringify({ keep: 1 })); + + await fs.mergeJsonFile(path, '{"prototype": {"polluted": true}, "b": 2}', "framework-prime"); + + expect(JSON.parse(await readFile(path, "utf-8"))).toStrictEqual({ keep: 1, b: 2 }); + }); + + it("replaces a scalar with an incoming array outright", async () => { + const path = join(tempDir, "settings.json"); + await writeFile(path, JSON.stringify({ list: "one" })); + + await fs.mergeJsonFile(path, '{"list": ["a"]}', "framework-prime"); + + expect(JSON.parse(await readFile(path, "utf-8"))).toStrictEqual({ list: ["a"] }); + }); + + it("replaces a scalar with an incoming object outright", async () => { + const path = join(tempDir, "settings.json"); + await writeFile(path, JSON.stringify({ nested: "one" })); + + await fs.mergeJsonFile(path, '{"nested": {"a": 1}}', "framework-prime"); + + expect(JSON.parse(await readFile(path, "utf-8"))).toStrictEqual({ nested: { a: 1 } }); + }); + + it("replaces an object with an incoming null outright", async () => { + const path = join(tempDir, "settings.json"); + await writeFile(path, JSON.stringify({ nested: { a: 1 } })); + + await fs.mergeJsonFile(path, '{"nested": null}', "framework-prime"); + + expect(JSON.parse(await readFile(path, "utf-8"))).toStrictEqual({ nested: null }); + }); + }); +}); diff --git a/cli/tests/runtime/git/git-adapter.integration.test.ts b/cli/tests/runtime/git/git-adapter.integration.test.ts index b71554bf3..76d34c62f 100644 --- a/cli/tests/runtime/git/git-adapter.integration.test.ts +++ b/cli/tests/runtime/git/git-adapter.integration.test.ts @@ -3,7 +3,11 @@ import { chmod, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:f import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { sessionTrailerHookLine } from "../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; +import { + SESSION_TRAILER_HOOK_HEADER, + SESSION_TRAILER_TOKEN, + sessionTrailerHookLine, +} from "../../../src/contexts/telemetry/domain/formats/commit-session-trailer.js"; import { FileAdapter } from "../../../src/runtime/filesystem/file-adapter.js"; import { HasherAdapter } from "../../../src/runtime/filesystem/hasher-adapter.js"; import { GitAdapter } from "../../../src/runtime/git/git-adapter.js"; @@ -22,13 +26,21 @@ function git(cwd: string, ...args: string[]): string { return result.stdout.trim(); } +async function commit(root: string, message: string): Promise { + await writeFile(join(root, `${Math.random()}.txt`), "x"); + git(root, "add", "."); + git(root, "commit", "-q", "-m", message); +} + describe("GitAdapter", () => { let root: string; + let outside: string; let hooksDir: string; let adapter: GitAdapter; beforeEach(async () => { root = await realpath(await mkdtemp(join(tmpdir(), "aidd-git-adapter-"))); + outside = await realpath(await mkdtemp(join(tmpdir(), "aidd-git-adapter-outside-"))); git(root, "init", "-q"); hooksDir = join(root, ".git", "hooks"); adapter = new GitAdapter(new FileAdapter(new HasherAdapter())); @@ -36,6 +48,190 @@ describe("GitAdapter", () => { afterEach(async () => { await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + }); + + describe("isRepository", () => { + it("answers true inside a repository", async () => { + expect(await adapter.isRepository(root)).toBe(true); + }); + + it("answers false outside one", async () => { + expect(await adapter.isRepository(outside)).toBe(false); + }); + }); + + describe("listTrackedFiles", () => { + it("lists the tracked paths matching the pathspec, relative to the root", async () => { + await mkdir(join(root, "aidd_docs", "runs"), { recursive: true }); + await writeFile(join(root, "aidd_docs", "runs", "a.jsonl"), ""); + await writeFile(join(root, "other.txt"), ""); + git(root, "add", "."); + + expect(await adapter.listTrackedFiles(root, "aidd_docs/runs/")).toStrictEqual([ + "aidd_docs/runs/a.jsonl", + ]); + }); + + it("answers nothing outside a repository", async () => { + expect(await adapter.listTrackedFiles(outside, "aidd_docs/runs/")).toStrictEqual([]); + }); + }); + + describe("hasHistoryFor", () => { + it("is false while the pathspec is only staged", async () => { + await writeFile(join(root, "tracked.txt"), ""); + git(root, "add", "."); + + expect(await adapter.hasHistoryFor(root, "tracked.txt")).toBe(false); + }); + + it("is false for a pathspec staged after other commits", async () => { + await commit(root, "one"); + await writeFile(join(root, "tracked.txt"), ""); + git(root, "add", "."); + + expect(await adapter.hasHistoryFor(root, "tracked.txt")).toBe(false); + }); + + it("is true once a commit touched the pathspec", async () => { + await writeFile(join(root, "tracked.txt"), ""); + git(root, "add", "."); + git(root, "commit", "-q", "-m", "one"); + + expect(await adapter.hasHistoryFor(root, "tracked.txt")).toBe(true); + }); + + it("is false outside a repository", async () => { + expect(await adapter.hasHistoryFor(outside, "tracked.txt")).toBe(false); + }); + }); + + describe("installCommitMessageDelegate, when this CLI owns the hook", () => { + it("writes the delegate executable and a hook calling it from scratch", async () => { + const result = await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + const delegatePath = join(hooksDir, DELEGATE); + expect(result).toStrictEqual({ lineAdded: true }); + expect(await readFile(delegatePath, "utf8")).toBe(SCRIPT); + expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( + `${SESSION_TRAILER_HOOK_HEADER}\n${sessionTrailerHookLine(delegatePath)}\n` + ); + }); + + it("reports the delegate executable and the call site present afterwards", async () => { + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect( + await adapter.readCommitTrailerSetup(root, DELEGATE, SESSION_TRAILER_TOKEN, 10) + ).toStrictEqual({ + delegate: "executable", + hookExecutable: true, + callSite: "present", + hookHasOtherContent: false, + hooksDir, + }); + }); + + it("adds nothing the second time", async () => { + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT)).toStrictEqual({ + lineAdded: false, + }); + }); + + it("appends one line to an existing hook, on its own line", async () => { + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\necho mine"); + + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( + `#!/bin/sh\necho mine\n${sessionTrailerHookLine(join(hooksDir, DELEGATE))}\n` + ); + }); + + it("follows core.hooksPath rather than assuming .git/hooks", async () => { + const custom = join(root, "my-hooks"); + git(root, "config", "core.hooksPath", "my-hooks"); + + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(await readFile(join(custom, DELEGATE), "utf8")).toBe(SCRIPT); + }); + + it("installs nothing outside a repository", async () => { + expect(await adapter.installCommitMessageDelegate(outside, DELEGATE, SCRIPT)).toStrictEqual({ + lineAdded: false, + }); + }); + }); + + describe("installCommitMessageDelegate, when a manager owns the hook", () => { + it("lands the delegate in the common hooks directory and appends no line", async () => { + await writeFile(join(root, "lefthook.yml"), "pre-commit:\n"); + + const result = await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(result).toStrictEqual({ + lineAdded: false, + hookManager: "lefthook", + managerCallsDelegate: false, + }); + expect(await readFile(join(hooksDir, DELEGATE), "utf8")).toBe(SCRIPT); + await expect(readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).rejects.toThrow( + /ENOENT/ + ); + }); + + it("reads a lefthook config that names the delegate as already wired", async () => { + await writeFile(join(root, ".lefthook.yaml"), `run: sh .git/hooks/${DELEGATE}\n`); + + expect(await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT)).toStrictEqual({ + lineAdded: false, + hookManager: "lefthook", + managerCallsDelegate: true, + }); + }); + + it("reads husky's own prepare-commit-msg for the delegate", async () => { + await mkdir(join(root, ".husky"), { recursive: true }); + await writeFile(join(root, ".husky", "prepare-commit-msg"), `sh ${DELEGATE}\n`); + + expect(await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT)).toStrictEqual({ + lineAdded: false, + hookManager: "husky", + managerCallsDelegate: true, + }); + }); + + it("reads a husky directory holding no prepare-commit-msg as not wired", async () => { + await mkdir(join(root, ".husky"), { recursive: true }); + + expect(await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT)).toStrictEqual({ + lineAdded: false, + hookManager: "husky", + managerCallsDelegate: false, + }); + }); + + it("ignores core.hooksPath under a manager", async () => { + await writeFile(join(root, "lefthook.yml"), ""); + git(root, "config", "core.hooksPath", "my-hooks"); + + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(await readFile(join(hooksDir, DELEGATE), "utf8")).toBe(SCRIPT); + }); + + it("installs nothing outside a repository, and names no manager", async () => { + await writeFile(join(outside, "lefthook.yml"), ""); + + expect(await adapter.installCommitMessageDelegate(outside, DELEGATE, SCRIPT)).toStrictEqual({ + lineAdded: false, + }); + }); }); describe("removeCommitMessageDelegate", () => { @@ -59,6 +255,37 @@ describe("GitAdapter", () => { }); }); + it("reports nothing removed when nothing was installed", async () => { + expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ + removed: false, + }); + }); + + it("reports nothing removed from a hook that never called the delegate", async () => { + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\necho mine\n"); + + expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ + removed: false, + }); + expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( + "#!/bin/sh\necho mine\n" + ); + }); + + it("drops the line even when a hand edit indented it", async () => { + await mkdir(hooksDir, { recursive: true }); + const line = sessionTrailerHookLine(join(hooksDir, DELEGATE)); + await writeFile(join(hooksDir, "prepare-commit-msg"), `#!/bin/sh\n ${line}\necho mine\n`); + + expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ + removed: true, + }); + expect(await readFile(join(hooksDir, "prepare-commit-msg"), "utf8")).toBe( + "#!/bin/sh\necho mine\n" + ); + }); + // A mode bit is POSIX: on Windows `access(X_OK)` answers like `F_OK`. it.skipIf(process.platform === "win32")( "leaves a hook that was not executable as it found it", @@ -79,5 +306,127 @@ describe("GitAdapter", () => { ); } ); + + it("counts a hand-deleted delegate whose line is still there as removed", async () => { + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + await rm(join(hooksDir, DELEGATE)); + + expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ + removed: true, + }); + }); + + it("counts a delegate whose line was hand-dropped as removed", async () => { + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\n"); + + expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ + removed: true, + }); + }); + + it("looks under the manager's directory and carries the manager facts", async () => { + await writeFile(join(root, "lefthook.yml"), ""); + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(await adapter.removeCommitMessageDelegate(root, DELEGATE)).toStrictEqual({ + removed: true, + hookManager: "lefthook", + managerCallsDelegate: false, + }); + }); + + it("carries the manager facts outside a repository too", async () => { + await writeFile(join(outside, "lefthook.yml"), ""); + + expect(await adapter.removeCommitMessageDelegate(outside, DELEGATE)).toStrictEqual({ + removed: false, + hookManager: "lefthook", + managerCallsDelegate: false, + }); + }); + }); + + describe("readCommitTrailerSetup", () => { + it("says no hook file and no delegate on a fresh repository", async () => { + expect(await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).toStrictEqual({ + delegate: "absent", + callSite: "no-hook-file", + hookHasOtherContent: false, + hooksDir, + }); + }); + + it("says no repository outside one", async () => { + expect(await adapter.readCommitTrailerSetup(outside, DELEGATE, "X", 1)).toStrictEqual({ + delegate: "absent", + callSite: "no-hook-file", + hookHasOtherContent: false, + hooksDirMissing: "no-repository", + }); + }); + + it("reports a hook git would refuse to run", async () => { + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\n", { mode: 0o644 }); + + expect(await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).toStrictEqual({ + delegate: "absent", + hookExecutable: false, + callSite: "missing", + hookHasOtherContent: false, + hooksDir, + }); + }); + + it("tells a delegate that is there but not executable from one that is missing", async () => { + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, DELEGATE), SCRIPT, { mode: 0o644 }); + + expect((await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).delegate).toBe( + "not-executable" + ); + }); + + it("does not count the header the CLI writes as somebody else's content", async () => { + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\n\n \n"); + + expect( + (await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).hookHasOtherContent + ).toBe(false); + }); + + it("counts, among the last commits, those carrying the trailer", async () => { + await commit(root, "plain"); + await commit(root, `stamped\n\n${SESSION_TRAILER_TOKEN}: abc`); + await commit(root, "plain again"); + + expect( + (await adapter.readCommitTrailerSetup(root, DELEGATE, SESSION_TRAILER_TOKEN, 2)) + .recentlyCarrying + ).toStrictEqual({ carrying: 1, examined: 2 }); + }); + + it("leaves the count absent, not zero, when there is no history", async () => { + expect( + "recentlyCarrying" in (await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 5)) + ).toBe(false); + }); + + it("reports the delegate under a manager's directory even when the hooks dir is elsewhere", async () => { + await writeFile(join(root, "lefthook.yml"), `pre-commit: ${DELEGATE}`); + git(root, "config", "core.hooksPath", "my-hooks"); + await adapter.installCommitMessageDelegate(root, DELEGATE, SCRIPT); + + expect(await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).toStrictEqual({ + delegate: "executable", + callSite: "no-hook-file", + hookHasOtherContent: false, + hooksDir: join(root, "my-hooks"), + hookManager: "lefthook", + managerCallsDelegate: true, + }); + }); }); }); diff --git a/cli/tests/runtime/git/git-environment.unit.test.ts b/cli/tests/runtime/git/git-environment.unit.test.ts new file mode 100644 index 000000000..6e80729d8 --- /dev/null +++ b/cli/tests/runtime/git/git-environment.unit.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from "vitest"; +import { environmentWithoutGitVariables } from "../../../src/runtime/git/git-environment.js"; + +describe("environmentWithoutGitVariables", () => { + it("drops every variable git exports into a hook, and keeps the rest", () => { + expect( + environmentWithoutGitVariables({ + GIT_DIR: "/elsewhere/.git", + GIT_WORK_TREE: "/elsewhere", + GIT_INDEX_FILE: "/elsewhere/index", + PATH: "/usr/bin", + MY_GIT_THING: "kept", + }) + ).toStrictEqual({ PATH: "/usr/bin", MY_GIT_THING: "kept" }); + }); + + it("reads the process environment when given none", () => { + expect(environmentWithoutGitVariables().PATH).toBe(process.env.PATH); + }); +}); diff --git a/cli/tests/runtime/git/inject-token-hosts.unit.test.ts b/cli/tests/runtime/git/inject-token-hosts.unit.test.ts new file mode 100644 index 000000000..de802b3ca --- /dev/null +++ b/cli/tests/runtime/git/inject-token-hosts.unit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { injectTokenIntoUrl, withoutCredentials } from "../../../src/runtime/git/inject-token.js"; + +describe("injectTokenIntoUrl, Azure DevOps", () => { + it("uses an empty user with the token as password", () => { + expect(injectTokenIntoUrl("https://dev.azure.com/org/repo", "tok")).toBe( + "https://:tok@dev.azure.com/org/repo" + ); + }); +}); + +describe("withoutCredentials", () => { + it("strips a credential from an http URL as well", () => { + expect(withoutCredentials("http://user:pw@host/repo.git")).toBe("http://host/repo.git"); + }); + + it("strips only a credential at the start of the URL", () => { + expect(withoutCredentials("x https://user@host/repo")).toBe("x https://user@host/repo"); + }); + + it("leaves a URL carrying no credential alone", () => { + expect(withoutCredentials("https://host/repo.git")).toBe("https://host/repo.git"); + }); +}); diff --git a/cli/tests/runtime/http/http-client-wire.integration.test.ts b/cli/tests/runtime/http/http-client-wire.integration.test.ts new file mode 100644 index 000000000..6dcc40232 --- /dev/null +++ b/cli/tests/runtime/http/http-client-wire.integration.test.ts @@ -0,0 +1,103 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { describe, expect, it } from "vitest"; +import { HttpError, HttpRedirectError } from "../../../src/kernel/errors.js"; +import { HttpClient } from "../../../src/runtime/http/http-client.js"; + +function startServer(handler: (req: IncomingMessage, res: ServerResponse) => void) { + const server = createServer(handler); + return new Promise<{ url: string; close: () => Promise }>((resolve) => { + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as AddressInfo).port; + resolve({ + url: `http://127.0.0.1:${port}`, + close: () => new Promise((done) => server.close(() => done())), + }); + }); + }); +} + +describe("HttpClient, on the wire", () => { + it("sends GET, the path with its query, and the GitHub headers by default", async () => { + let seen: { method?: string; url?: string; agent?: string; accept?: string } = {}; + const { url, close } = await startServer((req, res) => { + seen = { + method: req.method, + url: req.url, + agent: req.headers["user-agent"], + accept: req.headers.accept, + }; + res.writeHead(200); + res.end(); + }); + try { + await new HttpClient().get(`${url}/a/b?c=1`); + expect(seen).toStrictEqual({ + method: "GET", + url: "/a/b?c=1", + agent: "aidd-cli", + accept: "application/vnd.github+json", + }); + } finally { + await close(); + } + }); + + it("answers an empty content type and the raw bytes when the server names none", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(200); + res.end("raw"); + }); + try { + const response = await new HttpClient().get(url); + expect({ ...response, body: String(response.body) }).toStrictEqual({ + body: "raw", + statusCode: 200, + contentType: "", + }); + } finally { + await close(); + } + }); + + it("follows a 301 as it follows a 302", async () => { + const target = await startServer((_req, res) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ moved: true })); + }); + const { url, close } = await startServer((_req, res) => { + res.writeHead(301, { Location: target.url }); + res.end(); + }); + try { + expect((await new HttpClient().get(url)).body).toStrictEqual({ moved: true }); + } finally { + await close(); + await target.close(); + } + }); + + it("refuses a redirect that names no destination", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(302); + res.end(); + }); + try { + await expect(new HttpClient().get(url)).rejects.toThrow(new HttpRedirectError(url)); + } finally { + await close(); + } + }); + + it("reports HTTP 300 as unexpected, the first code past success", async () => { + const { url, close } = await startServer((_req, res) => { + res.writeHead(300); + res.end(); + }); + try { + await expect(new HttpClient().get(url)).rejects.toThrow(new HttpError(300, url)); + } finally { + await close(); + } + }); +}); diff --git a/cli/tests/runtime/project-root/project-root.unit.test.ts b/cli/tests/runtime/project-root/project-root.unit.test.ts new file mode 100644 index 000000000..df4e11470 --- /dev/null +++ b/cli/tests/runtime/project-root/project-root.unit.test.ts @@ -0,0 +1,40 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resolveProjectRoot } from "../../../src/runtime/project-root/project-root.js"; + +describe("resolveProjectRoot", () => { + const savedPwd = process.env.PWD; + let elsewhere: string; + + beforeEach(async () => { + elsewhere = await mkdtemp(join(tmpdir(), "aidd-project-root-")); + }); + + afterEach(async () => { + if (savedPwd === undefined) delete process.env.PWD; + else process.env.PWD = savedPwd; + await rm(elsewhere, { recursive: true, force: true }); + }); + + it("prefers the shell's PWD when it names another existing directory", () => { + process.env.PWD = elsewhere; + expect(resolveProjectRoot()).toBe(elsewhere); + }); + + it("answers the working directory when PWD names a directory that is gone", () => { + process.env.PWD = join(elsewhere, "gone"); + expect(resolveProjectRoot()).toBe(process.cwd()); + }); + + it("answers the working directory when PWD is unset", () => { + delete process.env.PWD; + expect(resolveProjectRoot()).toBe(process.cwd()); + }); + + it("answers the working directory when PWD already names it", () => { + process.env.PWD = process.cwd(); + expect(resolveProjectRoot()).toBe(process.cwd()); + }); +}); diff --git a/cli/tests/runtime/prompter/prompter-adapter-rendering.integration.test.ts b/cli/tests/runtime/prompter/prompter-adapter-rendering.integration.test.ts new file mode 100644 index 000000000..682bc499c --- /dev/null +++ b/cli/tests/runtime/prompter/prompter-adapter-rendering.integration.test.ts @@ -0,0 +1,152 @@ +import { PassThrough } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { + InquirerPrompterAdapter, + SilentPrompterAdapter, +} from "../../../src/runtime/prompter/prompter-adapter.js"; + +const ENTER = "\n"; +const ARROW_DOWN = "\x1b[B"; + +function makeAdapter() { + const inputStream = new PassThrough(); + const outputStream = new PassThrough(); + const rendered: string[] = []; + outputStream.on("data", (chunk: Buffer) => rendered.push(chunk.toString())); + const adapter = new InquirerPrompterAdapter({ input: inputStream, output: outputStream }); + return { adapter, inputStream, screen: () => rendered.join("") }; +} + +function press(inputStream: PassThrough, ...keys: string[]): void { + let delay = 0; + for (const key of keys) { + delay += 20; + setTimeout(() => inputStream.write(key), delay); + } +} + +describe("SilentPrompterAdapter, conflicts", () => { + const adapter = new SilentPrompterAdapter(); + + it("overwrites a single conflict", async () => { + expect(await adapter.resolveConflict("a.md", "modified")).toBe("overwrite"); + }); + + it("overwrites a bulk conflict, one at a time", async () => { + expect(await adapter.resolveConflictBulk("a.md", "deleted")).toBe("overwrite"); + }); +}); + +describe("InquirerPrompterAdapter, what the screen says", () => { + it("names the file and that it was deleted", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.resolveConflict("a.md", "deleted"); + press(inputStream, ENTER); + await result; + + expect(screen()).toContain("Conflict: a.md was deleted. What do you want to do?"); + }); + + it("names the file and that it was locally modified", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.resolveConflict("a.md", "modified"); + press(inputStream, ENTER); + await result; + + expect(screen()).toContain("Conflict: a.md was locally modified. What do you want to do?"); + }); + + it("offers overwrite and keep for a single conflict", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.resolveConflict("a.md", "modified"); + press(inputStream, ENTER); + await result; + + expect(screen()).toContain("Overwrite with latest version"); + expect(screen()).toContain("Keep my local version"); + }); + + it("offers the two bulk answers as well for a bulk conflict", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.resolveConflictBulk("a.md", "modified"); + press(inputStream, ENTER); + await result; + + expect(screen()).toContain("Overwrite with latest version"); + expect(screen()).toContain("Keep my local version"); + expect(screen()).toContain("Overwrite all remaining conflicts"); + expect(screen()).toContain("Skip all remaining conflicts"); + }); + + it("marks a choice disabled without a reason as Disabled", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.select("Pick", [ + { name: "first", value: 1, disabled: true }, + { name: "second", value: 2 }, + ]); + press(inputStream, ARROW_DOWN, ENTER); + await result; + + expect(screen()).toContain("first Disabled"); + }); + + it("shows the reason a choice is disabled", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.select("Pick", [ + { name: "first", value: 1, disabled: "not installed" }, + { name: "second", value: 2 }, + ]); + press(inputStream, ARROW_DOWN, ENTER); + await result; + + expect(screen()).toContain("first not installed"); + }); +}); + +describe("InquirerPrompterAdapter, bulk conflict answers", () => { + it("answers overwrite on Enter", async () => { + const { adapter, inputStream } = makeAdapter(); + const result = adapter.resolveConflictBulk("a.md", "modified"); + press(inputStream, ENTER); + + expect(await result).toBe("overwrite"); + }); + + it("answers keep one step down", async () => { + const { adapter, inputStream } = makeAdapter(); + const result = adapter.resolveConflictBulk("a.md", "modified"); + press(inputStream, ARROW_DOWN, ENTER); + + expect(await result).toBe("keep"); + }); + + it("answers overwrite-all two steps down", async () => { + const { adapter, inputStream } = makeAdapter(); + const result = adapter.resolveConflictBulk("a.md", "modified"); + press(inputStream, ARROW_DOWN, ARROW_DOWN, ENTER); + + expect(await result).toBe("overwrite-all"); + }); + + it("answers skip-all three steps down", async () => { + const { adapter, inputStream } = makeAdapter(); + const result = adapter.resolveConflictBulk("a.md", "modified"); + press(inputStream, ARROW_DOWN, ARROW_DOWN, ARROW_DOWN, ENTER); + + expect(await result).toBe("skip-all"); + }); +}); + +describe("InquirerPrompterAdapter, a disabled choice", () => { + it("refuses Enter, so the answer is the next enabled one", async () => { + const { adapter, inputStream, screen } = makeAdapter(); + const result = adapter.select("Pick", [ + { name: "first", value: 1, disabled: "not installed" }, + { name: "second", value: 2 }, + ]); + press(inputStream, ENTER, ARROW_DOWN, ENTER); + + expect(await result).toBe(2); + expect(screen()).toContain("This option is disabled and cannot be selected."); + }); +}); diff --git a/cli/tests/runtime/self-update/check-update-notice.unit.test.ts b/cli/tests/runtime/self-update/check-update-notice.unit.test.ts new file mode 100644 index 000000000..83d2247c5 --- /dev/null +++ b/cli/tests/runtime/self-update/check-update-notice.unit.test.ts @@ -0,0 +1,69 @@ +import { dirname, join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CheckUpdateUseCase } from "../../../src/runtime/self-update/check-update-use-case.js"; +import { userConfigDir } from "../../../src/runtime/user-config-dir.js"; +import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../helpers/ports/in-memory-file-adapter.js"; + +const CACHE = join(userConfigDir(), "cache", "update-check.json"); + +function useCase(fs: InMemoryFileAdapter, current: string, logger = new CapturingLogger()) { + return new CheckUpdateUseCase( + { + fetchLatestRelease: async () => ({ version: "9.0.0", changelog: null }), + install: () => "/usr/local/bin/aidd", + }, + { get: () => current }, + logger, + fs + ); +} + +describe("CheckUpdateUseCase, the notice", () => { + it("names both versions without their v, and the command to run", async () => { + const fs = new InMemoryFileAdapter({ + [CACHE]: JSON.stringify({ checkedAt: 0, latest: "v2.0.0" }), + }); + const logger = new CapturingLogger(); + + await useCase(fs, "v1.0.0", logger).printFromCacheOnly(); + + expect(logger.warnMessages).toStrictEqual([ + "CLI update available: v1.0.0 → v2.0.0", + "Run `aidd update`.", + ]); + }); + + it("strips only a leading v, never one inside a pre-release tag", async () => { + const fs = new InMemoryFileAdapter({ + [CACHE]: JSON.stringify({ checkedAt: 0, latest: "2.0.0-preview" }), + }); + const logger = new CapturingLogger(); + + await useCase(fs, "1.0.0-dev", logger).printFromCacheOnly(); + + expect(logger.warnMessages[0]).toBe("CLI update available: v1.0.0-dev → v2.0.0-preview"); + }); + + it("says nothing when the cache cannot be read", async () => { + const fs = new InMemoryFileAdapter({ [CACHE]: "{ not json" }); + const logger = new CapturingLogger(); + + await useCase(fs, "1.0.0", logger).printFromCacheOnly(); + + expect(logger.warnMessages).toStrictEqual([]); + }); + + it("writes the cache under the cache directory it created", async () => { + const created: string[] = []; + const fs = new InMemoryFileAdapter(); + fs.createDirectory = async (path: string) => { + created.push(path); + }; + + await useCase(fs, "1.0.0").refresh(); + + expect(created.map((path) => join(path))).toStrictEqual([dirname(CACHE)]); + expect(JSON.parse(fs.getFile(CACHE) ?? "{}").latest).toBe("9.0.0"); + }); +}); diff --git a/cli/tests/runtime/self-update/github-release-resolver-shape.unit.test.ts b/cli/tests/runtime/self-update/github-release-resolver-shape.unit.test.ts new file mode 100644 index 000000000..b0b0668a0 --- /dev/null +++ b/cli/tests/runtime/self-update/github-release-resolver-shape.unit.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import type { HttpGet, HttpGetOptions } from "../../../src/runtime/http/http-client.js"; +import { GitHubReleaseResolverAdapter } from "../../../src/runtime/self-update/github-release-resolver-adapter.js"; + +function http(body: unknown): HttpGet & { calls: { url: string; options?: HttpGetOptions }[] } { + const calls: { url: string; options?: HttpGetOptions }[] = []; + return { + calls, + get: async (url, options) => { + calls.push({ url, options }); + return { body, statusCode: 200, contentType: "application/json" }; + }, + }; +} + +describe("GitHubReleaseResolverAdapter, the shape of an answer", () => { + it("resolves no latest when the first release names no tag", async () => { + const adapter = new GitHubReleaseResolverAdapter(http([{ tag_name: 7 }])); + + expect(await adapter.resolveLatest("o/r")).toBeNull(); + }); + + it("lists no root release when the body is not a list", async () => { + const adapter = new GitHubReleaseResolverAdapter(http({ message: "rate limited" })); + + expect(await adapter.listRootReleases("o/r")).toStrictEqual([]); + }); + + it("keeps only tags that are text", async () => { + const adapter = new GitHubReleaseResolverAdapter( + http([{ tag_name: 7 }, { tag_name: "v1.0.0" }]) + ); + + expect(await adapter.listRootReleases("o/r")).toStrictEqual(["v1.0.0"]); + }); + + it("lists releases with the token it was given", async () => { + const client = http([]); + const adapter = new GitHubReleaseResolverAdapter(client, { resolve: async () => "tok" }); + + await adapter.listRootReleases("o/r"); + + expect(client.calls).toStrictEqual([ + { url: "https://api.github.com/repos/o/r/releases?per_page=100", options: { token: "tok" } }, + ]); + }); + + it("resolves the latest with the token it was given", async () => { + const client = http([]); + const adapter = new GitHubReleaseResolverAdapter(client, { resolve: async () => "tok" }); + + await adapter.resolveLatest("o/r"); + + expect(client.calls).toStrictEqual([ + { url: "https://api.github.com/repos/o/r/releases?per_page=1", options: { token: "tok" } }, + ]); + }); +}); diff --git a/cli/tests/runtime/self-update/self-updater-adapter-edges.integration.test.ts b/cli/tests/runtime/self-update/self-updater-adapter-edges.integration.test.ts new file mode 100644 index 000000000..41c36c0e8 --- /dev/null +++ b/cli/tests/runtime/self-update/self-updater-adapter-edges.integration.test.ts @@ -0,0 +1,177 @@ +import { execSync } from "node:child_process"; +import { platform } from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { FrameworkResolutionError, UpdateError } from "../../../src/kernel/errors.js"; +import type { HttpGet, HttpGetOptions } from "../../../src/runtime/http/http-client.js"; +import { SelfUpdaterAdapter } from "../../../src/runtime/self-update/self-updater-adapter.js"; +import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; + +vi.mock("node:child_process", () => ({ execSync: vi.fn() })); +vi.mock("node:os", () => ({ platform: vi.fn() })); + +const mockExecSync = vi.mocked(execSync); +const mockPlatform = vi.mocked(platform); + +const NPM_URL = "https://registry.npmjs.org/-/package/@ai-driven-dev/cli/dist-tags"; +const TAG_URL = "https://api.github.com/repos/ai-driven-dev/framework/releases/tags/cli-v1.2.3"; + +function http( + answers: Record +): HttpGet & { calls: { url: string; options?: HttpGetOptions }[] } { + const calls: { url: string; options?: HttpGetOptions }[] = []; + return { + calls, + get: async (url, options) => { + calls.push({ url, options }); + const answer = answers[url]; + if (answer instanceof Error) throw answer; + return { body: answer, statusCode: 200, contentType: "application/json" }; + }, + }; +} + +function offline(): HttpGet { + return { get: async () => ({ body: {}, statusCode: 200, contentType: "" }) }; +} + +describe("SelfUpdaterAdapter.fetchLatestRelease, the edges", () => { + it("refuses a registry answer that is not an object", async () => { + const adapter = new SelfUpdaterAdapter(http({ [NPM_URL]: null })); + + await expect(adapter.fetchLatestRelease()).rejects.toThrow( + new FrameworkResolutionError(`Unexpected npm registry response from ${NPM_URL}`) + ); + }); + + it("wraps a transport failure with the URL it was reading", async () => { + const adapter = new SelfUpdaterAdapter(http({ [NPM_URL]: new Error("socket hang up") })); + + await expect(adapter.fetchLatestRelease()).rejects.toThrow( + new FrameworkResolutionError( + `Could not resolve the latest CLI version from ${NPM_URL}: socket hang up` + ) + ); + }); + + it("reads the changelog with the token it was given", async () => { + const client = http({ [NPM_URL]: { latest: "1.2.3" }, [TAG_URL]: { body: "notes" } }); + const adapter = new SelfUpdaterAdapter(client, { + tokenProvider: { resolve: async () => "tok" }, + }); + + expect(await adapter.fetchLatestRelease()).toStrictEqual({ + version: "1.2.3", + changelog: "notes", + }); + expect(client.calls[1]).toStrictEqual({ url: TAG_URL, options: { token: "tok" } }); + }); + + it("says why the changelog is missing, on the debug channel", async () => { + const logger = new CapturingLogger(); + const client = http({ [NPM_URL]: { latest: "1.2.3" }, [TAG_URL]: new Error("offline") }); + + await new SelfUpdaterAdapter(client, { logger }).fetchLatestRelease(); + + expect(logger.debugMessages).toStrictEqual([`Changelog unavailable from ${TAG_URL}: offline`]); + }); +}); + +describe("SelfUpdaterAdapter.install, what it runs", () => { + let written: string[]; + const realWrite = process.stderr.write; + + beforeEach(() => { + vi.clearAllMocks(); + written = []; + process.stderr.write = ((chunk: string | Uint8Array) => { + written.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + }); + + afterEach(() => { + process.stderr.write = realWrite; + }); + + it("asks `which` on POSIX, reading its answer as text", () => { + mockPlatform.mockReturnValue("linux"); + mockExecSync.mockReturnValueOnce("/usr/local/bin/aidd\n").mockReturnValue(Buffer.alloc(0)); + + new SelfUpdaterAdapter(offline()).install(); + + expect(mockExecSync.mock.calls[0]).toStrictEqual(["which aidd", { encoding: "utf8" }]); + }); + + it("asks `where` on Windows", () => { + mockPlatform.mockReturnValue("win32"); + mockExecSync.mockReturnValueOnce("C:\\x\\aidd.cmd").mockReturnValue(Buffer.alloc(0)); + + new SelfUpdaterAdapter(offline()).install(); + + expect(mockExecSync.mock.calls[0]).toStrictEqual(["where aidd", { encoding: "utf8" }]); + }); + + it("runs the npm install with stderr piped, and answers the binary path", () => { + mockPlatform.mockReturnValue("linux"); + mockExecSync.mockReturnValueOnce("/usr/local/bin/aidd\n").mockReturnValue(Buffer.alloc(0)); + + const binaryPath = new SelfUpdaterAdapter(offline()).install(); + + expect(binaryPath).toBe("/usr/local/bin/aidd"); + expect(mockExecSync.mock.calls[1]).toStrictEqual([ + "npm install -g @ai-driven-dev/cli@latest", + { stdio: ["inherit", "inherit", "pipe"] }, + ]); + }); + + it("detects bun from a Windows bin directory whatever its case", () => { + mockPlatform.mockReturnValue("win32"); + mockExecSync + .mockReturnValueOnce("C:\\Users\\me\\AppData\\Local\\Bun\\Bin\\aidd.exe") + .mockReturnValue(Buffer.alloc(0)); + + new SelfUpdaterAdapter(offline()).install(); + + expect(mockExecSync.mock.calls[1]?.[0]).toBe("bun add -g @ai-driven-dev/cli@latest"); + }); + + it("echoes the failed install's stderr once, then refuses", () => { + mockPlatform.mockReturnValue("linux"); + mockExecSync.mockReturnValueOnce("/usr/local/bin/aidd").mockImplementationOnce(() => { + throw Object.assign(new Error("failed"), { stderr: Buffer.from("npm error 403\n") }); + }); + + expect(() => new SelfUpdaterAdapter(offline()).install()).toThrow(new UpdateError()); + expect(written).toStrictEqual(["npm error 403\n"]); + }); + + it("echoes nothing when the failure carried no stderr", () => { + mockPlatform.mockReturnValue("linux"); + mockExecSync.mockReturnValueOnce("/usr/local/bin/aidd").mockImplementationOnce(() => { + throw new Error("failed"); + }); + + expect(() => new SelfUpdaterAdapter(offline()).install()).toThrow(new UpdateError()); + expect(written).toStrictEqual([]); + }); + + it("echoes nothing when the failure was not even an object", () => { + mockPlatform.mockReturnValue("linux"); + mockExecSync.mockReturnValueOnce("/usr/local/bin/aidd").mockImplementationOnce(() => { + throw "failed"; + }); + + expect(() => new SelfUpdaterAdapter(offline()).install()).toThrow(new UpdateError()); + expect(written).toStrictEqual([]); + }); + + it("echoes nothing when stderr is neither text nor bytes", () => { + mockPlatform.mockReturnValue("linux"); + mockExecSync.mockReturnValueOnce("/usr/local/bin/aidd").mockImplementationOnce(() => { + throw Object.assign(new Error("failed"), { stderr: 42 }); + }); + + expect(() => new SelfUpdaterAdapter(offline()).install()).toThrow(new UpdateError()); + expect(written).toStrictEqual([]); + }); +}); diff --git a/cli/tests/runtime/wiring/create-deps.integration.test.ts b/cli/tests/runtime/wiring/create-deps.integration.test.ts new file mode 100644 index 000000000..6b4b62109 --- /dev/null +++ b/cli/tests/runtime/wiring/create-deps.integration.test.ts @@ -0,0 +1,61 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CLIOutput } from "../../../src/presentation/output.js"; +import { createDeps, createMenuDeps } from "../../../src/runtime/wiring/framework.js"; + +describe("createDeps", () => { + let root: string; + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), "aidd-create-deps-")); + }); + + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + + it("builds the graph once per project root", async () => { + const first = await createDeps(root, { verbose: false }); + + expect(await createDeps(root, { verbose: false })).toBe(first); + }); + + it("builds a separate graph for another project root", async () => { + const other = await mkdtemp(join(tmpdir(), "aidd-create-deps-other-")); + try { + expect(await createDeps(other, { verbose: false })).not.toBe( + await createDeps(root, { verbose: false }) + ); + } finally { + await rm(other, { recursive: true, force: true }); + } + }); + + it("builds a separate graph when a token is given on the command line", async () => { + expect(await createDeps(root, { verbose: false, token: "ghp_x" })).not.toBe( + await createDeps(root, { verbose: false }) + ); + }); + + it("builds a separate graph per token", async () => { + expect(await createDeps(root, { verbose: false, token: "ghp_a" })).not.toBe( + await createDeps(root, { verbose: false, token: "ghp_b" }) + ); + }); + + it("logs through the output it was handed", async () => { + const output = new CLIOutput(false); + const withOutput = await mkdtemp(join(tmpdir(), "aidd-create-deps-output-")); + try { + expect((await createDeps(withOutput, { verbose: false }, output)).logger).toBe(output); + } finally { + await rm(withOutput, { recursive: true, force: true }); + } + }); + + it("hands the menu a manifest repository rooted at the project", () => { + expect(createMenuDeps(root).manifestRepo.path).toBe(join(root, ".aidd", "manifest.json")); + }); +}); diff --git a/cli/tests/runtime/wiring/framework-build-modes.integration.test.ts b/cli/tests/runtime/wiring/framework-build-modes.integration.test.ts new file mode 100644 index 000000000..6bdd98ef3 --- /dev/null +++ b/cli/tests/runtime/wiring/framework-build-modes.integration.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { OutDirNotDirectoryError } from "../../../src/kernel/errors.js"; +import { BundledAssetProviderAdapter } from "../../../src/runtime/assets/asset-loader.js"; +import { createFrameworkBuildUseCase } from "../../../src/runtime/wiring/translate.js"; +import { CapturingLogger } from "../../helpers/ports/capturing-logger.js"; +import { InMemoryFileAdapter } from "../../helpers/ports/in-memory-file-adapter.js"; +import { seedFromDirectory } from "../../helpers/ports/seed-from-directory.js"; +import { REPOSITORY_ROOT } from "../../helpers/repository-root.js"; + +const FIXTURE_DIR = join(REPOSITORY_ROOT, "cli", "tests", "fixtures", "framework"); + +describe("createFrameworkBuildUseCase, by mode", () => { + let outDir: string; + let fs: InMemoryFileAdapter; + + beforeEach(async () => { + outDir = await mkdtemp(join(tmpdir(), "aidd-build-modes-")); + fs = new InMemoryFileAdapter(); + await seedFromDirectory(fs, FIXTURE_DIR, { useAbsolutePaths: true }); + fs.setFile(`${outDir}/.keep`, ""); + }); + + afterEach(async () => { + await rm(outDir, { recursive: true, force: true }); + }); + + it("builds a marketplace tree, plugin directories included, in marketplace mode", async () => { + const useCase = createFrameworkBuildUseCase( + { fs, assetProvider: new BundledAssetProviderAdapter(), logger: new CapturingLogger() }, + { target: "claude", mode: "marketplace", outDir, force: true } + ); + if (useCase === undefined) throw new Error("claude:marketplace must be wired"); + + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir, target: "claude" }); + + expect(fs.has(`${outDir}/.claude-plugin/marketplace.json`)).toBe(true); + expect(fs.listUnder(`${outDir}/.github`)).toStrictEqual([]); + }); + + it("builds a flat tree, no marketplace catalog, in flat mode", async () => { + const useCase = createFrameworkBuildUseCase( + { fs, assetProvider: new BundledAssetProviderAdapter(), logger: new CapturingLogger() }, + { target: "copilot", mode: "flat", outDir, force: true } + ); + if (useCase === undefined) throw new Error("copilot:flat must be wired"); + + await useCase.execute({ sourceDir: FIXTURE_DIR, outDir, target: "copilot" }); + + expect(fs.has(`${outDir}/.claude-plugin/marketplace.json`)).toBe(false); + expect(fs.listUnder(`${outDir}/.github/agents`).length).toBeGreaterThan(0); + }); + + it("refuses a flat build into a directory that is not there on disk", async () => { + const gone = join(outDir, "gone"); + fs.setFile(`${gone}/.keep`, ""); + const useCase = createFrameworkBuildUseCase( + { fs, assetProvider: new BundledAssetProviderAdapter(), logger: new CapturingLogger() }, + { target: "copilot", mode: "flat", outDir: gone, force: true } + ); + if (useCase === undefined) throw new Error("copilot:flat must be wired"); + + await expect( + useCase.execute({ sourceDir: FIXTURE_DIR, outDir: gone, target: "copilot" }) + ).rejects.toBeInstanceOf(OutDirNotDirectoryError); + }); +}); diff --git a/cli/tests/runtime/wiring/installed-plugins-from-manifest.unit.test.ts b/cli/tests/runtime/wiring/installed-plugins-from-manifest.unit.test.ts new file mode 100644 index 000000000..699227dc9 --- /dev/null +++ b/cli/tests/runtime/wiring/installed-plugins-from-manifest.unit.test.ts @@ -0,0 +1,48 @@ +import "../../../src/contexts/tools/domain/profiles/claude/profile.js"; +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 { ManifestRepository } from "../../../src/contexts/framework/domain/ports/manifest-repository.js"; +import { installedPluginsFromManifest } from "../../../src/runtime/wiring/installed-plugins-from-manifest.js"; + +function repo(manifest: Manifest | null): ManifestRepository { + return { + path: "/proj/.aidd/manifest.json", + load: async () => manifest, + save: async () => {}, + delete: async () => {}, + }; +} + +describe("installedPluginsFromManifest", () => { + it("answers nothing at all when there is no manifest", async () => { + expect(await installedPluginsFromManifest(repo(null)).read()).toBeNull(); + }); + + it("lists only the tools that hold a plugin, by name and marketplace", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.addPlugin( + "claude", + InstalledPlugin.fromJSON({ + name: "aidd-dev", + source: { kind: "local", path: "/p" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + marketplace: "aidd-framework", + }) + ); + + const byTool = await installedPluginsFromManifest(repo(manifest)).read(); + + expect([...(byTool ?? new Map()).entries()]).toStrictEqual([ + ["claude", [{ name: "aidd-dev", marketplace: "aidd-framework" }]], + ]); + }); + + it("names the manifest file it reads", () => { + expect(installedPluginsFromManifest(repo(null)).path).toBe("/proj/.aidd/manifest.json"); + }); +}); diff --git a/cli/tests/runtime/wiring/wire-tools.unit.test.ts b/cli/tests/runtime/wiring/wire-tools.unit.test.ts new file mode 100644 index 000000000..dffb67d11 --- /dev/null +++ b/cli/tests/runtime/wiring/wire-tools.unit.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { wireTools } from "../../../src/runtime/wiring/tools.js"; + +describe("wireTools", () => { + it("wires one native activator per tool whose profile declares a binary, keyed by that binary", () => { + const { nativePluginActivators } = wireTools(); + + expect([...nativePluginActivators.keys()].sort()).toStrictEqual(["claude", "codex", "copilot"]); + }); + + it("wires the host marketplace registry readers off the same profiles", () => { + const { hostMarketplaceRegistries } = wireTools(); + + expect([...hostMarketplaceRegistries.keys()]).toStrictEqual(["claude"]); + }); +}); From 8b58f2ec163fade2e52b4d4aa90277a1b6a28d27 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 10 Sep 2026 10:51:59 +0200 Subject: [PATCH 3/4] test(cli): skip the trailer setup's mode-bit checks on Windows Windows CI failed two readCommitTrailerSetup tests that expect a hook or a delegate to read as not executable: there `access(X_OK)` answers like `F_OK`, so every existing file reads as executable, and git needs no mode bit to run a hook. They skip on Windows, as the removal test already does. Refs #799 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../git/git-adapter.integration.test.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/cli/tests/runtime/git/git-adapter.integration.test.ts b/cli/tests/runtime/git/git-adapter.integration.test.ts index 76d34c62f..fa9e49c92 100644 --- a/cli/tests/runtime/git/git-adapter.integration.test.ts +++ b/cli/tests/runtime/git/git-adapter.integration.test.ts @@ -366,7 +366,8 @@ describe("GitAdapter", () => { }); }); - it("reports a hook git would refuse to run", async () => { + // A mode bit is POSIX: on Windows `access(X_OK)` answers like `F_OK`. + it.skipIf(process.platform === "win32")("reports a hook git would refuse to run", async () => { await mkdir(hooksDir, { recursive: true }); await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\n", { mode: 0o644 }); @@ -379,14 +380,18 @@ describe("GitAdapter", () => { }); }); - it("tells a delegate that is there but not executable from one that is missing", async () => { - await mkdir(hooksDir, { recursive: true }); - await writeFile(join(hooksDir, DELEGATE), SCRIPT, { mode: 0o644 }); + // A mode bit is POSIX: on Windows `access(X_OK)` answers like `F_OK`. + it.skipIf(process.platform === "win32")( + "tells a delegate that is there but not executable from one that is missing", + async () => { + await mkdir(hooksDir, { recursive: true }); + await writeFile(join(hooksDir, DELEGATE), SCRIPT, { mode: 0o644 }); - expect((await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).delegate).toBe( - "not-executable" - ); - }); + expect((await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).delegate).toBe( + "not-executable" + ); + } + ); it("does not count the header the CLI writes as somebody else's content", async () => { await mkdir(hooksDir, { recursive: true }); From 1ff666a886c8122b74159cdd42b5266404dc1057 Mon Sep 17 00:00:00 2001 From: Baptiste LAFOURCADE Date: Thu, 10 Sep 2026 12:33:04 +0200 Subject: [PATCH 4/4] test(cli): name the mode-bit skip once in the git adapter tests The same Windows explanation sat above three skips. Stacked with the telemetry tests, the repetition pushed tests/ past its comment baseline (2989 against 2987); one named predicate says it once. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011x4ms5qcGuZgYhCxfdHMUb AIDD-Session-Id: 4acc9a1c-19bc-4468-b8b6-e86644bcba60 --- .../runtime/git/git-adapter.integration.test.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/cli/tests/runtime/git/git-adapter.integration.test.ts b/cli/tests/runtime/git/git-adapter.integration.test.ts index fa9e49c92..0ca84432d 100644 --- a/cli/tests/runtime/git/git-adapter.integration.test.ts +++ b/cli/tests/runtime/git/git-adapter.integration.test.ts @@ -15,6 +15,8 @@ import { environmentWithoutGitVariables } from "../../../src/runtime/git/git-env const DELEGATE = "aidd-session-trailer.sh"; const SCRIPT = "#!/bin/sh\necho delegate\n"; +/** A mode bit is POSIX: on Windows `access(X_OK)` answers like `F_OK`. */ +const MODE_BITS_UNOBSERVABLE = process.platform === "win32"; function git(cwd: string, ...args: string[]): string { const result = spawnSync( @@ -285,9 +287,7 @@ describe("GitAdapter", () => { "#!/bin/sh\necho mine\n" ); }); - - // A mode bit is POSIX: on Windows `access(X_OK)` answers like `F_OK`. - it.skipIf(process.platform === "win32")( + it.skipIf(MODE_BITS_UNOBSERVABLE)( "leaves a hook that was not executable as it found it", async () => { await mkdir(hooksDir, { recursive: true }); @@ -365,9 +365,7 @@ describe("GitAdapter", () => { hooksDirMissing: "no-repository", }); }); - - // A mode bit is POSIX: on Windows `access(X_OK)` answers like `F_OK`. - it.skipIf(process.platform === "win32")("reports a hook git would refuse to run", async () => { + it.skipIf(MODE_BITS_UNOBSERVABLE)("reports a hook git would refuse to run", async () => { await mkdir(hooksDir, { recursive: true }); await writeFile(join(hooksDir, "prepare-commit-msg"), "#!/bin/sh\n", { mode: 0o644 }); @@ -379,9 +377,7 @@ describe("GitAdapter", () => { hooksDir, }); }); - - // A mode bit is POSIX: on Windows `access(X_OK)` answers like `F_OK`. - it.skipIf(process.platform === "win32")( + it.skipIf(MODE_BITS_UNOBSERVABLE)( "tells a delegate that is there but not executable from one that is missing", async () => { await mkdir(hooksDir, { recursive: true });