diff --git a/cli/mutation-scopes.json b/cli/mutation-scopes.json index 1cbb4ac59..499a0ae20 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": [ @@ -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/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", + }); + }); +}); 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..0ca84432d 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"; @@ -11,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( @@ -22,13 +28,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 +50,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,8 +257,37 @@ describe("GitAdapter", () => { }); }); - // A mode bit is POSIX: on Windows `access(X_OK)` answers like `F_OK`. - it.skipIf(process.platform === "win32")( + 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" + ); + }); + it.skipIf(MODE_BITS_UNOBSERVABLE)( "leaves a hook that was not executable as it found it", async () => { await mkdir(hooksDir, { recursive: true }); @@ -79,5 +306,128 @@ 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.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 }); + + expect(await adapter.readCommitTrailerSetup(root, DELEGATE, "X", 1)).toStrictEqual({ + delegate: "absent", + hookExecutable: false, + callSite: "missing", + hookHasOtherContent: false, + hooksDir, + }); + }); + 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 }); + 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"]); + }); +});