From 469ba7e2a39a46e8f0d7ba4145242c867ea4d0db Mon Sep 17 00:00:00 2001 From: saitota Date: Fri, 3 Jul 2026 19:35:53 +0900 Subject: [PATCH] test(e2e): assert the generate matrix covers every declared tool target --- src/e2e/e2e-commands.spec.ts | 203 ++++++----- src/e2e/e2e-helper.ts | 56 ++- src/e2e/e2e-hooks.spec.ts | 256 ++++++++------ src/e2e/e2e-ignore.spec.ts | 131 +++---- src/e2e/e2e-mcp.spec.ts | 226 ++++++------ src/e2e/e2e-permissions.spec.ts | 165 +++++++++ src/e2e/e2e-rules.spec.ts | 237 +++++++------ src/e2e/e2e-skills.spec.ts | 585 +++++++++++++++++--------------- src/e2e/e2e-subagents.spec.ts | 346 ++++++++++--------- 9 files changed, 1321 insertions(+), 884 deletions(-) diff --git a/src/e2e/e2e-commands.spec.ts b/src/e2e/e2e-commands.spec.ts index 1e9e813a4..0eb733277 100644 --- a/src/e2e/e2e-commands.spec.ts +++ b/src/e2e/e2e-commands.spec.ts @@ -3,60 +3,110 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { RULESYNC_COMMANDS_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js"; +import { CommandsProcessor } from "../features/commands/commands-processor.js"; import { readFileContent, writeFileContent } from "../utils/file.js"; import { + assertGenerateMatrixCoversTargets, runGenerate, runImport, useGlobalTestDirectories, useTestDirectory, } from "./e2e-helper.js"; +const commandsGenerateTargets = [ + { target: "claudecode", outputPath: join(".claude", "commands", "review-pr.md") }, + { target: "cursor", outputPath: join(".cursor", "commands", "review-pr.md") }, + { target: "augmentcode", outputPath: join(".augment", "commands", "review-pr.md") }, + { target: "copilot", outputPath: join(".github", "prompts", "review-pr.prompt.md") }, + { target: "opencode", outputPath: join(".opencode", "commands", "review-pr.md") }, + { target: "cline", outputPath: join(".clinerules", "workflows", "review-pr.md") }, + { target: "kilo", outputPath: join(".kilo", "commands", "review-pr.md") }, + { target: "roo", outputPath: join(".roo", "commands", "review-pr.md") }, + { target: "kiro", outputPath: join(".kiro", "prompts", "review-pr.md") }, + { target: "kiro-cli", outputPath: join(".kiro", "prompts", "review-pr.md") }, + { target: "kiro-ide", outputPath: join(".kiro", "prompts", "review-pr.md") }, + { target: "antigravity-ide", outputPath: join(".agents", "workflows", "review-pr.md") }, + { target: "antigravity-cli", outputPath: join(".agents", "workflows", "review-pr.md") }, + { target: "junie", outputPath: join(".junie", "commands", "review-pr.md") }, + { target: "takt", outputPath: join(".takt", "facets", "instructions", "review-pr.md") }, + { target: "pi", outputPath: join(".pi", "prompts", "review-pr.md") }, + { target: "devin", outputPath: join(".devin", "workflows", "review-pr.md") }, + { target: "factorydroid", outputPath: join(".factory", "commands", "review-pr.md") }, + { target: "goose", outputPath: join(".goose", "recipes", "review-pr.yaml") }, + { target: "qwencode", outputPath: join(".qwen", "commands", "review-pr.md") }, + { target: "reasonix", outputPath: join(".reasonix", "commands", "review-pr.md") }, + { target: "rovodev", outputPath: join(".rovodev", "prompts", "review-pr.md") }, +] as const; + +const commandsGlobalTargets = [ + { target: "claudecode", outputPath: join(".claude", "commands", "review-pr.md") }, + { target: "cursor", outputPath: join(".cursor", "commands", "review-pr.md") }, + { target: "augmentcode", outputPath: join(".augment", "commands", "review-pr.md") }, + { target: "opencode", outputPath: join(".config", "opencode", "commands", "review-pr.md") }, + { target: "codexcli", outputPath: join(".codex", "prompts", "review-pr.md") }, + { target: "cline", outputPath: join("Documents", "Cline", "Workflows", "review-pr.md") }, + { target: "kilo", outputPath: join(".config", "kilo", "commands", "review-pr.md") }, + { target: "junie", outputPath: join(".junie", "commands", "review-pr.md") }, + { target: "kiro-cli", outputPath: join(".kiro", "prompts", "review-pr.md") }, + { + target: "antigravity-ide", + outputPath: join(".gemini", "antigravity", "global_workflows", "review-pr.md"), + }, + { + target: "antigravity-cli", + outputPath: join(".gemini", "antigravity-cli", "global_workflows", "review-pr.md"), + }, + { + target: "takt", + outputPath: join(".takt", "facets", "instructions", "review-pr.md"), + }, + { target: "pi", outputPath: join(".pi", "agent", "prompts", "review-pr.md") }, + { + target: "devin", + outputPath: join(".codeium", "windsurf", "global_workflows", "review-pr.md"), + }, + { target: "factorydroid", outputPath: join(".factory", "commands", "review-pr.md") }, + { target: "goose", outputPath: join(".config", "goose", "recipes", "review-pr.yaml") }, + { target: "qwencode", outputPath: join(".qwen", "commands", "review-pr.md") }, + // Hermes Agent has no project-scoped command location; commands are emitted + // as Hermes skills under ~/.hermes/skills//SKILL.md (global only). + { target: "hermesagent", outputPath: join(".hermes", "skills", "review-pr", "SKILL.md") }, + { target: "reasonix", outputPath: join(".reasonix", "commands", "review-pr.md") }, + { target: "rovodev", outputPath: join(".rovodev", "prompts", "review-pr.md") }, +] as const; + describe("E2E: commands", () => { const { getTestDir } = useTestDirectory(); - it.each([ - { target: "claudecode", outputPath: join(".claude", "commands", "review-pr.md") }, - { target: "cursor", outputPath: join(".cursor", "commands", "review-pr.md") }, - { target: "augmentcode", outputPath: join(".augment", "commands", "review-pr.md") }, - { target: "copilot", outputPath: join(".github", "prompts", "review-pr.prompt.md") }, - { target: "opencode", outputPath: join(".opencode", "commands", "review-pr.md") }, - { target: "cline", outputPath: join(".clinerules", "workflows", "review-pr.md") }, - { target: "kilo", outputPath: join(".kilo", "commands", "review-pr.md") }, - { target: "roo", outputPath: join(".roo", "commands", "review-pr.md") }, - { target: "kiro", outputPath: join(".kiro", "prompts", "review-pr.md") }, - { target: "antigravity-ide", outputPath: join(".agents", "workflows", "review-pr.md") }, - { target: "antigravity-cli", outputPath: join(".agents", "workflows", "review-pr.md") }, - { target: "junie", outputPath: join(".junie", "commands", "review-pr.md") }, - { target: "takt", outputPath: join(".takt", "facets", "instructions", "review-pr.md") }, - { target: "pi", outputPath: join(".pi", "prompts", "review-pr.md") }, - { target: "devin", outputPath: join(".devin", "workflows", "review-pr.md") }, - { target: "factorydroid", outputPath: join(".factory", "commands", "review-pr.md") }, - { target: "goose", outputPath: join(".goose", "recipes", "review-pr.yaml") }, - { target: "qwencode", outputPath: join(".qwen", "commands", "review-pr.md") }, - { target: "reasonix", outputPath: join(".reasonix", "commands", "review-pr.md") }, - { target: "rovodev", outputPath: join(".rovodev", "prompts", "review-pr.md") }, - ])("should generate $target commands", async ({ target, outputPath }) => { - const testDir = getTestDir(); + it("generate matrix must cover every native commands tool target", () => { + assertGenerateMatrixCoversTargets({ + processor: CommandsProcessor, + testedTargets: commandsGenerateTargets.map((e) => e.target), + }); + }); - // Setup: Create .rulesync/commands/review-pr.md - const commandContent = `--- + it.each(commandsGenerateTargets)( + "should generate $target commands", + async ({ target, outputPath }) => { + const testDir = getTestDir(); + + const commandContent = `--- description: "Review a pull request" targets: ["*"] --- Check the PR diff and provide feedback. `; - await writeFileContent( - join(testDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "review-pr.md"), - commandContent, - ); + await writeFileContent( + join(testDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "review-pr.md"), + commandContent, + ); - // Execute: Generate commands for the target - await runGenerate({ target, features: "commands" }); + await runGenerate({ target, features: "commands" }); - // Verify that the expected output file was generated - const generatedContent = await readFileContent(join(testDir, outputPath)); - expect(generatedContent).toContain("Check the PR diff and provide feedback."); - }); + const generatedContent = await readFileContent(join(testDir, outputPath)); + expect(generatedContent).toContain("Check the PR diff and provide feedback."); + }, + ); it("should generate a rovodev prompts.yml manifest alongside the content file", async () => { const testDir = getTestDir(); @@ -212,70 +262,43 @@ describe("E2E: commands (import)", () => { describe("E2E: commands (global mode)", () => { const { getProjectDir, getHomeDir } = useGlobalTestDirectories(); - it.each([ - { target: "claudecode", outputPath: join(".claude", "commands", "review-pr.md") }, - { target: "cursor", outputPath: join(".cursor", "commands", "review-pr.md") }, - { target: "augmentcode", outputPath: join(".augment", "commands", "review-pr.md") }, - { target: "opencode", outputPath: join(".config", "opencode", "commands", "review-pr.md") }, - { target: "codexcli", outputPath: join(".codex", "prompts", "review-pr.md") }, - { target: "cline", outputPath: join("Documents", "Cline", "Workflows", "review-pr.md") }, - { target: "kilo", outputPath: join(".config", "kilo", "commands", "review-pr.md") }, - { target: "junie", outputPath: join(".junie", "commands", "review-pr.md") }, - { target: "kiro-cli", outputPath: join(".kiro", "prompts", "review-pr.md") }, - { - target: "antigravity-ide", - outputPath: join(".gemini", "antigravity", "global_workflows", "review-pr.md"), - }, - { - target: "antigravity-cli", - outputPath: join(".gemini", "antigravity-cli", "global_workflows", "review-pr.md"), - }, - { - target: "takt", - outputPath: join(".takt", "facets", "instructions", "review-pr.md"), - }, - { target: "pi", outputPath: join(".pi", "agent", "prompts", "review-pr.md") }, - { - target: "devin", - outputPath: join(".codeium", "windsurf", "global_workflows", "review-pr.md"), - }, - { target: "factorydroid", outputPath: join(".factory", "commands", "review-pr.md") }, - { target: "goose", outputPath: join(".config", "goose", "recipes", "review-pr.yaml") }, - { target: "qwencode", outputPath: join(".qwen", "commands", "review-pr.md") }, - // Hermes Agent has no project-scoped command location; commands are emitted - // as Hermes skills under ~/.hermes/skills//SKILL.md (global only). - { target: "hermesagent", outputPath: join(".hermes", "skills", "review-pr", "SKILL.md") }, - { target: "reasonix", outputPath: join(".reasonix", "commands", "review-pr.md") }, - { target: "rovodev", outputPath: join(".rovodev", "prompts", "review-pr.md") }, - ])("should generate $target commands in home directory", async ({ target, outputPath }) => { - const projectDir = getProjectDir(); - const homeDir = getHomeDir(); + it("global matrix must cover every native global commands tool target", () => { + assertGenerateMatrixCoversTargets({ + processor: CommandsProcessor, + testedTargets: commandsGlobalTargets.map((e) => e.target), + global: true, + }); + }); - // Setup: Create .rulesync/commands/review-pr.md with root: true - const commandContent = `--- + it.each(commandsGlobalTargets)( + "should generate $target commands in home directory", + async ({ target, outputPath }) => { + const projectDir = getProjectDir(); + const homeDir = getHomeDir(); + + const commandContent = `--- root: true description: "Review a pull request" targets: ["*"] --- Check the PR diff and provide feedback. `; - await writeFileContent( - join(projectDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "review-pr.md"), - commandContent, - ); + await writeFileContent( + join(projectDir, RULESYNC_COMMANDS_RELATIVE_DIR_PATH, "review-pr.md"), + commandContent, + ); - // Execute: Generate commands in global mode with HOME pointed to temp dir - await runGenerate({ - target, - features: "commands", - global: true, - env: { HOME_DIR: homeDir }, - }); + await runGenerate({ + target, + features: "commands", + global: true, + env: { HOME_DIR: homeDir }, + }); - // Verify that the expected output file was generated - const generatedContent = await readFileContent(join(homeDir, outputPath)); - expect(generatedContent).toContain("Check the PR diff and provide feedback."); - }); + const generatedContent = await readFileContent(join(homeDir, outputPath)); + expect(generatedContent).toContain("Check the PR diff and provide feedback."); + }, + ); it("should ignore non-root commands in global mode", async () => { const projectDir = getProjectDir(); diff --git a/src/e2e/e2e-helper.ts b/src/e2e/e2e-helper.ts index d99a47d87..19aa87625 100644 --- a/src/e2e/e2e-helper.ts +++ b/src/e2e/e2e-helper.ts @@ -2,9 +2,10 @@ import { execFile } from "node:child_process"; import { join, resolve, sep } from "node:path"; import { promisify } from "node:util"; -import { afterEach, beforeEach } from "vitest"; +import { afterEach, beforeEach, expect } from "vitest"; import { setupTestDirectory } from "../test-utils/test-directories.js"; +import type { ToolTarget } from "../types/tool-targets.js"; // Save original working directory const originalCwd = process.cwd(); @@ -159,3 +160,56 @@ export function useGlobalTestDirectories(): { getHomeDir: () => homeDir, }; } + +type ProcessorTargets = { + getToolTargets(options?: { global?: boolean; importOnly?: boolean }): ToolTarget[]; +}; + +/** + * Asserts that an e2e generate/global happy-path matrix stays in lock-step with + * the tool targets a feature processor actually declares, so adding a tool to a + * processor without wiring it into the matrix (or dropping one) fails CI instead + * of silently eroding coverage. + * + * The declared targets (from `getToolTargets`) must partition exactly into + * `testedTargets` (tools with an entry in the matrix `it.each` dictionary) and + * `untested` (tools intentionally excluded from this matrix — e.g. tools whose + * output only exists in another scope, or that merge into a shared file). Every + * excluded tool must be listed explicitly with a reason so the omission is a + * conscious decision rather than an accidental gap. + * + * `-legacy` targets are dropped from the comparison: they are duplicate aliases + * that the same tables/generators exclude, and are never exercised end-to-end. + * + * Mirrors the "derive from the implementation, fail on drift" idiom already used + * by the TOOL_DISPLAY completeness check and the gitignore derivation. + */ +export function assertGenerateMatrixCoversTargets({ + processor, + testedTargets, + untested = [], + global = false, +}: { + processor: ProcessorTargets; + testedTargets: readonly string[]; + untested?: readonly string[]; + global?: boolean; +}): void { + const declared = processor + .getToolTargets({ global }) + .filter((target) => !target.endsWith("-legacy")); + const declaredSet = new Set(declared); + + const stray = [...testedTargets, ...untested].filter((t) => !declaredSet.has(t)).toSorted(); + expect( + stray, + `These matrix/untested entries are not declared by the processor (stale or mistyped): ${stray.join(", ")}`, + ).toEqual([]); + + const covered = new Set([...testedTargets, ...untested]); + const uncovered = declared.filter((t) => !covered.has(t)).toSorted(); + expect( + uncovered, + `These tools are declared by the processor but missing from the e2e matrix. Add each to the matrix dictionary, or to the \`untested\` list with a reason: ${uncovered.join(", ")}`, + ).toEqual([]); +} diff --git a/src/e2e/e2e-hooks.spec.ts b/src/e2e/e2e-hooks.spec.ts index 5bcccd52a..4b97a396b 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -3,8 +3,10 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { RULESYNC_HOOKS_RELATIVE_FILE_PATH } from "../constants/rulesync-paths.js"; +import { HooksProcessor } from "../features/hooks/hooks-processor.js"; import { readFileContent, writeFileContent } from "../utils/file.js"; import { + assertGenerateMatrixCoversTargets, runGenerate, runImport, useGlobalTestDirectories, @@ -24,31 +26,51 @@ function assertHookCommandsPreserved(parsed: { hooks?: unknown }): void { expect(serialized).toContain(".rulesync/hooks/audit.sh"); } +// Tools whose event mapping/serialization needs a +// bespoke assertion (vibe, devin, reasonix) live in their own standalone `it`s +// below; `hooksProjectStandaloneTargets` lists them so the completeness check +// still accounts for them. +const hooksGenerateTargets = [ + { target: "claudecode", outputPath: join(".claude", "settings.json") }, + { target: "cursor", outputPath: join(".cursor", "hooks.json") }, + { target: "opencode", outputPath: join(".opencode", "plugins", "rulesync-hooks.js") }, + { target: "kilo", outputPath: join(".kilo", "plugins", "rulesync-hooks.js") }, + { target: "codexcli", outputPath: join(".codex", "hooks.json") }, + { target: "qwencode", outputPath: join(".qwen", "settings.json") }, + { + target: "goose", + outputPath: join(".agents", "plugins", "rulesync", "hooks", "hooks.json"), + }, + { target: "copilot", outputPath: join(".github", "hooks", "copilot-hooks.json") }, + { target: "copilotcli", outputPath: join(".github", "hooks", "copilotcli-hooks.json") }, + { target: "factorydroid", outputPath: join(".factory", "hooks.json") }, + { target: "kiro", outputPath: join(".kiro", "agents", "default.json") }, + { target: "kiro-cli", outputPath: join(".kiro", "agents", "default.json") }, + { target: "kiro-ide", outputPath: join(".kiro", "hooks", "rulesync.json") }, + { target: "antigravity-ide", outputPath: join(".agents", "hooks.json") }, + { target: "antigravity-cli", outputPath: join(".agents", "hooks.json") }, + { target: "augmentcode", outputPath: join(".augment", "settings.json") }, +] as const; + +// Targets exercised by dedicated `it`s (bespoke per-tool serialization). +const hooksProjectStandaloneTargets = ["vibe", "devin", "reasonix"] as const; + describe("E2E: hooks", () => { const { getTestDir } = useTestDirectory(); - it.each([ - { target: "claudecode", outputPath: join(".claude", "settings.json") }, - { target: "cursor", outputPath: join(".cursor", "hooks.json") }, - { target: "opencode", outputPath: join(".opencode", "plugins", "rulesync-hooks.js") }, - { target: "codexcli", outputPath: join(".codex", "hooks.json") }, - { target: "qwencode", outputPath: join(".qwen", "settings.json") }, - { - target: "goose", - outputPath: join(".agents", "plugins", "rulesync", "hooks", "hooks.json"), - }, - { target: "copilot", outputPath: join(".github", "hooks", "copilot-hooks.json") }, - { target: "copilotcli", outputPath: join(".github", "hooks", "copilotcli-hooks.json") }, - { target: "factorydroid", outputPath: join(".factory", "hooks.json") }, - { target: "kiro", outputPath: join(".kiro", "agents", "default.json") }, - { target: "kiro-ide", outputPath: join(".kiro", "hooks", "rulesync.json") }, - { target: "antigravity-ide", outputPath: join(".agents", "hooks.json") }, - { target: "antigravity-cli", outputPath: join(".agents", "hooks.json") }, - { target: "augmentcode", outputPath: join(".augment", "settings.json") }, - ])("should generate $target hooks", async ({ target, outputPath }) => { + it("generate matrix must cover every native hooks tool target", () => { + assertGenerateMatrixCoversTargets({ + processor: HooksProcessor, + testedTargets: [ + ...hooksGenerateTargets.map((e) => e.target), + ...hooksProjectStandaloneTargets, + ], + }); + }); + + it.each(hooksGenerateTargets)("should generate $target hooks", async ({ target, outputPath }) => { const testDir = getTestDir(); - // Setup: Create .rulesync/hooks.json const hooksContent = JSON.stringify( { version: 1, @@ -62,10 +84,8 @@ describe("E2E: hooks", () => { ); await writeFileContent(join(testDir, RULESYNC_HOOKS_RELATIVE_FILE_PATH), hooksContent); - // Execute: Generate hooks for the target await runGenerate({ target, features: "hooks" }); - // Verify that the expected output file was generated const generatedContent = await readFileContent(join(testDir, outputPath)); if (target === "opencode") { @@ -75,6 +95,11 @@ describe("E2E: hooks", () => { expect(generatedContent).toContain('"session.idle"'); expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); + } else if (target === "kilo") { + // Kilo also emits a JavaScript plugin (.kilo/plugins/rulesync-hooks.js), + // so assert the canonical command paths survive rather than parsing JSON. + expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); + expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); } else { const parsed = JSON.parse(generatedContent); @@ -89,8 +114,10 @@ describe("E2E: hooks", () => { expect(parsed.hooks).toBeDefined(); expect(parsed.hooks.sessionStart).toBeDefined(); expect(parsed.hooks.stop).toBeDefined(); - } else if (target === "kiro") { - // Kiro CLI uses its own event names: sessionStart → agentSpawn, stop → stop + } else if (target === "kiro" || target === "kiro-cli") { + // Kiro (and the kiro-cli alias, which reuses KiroHooks) share the + // .kiro/agents/default.json agent-hook format and event mapping: + // sessionStart → agentSpawn, stop → stop. expect(parsed.hooks).toBeDefined(); expect(parsed.hooks.agentSpawn).toBeDefined(); expect(parsed.hooks.stop).toBeDefined(); @@ -527,93 +554,114 @@ describe("E2E: hooks (import)", () => { ); }); +const hooksGlobalTargets = [ + { target: "claudecode", outputPath: join(".claude", "settings.json") }, + { target: "codexcli", outputPath: join(".codex", "hooks.json") }, + { target: "qwencode", outputPath: join(".qwen", "settings.json") }, + { + target: "goose", + outputPath: join(".agents", "plugins", "rulesync", "hooks", "hooks.json"), + }, + { target: "opencode", outputPath: join(".config", "opencode", "plugins", "rulesync-hooks.js") }, + { target: "kilo", outputPath: join(".config", "kilo", "plugins", "rulesync-hooks.js") }, + { target: "factorydroid", outputPath: join(".factory", "hooks.json") }, + { target: "deepagents", outputPath: join(".deepagents", "hooks.json") }, + { target: "junie", outputPath: join(".junie", "config.json") }, + { target: "cursor", outputPath: join(".cursor", "hooks.json") }, + { target: "copilotcli", outputPath: join(".copilot", "hooks", "copilot-hooks.json") }, + { target: "antigravity-ide", outputPath: join(".gemini", "config", "hooks.json") }, + { target: "antigravity-cli", outputPath: join(".gemini", "config", "hooks.json") }, + { target: "augmentcode", outputPath: join(".augment", "settings.json") }, + { target: "kiro-ide", outputPath: join(".kiro", "hooks", "rulesync.json") }, +] as const; + +// Global targets exercised by dedicated `it`s (bespoke per-tool serialization). +const hooksGlobalStandaloneTargets = ["devin", "vibe", "hermesagent", "reasonix"] as const; + describe("E2E: hooks (global mode)", () => { const { getProjectDir, getHomeDir } = useGlobalTestDirectories(); - it.each([ - { target: "claudecode", outputPath: join(".claude", "settings.json") }, - { target: "codexcli", outputPath: join(".codex", "hooks.json") }, - { target: "qwencode", outputPath: join(".qwen", "settings.json") }, - { - target: "goose", - outputPath: join(".agents", "plugins", "rulesync", "hooks", "hooks.json"), - }, - { target: "opencode", outputPath: join(".config", "opencode", "plugins", "rulesync-hooks.js") }, - { target: "factorydroid", outputPath: join(".factory", "hooks.json") }, - { target: "deepagents", outputPath: join(".deepagents", "hooks.json") }, - { target: "junie", outputPath: join(".junie", "config.json") }, - { target: "cursor", outputPath: join(".cursor", "hooks.json") }, - { target: "copilotcli", outputPath: join(".copilot", "hooks", "copilot-hooks.json") }, - { target: "antigravity-ide", outputPath: join(".gemini", "config", "hooks.json") }, - { target: "antigravity-cli", outputPath: join(".gemini", "config", "hooks.json") }, - { target: "augmentcode", outputPath: join(".augment", "settings.json") }, - { target: "kiro-ide", outputPath: join(".kiro", "hooks", "rulesync.json") }, - ])("should generate $target hooks in home directory", async ({ target, outputPath }) => { - const projectDir = getProjectDir(); - const homeDir = getHomeDir(); + it("global matrix must cover every native global hooks tool target", () => { + assertGenerateMatrixCoversTargets({ + processor: HooksProcessor, + testedTargets: [...hooksGlobalTargets.map((e) => e.target), ...hooksGlobalStandaloneTargets], + global: true, + }); + }); - const hooksContent = JSON.stringify( - { - version: 1, - root: true, - hooks: { - sessionStart: [{ type: "command", command: ".rulesync/hooks/session-start.sh" }], - stop: [{ command: ".rulesync/hooks/audit.sh" }], + it.each(hooksGlobalTargets)( + "should generate $target hooks in home directory", + async ({ target, outputPath }) => { + const projectDir = getProjectDir(); + const homeDir = getHomeDir(); + + const hooksContent = JSON.stringify( + { + version: 1, + root: true, + hooks: { + sessionStart: [{ type: "command", command: ".rulesync/hooks/session-start.sh" }], + stop: [{ command: ".rulesync/hooks/audit.sh" }], + }, }, - }, - null, - 2, - ); - await writeFileContent(join(projectDir, RULESYNC_HOOKS_RELATIVE_FILE_PATH), hooksContent); + null, + 2, + ); + await writeFileContent(join(projectDir, RULESYNC_HOOKS_RELATIVE_FILE_PATH), hooksContent); - await runGenerate({ - target, - features: "hooks", - global: true, - env: { HOME_DIR: homeDir }, - }); + await runGenerate({ + target, + features: "hooks", + global: true, + env: { HOME_DIR: homeDir }, + }); - const generatedContent = await readFileContent(join(homeDir, outputPath)); - if (target === "opencode") { - expect(generatedContent).toContain("RulesyncHooksPlugin"); - expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); - expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); - } else if (target === "copilotcli") { - // Copilot CLI does not support the `stop` hook event, so audit.sh is - // intentionally dropped during generation. - const parsed = JSON.parse(generatedContent); - expect(parsed.hooks.sessionStart).toBeDefined(); - expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh"); - } else if (target === "junie") { - // Junie CLI supports SessionStart, UserPromptSubmit, Stop, and SessionEnd - // (PascalCase), so both `sessionStart` and `stop` (audit.sh) survive. - const parsed = JSON.parse(generatedContent); - expect(parsed.hooks.SessionStart).toBeDefined(); - expect(parsed.hooks.Stop).toBeDefined(); - expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh"); - expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/audit.sh"); - } else if (target === "antigravity-ide" || target === "antigravity-cli") { - // Antigravity nests the event map under a generated `rulesync` hook name - // and supports preToolUse/postToolUse/preModelInvocation/ - // postModelInvocation/stop, so `sessionStart` is dropped and only audit.sh - // (mapped to `Stop`) survives generation. - const parsed = JSON.parse(generatedContent); - expect(parsed.rulesync.Stop).toBeDefined(); - expect(JSON.stringify(parsed)).toContain(".rulesync/hooks/audit.sh"); - } else if (target === "qwencode") { - // Qwen Code emits Claude-style PascalCase event names under the `hooks` - // key of .qwen/settings.json: canonical `sessionStart` → `SessionStart`, - // `stop` → `Stop`. See CANONICAL_TO_QWENCODE_EVENT_NAMES in - // src/types/hooks.ts. - const parsed = JSON.parse(generatedContent); - expect(parsed.hooks.SessionStart).toBeDefined(); - expect(parsed.hooks.Stop).toBeDefined(); - expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh"); - expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/audit.sh"); - } else { - assertHookCommandsPreserved(JSON.parse(generatedContent)); - } - }); + const generatedContent = await readFileContent(join(homeDir, outputPath)); + if (target === "opencode") { + expect(generatedContent).toContain("RulesyncHooksPlugin"); + expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); + expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); + } else if (target === "kilo") { + // Kilo's JS plugin differs from OpenCode's shape; assert command paths. + expect(generatedContent).toContain(".rulesync/hooks/session-start.sh"); + expect(generatedContent).toContain(".rulesync/hooks/audit.sh"); + } else if (target === "copilotcli") { + // Copilot CLI does not support the `stop` hook event, so audit.sh is + // intentionally dropped during generation. + const parsed = JSON.parse(generatedContent); + expect(parsed.hooks.sessionStart).toBeDefined(); + expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh"); + } else if (target === "junie") { + // Junie CLI supports SessionStart, UserPromptSubmit, Stop, and SessionEnd + // (PascalCase), so both `sessionStart` and `stop` (audit.sh) survive. + const parsed = JSON.parse(generatedContent); + expect(parsed.hooks.SessionStart).toBeDefined(); + expect(parsed.hooks.Stop).toBeDefined(); + expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh"); + expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/audit.sh"); + } else if (target === "antigravity-ide" || target === "antigravity-cli") { + // Antigravity nests the event map under a generated `rulesync` hook name + // and supports preToolUse/postToolUse/preModelInvocation/ + // postModelInvocation/stop, so `sessionStart` is dropped and only audit.sh + // (mapped to `Stop`) survives generation. + const parsed = JSON.parse(generatedContent); + expect(parsed.rulesync.Stop).toBeDefined(); + expect(JSON.stringify(parsed)).toContain(".rulesync/hooks/audit.sh"); + } else if (target === "qwencode") { + // Qwen Code emits Claude-style PascalCase event names under the `hooks` + // key of .qwen/settings.json: canonical `sessionStart` → `SessionStart`, + // `stop` → `Stop`. See CANONICAL_TO_QWENCODE_EVENT_NAMES in + // src/types/hooks.ts. + const parsed = JSON.parse(generatedContent); + expect(parsed.hooks.SessionStart).toBeDefined(); + expect(parsed.hooks.Stop).toBeDefined(); + expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh"); + expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/audit.sh"); + } else { + assertHookCommandsPreserved(JSON.parse(generatedContent)); + } + }, + ); it("should generate devin hooks in home directory", async () => { const projectDir = getProjectDir(); diff --git a/src/e2e/e2e-ignore.spec.ts b/src/e2e/e2e-ignore.spec.ts index 954f0f4db..d5fc54f37 100644 --- a/src/e2e/e2e-ignore.spec.ts +++ b/src/e2e/e2e-ignore.spec.ts @@ -4,73 +4,88 @@ import { describe, expect, it } from "vitest"; import { KIRO_IGNORE_FILE_NAME } from "../constants/kiro-paths.js"; import { RULESYNC_AIIGNORE_RELATIVE_FILE_PATH } from "../constants/rulesync-paths.js"; +import { IgnoreProcessor } from "../features/ignore/ignore-processor.js"; import { readFileContent, writeFileContent } from "../utils/file.js"; -import { runGenerate, runImport, useTestDirectory } from "./e2e-helper.js"; +import { + assertGenerateMatrixCoversTargets, + runGenerate, + runImport, + useTestDirectory, +} from "./e2e-helper.js"; + +const ignoreGenerateTargets = [ + { target: "cursor", outputPath: ".cursorignore", format: "plaintext" as const }, + { + target: "claudecode", + outputPath: join(".claude", "settings.json"), + format: "json" as const, + }, + { target: "antigravity-cli", outputPath: ".geminiignore", format: "plaintext" as const }, + { target: "goose", outputPath: ".gooseignore", format: "plaintext" as const }, + { target: "cline", outputPath: ".clineignore", format: "plaintext" as const }, + { target: "kilo", outputPath: ".kilocodeignore", format: "plaintext" as const }, + { target: "roo", outputPath: ".rooignore", format: "plaintext" as const }, + { target: "qwencode", outputPath: ".qwenignore", format: "plaintext" as const }, + { target: "kiro", outputPath: KIRO_IGNORE_FILE_NAME, format: "plaintext" as const }, + { target: "kiro-cli", outputPath: KIRO_IGNORE_FILE_NAME, format: "plaintext" as const }, + { target: "kiro-ide", outputPath: KIRO_IGNORE_FILE_NAME, format: "plaintext" as const }, + { target: "junie", outputPath: ".aiignore", format: "plaintext" as const }, + { target: "aiassistant", outputPath: ".aiignore", format: "plaintext" as const }, + { target: "augmentcode", outputPath: ".augmentignore", format: "plaintext" as const }, + { target: "devin", outputPath: ".devinignore", format: "plaintext" as const }, + { + target: "zed", + outputPath: join(".zed", "settings.json"), + format: "json" as const, + }, + { target: "vibe", outputPath: ".vibeignore", format: "plaintext" as const }, + { target: "warp", outputPath: ".warpindexingignore", format: "plaintext" as const }, +] as const; describe("E2E: ignore", () => { const { getTestDir } = useTestDirectory(); - it.each([ - { target: "cursor", outputPath: ".cursorignore", format: "plaintext" as const }, - { - target: "claudecode", - outputPath: join(".claude", "settings.json"), - format: "json" as const, - }, - { target: "antigravity-cli", outputPath: ".geminiignore", format: "plaintext" as const }, - { target: "goose", outputPath: ".gooseignore", format: "plaintext" as const }, - { target: "cline", outputPath: ".clineignore", format: "plaintext" as const }, - { target: "kilo", outputPath: ".kilocodeignore", format: "plaintext" as const }, - { target: "roo", outputPath: ".rooignore", format: "plaintext" as const }, - { target: "qwencode", outputPath: ".qwenignore", format: "plaintext" as const }, - { target: "kiro", outputPath: KIRO_IGNORE_FILE_NAME, format: "plaintext" as const }, - { target: "kiro-cli", outputPath: KIRO_IGNORE_FILE_NAME, format: "plaintext" as const }, - { target: "kiro-ide", outputPath: KIRO_IGNORE_FILE_NAME, format: "plaintext" as const }, - { target: "junie", outputPath: ".aiignore", format: "plaintext" as const }, - { target: "aiassistant", outputPath: ".aiignore", format: "plaintext" as const }, - { target: "augmentcode", outputPath: ".augmentignore", format: "plaintext" as const }, - { target: "devin", outputPath: ".devinignore", format: "plaintext" as const }, - { - target: "zed", - outputPath: join(".zed", "settings.json"), - format: "json" as const, - }, - { target: "vibe", outputPath: ".vibeignore", format: "plaintext" as const }, - { target: "warp", outputPath: ".warpindexingignore", format: "plaintext" as const }, - ])("should generate $target ignore", async ({ target, outputPath, format }) => { - const testDir = getTestDir(); + it("generate matrix must cover every native ignore tool target", () => { + assertGenerateMatrixCoversTargets({ + processor: IgnoreProcessor, + testedTargets: ignoreGenerateTargets.map((e) => e.target), + }); + }); - // Setup: Create .rulesync/.aiignore - const ignoreContent = `tmp/ + it.each(ignoreGenerateTargets)( + "should generate $target ignore", + async ({ target, outputPath, format }) => { + const testDir = getTestDir(); + + const ignoreContent = `tmp/ credentials/ *.secret `; - await writeFileContent(join(testDir, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH), ignoreContent); - - // Execute: Generate ignore for the target - await runGenerate({ target, features: "ignore" }); - - // Verify that the expected output file was generated - const generatedContent = await readFileContent(join(testDir, outputPath)); - if (format === "plaintext") { - expect(generatedContent).toContain("tmp/"); - expect(generatedContent).toContain("credentials/"); - } else if (format === "json" && target === "claudecode") { - // Claude Code uses JSON format with permissions.deny - const parsed = JSON.parse(generatedContent); - expect(parsed.permissions.deny).toBeDefined(); - expect(parsed.permissions.deny).toEqual( - expect.arrayContaining([expect.stringContaining("tmp/")]), - ); - } else if (format === "json" && target === "zed") { - // Zed uses JSON format with private_files - const parsed = JSON.parse(generatedContent); - expect(parsed.private_files).toBeDefined(); - expect(parsed.private_files).toEqual( - expect.arrayContaining([expect.stringContaining("tmp/")]), - ); - } - }); + await writeFileContent(join(testDir, RULESYNC_AIIGNORE_RELATIVE_FILE_PATH), ignoreContent); + + await runGenerate({ target, features: "ignore" }); + + const generatedContent = await readFileContent(join(testDir, outputPath)); + if (format === "plaintext") { + expect(generatedContent).toContain("tmp/"); + expect(generatedContent).toContain("credentials/"); + } else if (format === "json" && target === "claudecode") { + // Claude Code uses JSON format with permissions.deny + const parsed = JSON.parse(generatedContent); + expect(parsed.permissions.deny).toBeDefined(); + expect(parsed.permissions.deny).toEqual( + expect.arrayContaining([expect.stringContaining("tmp/")]), + ); + } else if (format === "json" && target === "zed") { + // Zed uses JSON format with private_files + const parsed = JSON.parse(generatedContent); + expect(parsed.private_files).toBeDefined(); + expect(parsed.private_files).toEqual( + expect.arrayContaining([expect.stringContaining("tmp/")]), + ); + } + }, + ); it.each([ { target: "cursor", orphanPath: ".cursorignore" }, diff --git a/src/e2e/e2e-mcp.spec.ts b/src/e2e/e2e-mcp.spec.ts index 1499cb1e4..d9c9fe2ea 100644 --- a/src/e2e/e2e-mcp.spec.ts +++ b/src/e2e/e2e-mcp.spec.ts @@ -10,8 +10,10 @@ import { RULESYNC_MCP_RELATIVE_FILE_PATH, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH, } from "../constants/rulesync-paths.js"; +import { McpProcessor } from "../features/mcp/mcp-processor.js"; import { fileExists, readFileContent, writeFileContent } from "../utils/file.js"; import { + assertGenerateMatrixCoversTargets, runGenerate, runImport, rulesyncArgs, @@ -20,37 +22,52 @@ import { useTestDirectory, } from "./e2e-helper.js"; +// Native MCP tools that emit "test-server" (takt writes a transport allowlist instead). +const mcpGenerateTargets = [ + { target: "amp", outputPath: join(".amp", "settings.json") }, + { target: "claudecode", outputPath: ".mcp.json" }, + { target: "cursor", outputPath: join(".cursor", "mcp.json") }, + { target: "qwencode", outputPath: join(".qwen", "settings.json") }, + { target: "codexcli", outputPath: join(".codex", "config.toml") }, + { target: "grokcli", outputPath: join(".grok", "config.toml") }, + { target: "copilot", outputPath: join(".vscode", "mcp.json") }, + { target: "copilotcli", outputPath: join(".github", "mcp.json") }, + { target: "opencode", outputPath: "opencode.jsonc" }, + { target: "deepagents", outputPath: join(".deepagents", ".mcp.json") }, + { target: "factorydroid", outputPath: join(".factory", "mcp.json") }, + { target: "goose", outputPath: join(".agents", "plugins", "rulesync", ".mcp.json") }, + { target: "kilo", outputPath: "kilo.jsonc" }, + { target: "roo", outputPath: join(".roo", "mcp.json") }, + { target: "kiro", outputPath: join(".kiro", "settings", "mcp.json") }, + { target: "kiro-cli", outputPath: join(".kiro", "settings", "mcp.json") }, + { target: "kiro-ide", outputPath: join(".kiro", "settings", "mcp.json") }, + { target: "junie", outputPath: join(".junie", "mcp", "mcp.json") }, + { target: "antigravity-ide", outputPath: join(".agents", "mcp_config.json") }, + { target: "antigravity-cli", outputPath: join(".agents", "mcp_config.json") }, + { target: "warp", outputPath: join(".warp", ".mcp.json") }, + { target: "zed", outputPath: join(".zed", "settings.json") }, + { target: "devin", outputPath: join(".devin", "config.json") }, + { target: "vibe", outputPath: join(".vibe", "config.toml") }, + { target: "reasonix", outputPath: "reasonix.toml" }, +] as const; + describe("E2E: mcp", () => { const { getTestDir } = useTestDirectory(); - it.each([ - { target: "amp", outputPath: join(".amp", "settings.json") }, - { target: "claudecode", outputPath: ".mcp.json" }, - { target: "cursor", outputPath: join(".cursor", "mcp.json") }, - { target: "qwencode", outputPath: join(".qwen", "settings.json") }, - { target: "codexcli", outputPath: join(".codex", "config.toml") }, - { target: "grokcli", outputPath: join(".grok", "config.toml") }, - { target: "copilot", outputPath: join(".vscode", "mcp.json") }, - { target: "copilotcli", outputPath: join(".github", "mcp.json") }, - { target: "opencode", outputPath: "opencode.jsonc" }, - { target: "deepagents", outputPath: join(".deepagents", ".mcp.json") }, - { target: "factorydroid", outputPath: join(".factory", "mcp.json") }, - { target: "goose", outputPath: join(".agents", "plugins", "rulesync", ".mcp.json") }, - { target: "kilo", outputPath: "kilo.jsonc" }, - { target: "roo", outputPath: join(".roo", "mcp.json") }, - { target: "kiro", outputPath: join(".kiro", "settings", "mcp.json") }, - { target: "junie", outputPath: join(".junie", "mcp", "mcp.json") }, - { target: "antigravity-ide", outputPath: join(".agents", "mcp_config.json") }, - { target: "antigravity-cli", outputPath: join(".agents", "mcp_config.json") }, - { target: "warp", outputPath: join(".warp", ".mcp.json") }, - { target: "zed", outputPath: join(".zed", "settings.json") }, - { target: "devin", outputPath: join(".devin", "config.json") }, - { target: "vibe", outputPath: join(".vibe", "config.toml") }, - { target: "reasonix", outputPath: "reasonix.toml" }, - ])("should generate $target mcp", async ({ target, outputPath }) => { + it("generate matrix must cover every native mcp tool target", () => { + assertGenerateMatrixCoversTargets({ + processor: McpProcessor, + testedTargets: mcpGenerateTargets.map((e) => e.target), + // takt only writes a transport allowlist to .takt/config.yaml (no + // "test-server" entry), so it is covered by its own dedicated test + // "should generate Takt MCP transport allowlist into .takt/config.yaml". + untested: ["takt"], + }); + }); + + it.each(mcpGenerateTargets)("should generate $target mcp", async ({ target, outputPath }) => { const testDir = getTestDir(); - // Setup: Create .rulesync/mcp.json with a test MCP server const mcpContent = JSON.stringify( { mcpServers: { @@ -68,10 +85,8 @@ describe("E2E: mcp", () => { ); await writeFileContent(join(testDir, RULESYNC_MCP_RELATIVE_FILE_PATH), mcpContent); - // Execute: Generate mcp for the target await runGenerate({ target, features: "mcp" }); - // Verify that the expected output file was generated and contains the server const generatedContent = await readFileContent(join(testDir, outputPath)); expect(generatedContent).toContain("test-server"); }); @@ -528,84 +543,99 @@ describe("E2E: mcp (import)", () => { }); }); +// Native global-scope MCP tools that emit "test-server" (takt writes a transport allowlist instead). +const mcpGlobalTargets = [ + { target: "augmentcode", outputPath: join(".augment", "settings.json") }, + { target: "claudecode", outputPath: ".claude.json" }, + { target: "cursor", outputPath: join(".cursor", "mcp.json") }, + { target: "qwencode", outputPath: join(".qwen", "settings.json") }, + { target: "goose", outputPath: join(".config", "goose", "config.yaml") }, + { target: "hermesagent", outputPath: join(".hermes", "config.yaml") }, + { target: "opencode", outputPath: join(".config", "opencode", "opencode.jsonc") }, + { target: "codexcli", outputPath: join(".codex", "config.toml") }, + { target: "grokcli", outputPath: join(".grok", "config.toml") }, + { target: "copilotcli", outputPath: join(".copilot", "mcp-config.json") }, + { target: "deepagents", outputPath: join(".deepagents", ".mcp.json") }, + { target: "factorydroid", outputPath: join(".factory", "mcp.json") }, + { target: "rovodev", outputPath: join(".rovodev", "mcp.json") }, + { + target: "cline", + outputPath: join(".cline", "data", "settings", "cline_mcp_settings.json"), + }, + { target: "kilo", outputPath: join(".config", "kilo", "kilo.jsonc") }, + { target: "junie", outputPath: join(".junie", "mcp", "mcp.json") }, + { target: "amp", outputPath: join(".config", "amp", "settings.json") }, + { + target: "antigravity-ide", + outputPath: join(".gemini", "config", "mcp_config.json"), + }, + { + target: "antigravity-cli", + outputPath: join(".gemini", "config", "mcp_config.json"), + }, + { target: "warp", outputPath: join(".warp", ".mcp.json") }, + { target: "zed", outputPath: join(".config", "zed", "settings.json") }, + { + target: "devin", + outputPath: join(".config", "devin", "config.json"), + }, + { target: "vibe", outputPath: join(".vibe", "config.toml") }, + { target: "reasonix", outputPath: join(".reasonix", "config.toml") }, + { target: "kiro", outputPath: join(".kiro", "settings", "mcp.json") }, + { target: "kiro-cli", outputPath: join(".kiro", "settings", "mcp.json") }, + { target: "kiro-ide", outputPath: join(".kiro", "settings", "mcp.json") }, +] as const; + describe("E2E: mcp (global mode)", () => { const { getProjectDir, getHomeDir } = useGlobalTestDirectories(); - it.each([ - { target: "augmentcode", outputPath: join(".augment", "settings.json") }, - { target: "claudecode", outputPath: ".claude.json" }, - { target: "cursor", outputPath: join(".cursor", "mcp.json") }, - { target: "qwencode", outputPath: join(".qwen", "settings.json") }, - { target: "goose", outputPath: join(".config", "goose", "config.yaml") }, - { target: "hermesagent", outputPath: join(".hermes", "config.yaml") }, - { target: "opencode", outputPath: join(".config", "opencode", "opencode.jsonc") }, - { target: "codexcli", outputPath: join(".codex", "config.toml") }, - { target: "grokcli", outputPath: join(".grok", "config.toml") }, - { target: "copilotcli", outputPath: join(".copilot", "mcp-config.json") }, - { target: "deepagents", outputPath: join(".deepagents", ".mcp.json") }, - { target: "factorydroid", outputPath: join(".factory", "mcp.json") }, - { target: "rovodev", outputPath: join(".rovodev", "mcp.json") }, - { - target: "cline", - outputPath: join(".cline", "data", "settings", "cline_mcp_settings.json"), - }, - { target: "kilo", outputPath: join(".config", "kilo", "kilo.jsonc") }, - { target: "junie", outputPath: join(".junie", "mcp", "mcp.json") }, - { target: "amp", outputPath: join(".config", "amp", "settings.json") }, - { - target: "antigravity-ide", - outputPath: join(".gemini", "config", "mcp_config.json"), - }, - { - target: "antigravity-cli", - outputPath: join(".gemini", "config", "mcp_config.json"), - }, - { target: "warp", outputPath: join(".warp", ".mcp.json") }, - { target: "zed", outputPath: join(".config", "zed", "settings.json") }, - { - target: "devin", - outputPath: join(".config", "devin", "config.json"), - }, - { target: "vibe", outputPath: join(".vibe", "config.toml") }, - { target: "reasonix", outputPath: join(".reasonix", "config.toml") }, - { target: "kiro", outputPath: join(".kiro", "settings", "mcp.json") }, - { target: "kiro-cli", outputPath: join(".kiro", "settings", "mcp.json") }, - { target: "kiro-ide", outputPath: join(".kiro", "settings", "mcp.json") }, - ])("should generate $target mcp in home directory", async ({ target, outputPath }) => { - const projectDir = getProjectDir(); - const homeDir = getHomeDir(); + it("generate matrix must cover every native mcp tool target in global mode", () => { + assertGenerateMatrixCoversTargets({ + processor: McpProcessor, + testedTargets: mcpGlobalTargets.map((e) => e.target), + global: true, + // takt only writes a transport allowlist to ~/.takt/config.yaml (no + // "test-server" entry), so it is covered by its own dedicated global test + // "should generate Takt MCP transport allowlist into ~/.takt/config.yaml (global)". + untested: ["takt"], + }); + }); - // Setup: Create .rulesync/mcp.json with root: true and a test MCP server - const mcpContent = JSON.stringify( - { - root: true, - mcpServers: { - "test-server": { - description: "Test MCP server", - type: "stdio", - command: "echo", - args: ["hello"], - env: {}, + it.each(mcpGlobalTargets)( + "should generate $target mcp in home directory", + async ({ target, outputPath }) => { + const projectDir = getProjectDir(); + const homeDir = getHomeDir(); + + const mcpContent = JSON.stringify( + { + root: true, + mcpServers: { + "test-server": { + description: "Test MCP server", + type: "stdio", + command: "echo", + args: ["hello"], + env: {}, + }, }, }, - }, - null, - 2, - ); - await writeFileContent(join(projectDir, RULESYNC_MCP_RELATIVE_FILE_PATH), mcpContent); + null, + 2, + ); + await writeFileContent(join(projectDir, RULESYNC_MCP_RELATIVE_FILE_PATH), mcpContent); - // Execute: Generate mcp in global mode with HOME pointed to temp dir - await runGenerate({ - target, - features: "mcp", - global: true, - env: { HOME_DIR: homeDir }, - }); + await runGenerate({ + target, + features: "mcp", + global: true, + env: { HOME_DIR: homeDir }, + }); - // Verify that the expected output file was generated and contains the server - const generatedContent = await readFileContent(join(homeDir, outputPath)); - expect(generatedContent).toContain("test-server"); - }); + const generatedContent = await readFileContent(join(homeDir, outputPath)); + expect(generatedContent).toContain("test-server"); + }, + ); it("should generate Takt MCP transport allowlist into ~/.takt/config.yaml (global)", async () => { const projectDir = getProjectDir(); diff --git a/src/e2e/e2e-permissions.spec.ts b/src/e2e/e2e-permissions.spec.ts index c2381954a..ca95a12c2 100644 --- a/src/e2e/e2e-permissions.spec.ts +++ b/src/e2e/e2e-permissions.spec.ts @@ -5,17 +5,135 @@ import * as smolToml from "smol-toml"; import { describe, expect, it } from "vitest"; import { RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH } from "../constants/rulesync-paths.js"; +import { PermissionsProcessor } from "../features/permissions/permissions-processor.js"; import { readFileContent, writeFileContent } from "../utils/file.js"; import { + assertGenerateMatrixCoversTargets, runGenerate, runImport, useGlobalTestDirectories, useTestDirectory, } from "./e2e-helper.js"; +// Permissions targets exercised by the project-scope generate `it`s below. Each +// tool has a bespoke serialization, so tests stay hand-written rather than +// table-driven; this explicit list feeds the completeness check so a new +// project-scope permissions tool cannot be added without a matching e2e test. +const permissionsGenerateTargets = [ + "opencode", + "zed", + "amp", + "devin", + "codexcli", + "junie", + "cursor", + "kiro", + "kiro-cli", + "kiro-ide", + "kilo", + "antigravity-ide", + "augmentcode", + "cline", + "factorydroid", + "qwencode", + "vibe", + "reasonix", + "takt", + "claudecode", +] as const; + +// Permissions targets exercised by the global-scope generate `it`s below. +const permissionsGlobalTargets = [ + "claudecode", + "opencode", + "codexcli", + "cursor", + "kilo", + "augmentcode", + "qwencode", + "antigravity-cli", + "warp", + "zed", + "amp", + "vibe", + "rovodev", + "goose", + "grokcli", + "takt", + "hermesagent", + "reasonix", + "devin", + "factorydroid", + "junie", +] as const; + describe("E2E: permissions", () => { const { getTestDir } = useTestDirectory(); + it("generate matrix must cover every native permissions tool target", () => { + assertGenerateMatrixCoversTargets({ + processor: PermissionsProcessor, + testedTargets: permissionsGenerateTargets, + }); + }); + + it("should generate claudecode permissions into .claude/settings.json", async () => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH), + JSON.stringify( + { + permission: { + bash: { "git status *": "allow", "rm *": "deny" }, + read: { ".env": "deny" }, + }, + }, + null, + 2, + ), + ); + + await runGenerate({ target: "claudecode", features: "permissions" }); + + const content = JSON.parse(await readFileContent(join(testDir, ".claude", "settings.json"))); + expect(content.permissions.allow).toContain("Bash(git status *)"); + expect(content.permissions.deny).toContain("Bash(rm *)"); + expect(content.permissions.deny).toContain("Read(.env)"); + }); + + it.each([{ target: "kiro-cli" }, { target: "kiro-ide" }])( + "should generate $target permissions into .kiro/agents/default.json", + async ({ target }) => { + const testDir = getTestDir(); + + await writeFileContent( + join(testDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH), + JSON.stringify( + { + permission: { + bash: { "git *": "allow", "rm *": "deny" }, + read: { "src/**": "allow" }, + }, + }, + null, + 2, + ), + ); + + // kiro-cli and kiro-ide reuse the same .kiro/agents/default.json format as + // the kiro alias. + await runGenerate({ target, features: "permissions" }); + + const content = JSON.parse( + await readFileContent(join(testDir, ".kiro", "agents", "default.json")), + ); + expect(content.toolsSettings.shell.allowedCommands).toContain("git *"); + expect(content.toolsSettings.shell.deniedCommands).toContain("rm *"); + expect(content.toolsSettings.read.allowedPaths).toContain("src/**"); + }, + ); + it("should generate opencode permissions from .rulesync/permissions.json", async () => { const testDir = getTestDir(); @@ -971,6 +1089,53 @@ enabled = true describe("E2E: permissions (global mode)", () => { const { getProjectDir, getHomeDir } = useGlobalTestDirectories(); + it("global matrix must cover every native global permissions tool target", () => { + assertGenerateMatrixCoversTargets({ + processor: PermissionsProcessor, + testedTargets: permissionsGlobalTargets, + global: true, + }); + }); + + it.each([ + { target: "devin", outputPath: join(".config", "devin", "config.json") }, + { target: "factorydroid", outputPath: join(".factory", "settings.json") }, + { target: "junie", outputPath: join(".junie", "allowlist.json") }, + ])( + "should generate $target permissions in home directory with --global", + async ({ target, outputPath }) => { + const projectDir = getProjectDir(); + const homeDir = getHomeDir(); + + await writeFileContent( + join(projectDir, RULESYNC_PERMISSIONS_RELATIVE_FILE_PATH), + JSON.stringify( + { + root: true, + permission: { + bash: { "git status *": "allow", "rm *": "deny" }, + }, + }, + null, + 2, + ), + ); + + await runGenerate({ + target, + features: "permissions", + global: true, + env: { HOME_DIR: homeDir }, + }); + + // Event mapping/serialization differs per tool, so assert the canonical + // command patterns survive somewhere in the generated file. + const generated = await readFileContent(join(homeDir, outputPath)); + expect(generated).toContain("git status *"); + expect(generated).toContain("rm *"); + }, + ); + it("should generate claudecode permissions in home directory with --global", async () => { const projectDir = getProjectDir(); const homeDir = getHomeDir(); diff --git a/src/e2e/e2e-rules.spec.ts b/src/e2e/e2e-rules.spec.ts index 8e337b302..b2d473f38 100644 --- a/src/e2e/e2e-rules.spec.ts +++ b/src/e2e/e2e-rules.spec.ts @@ -8,48 +8,74 @@ import { RULESYNC_OVERVIEW_FILE_NAME, RULESYNC_RULES_RELATIVE_DIR_PATH, } from "../constants/rulesync-paths.js"; +import { RulesProcessor } from "../features/rules/rules-processor.js"; import { fileExists, readFileContent, writeFileContent } from "../utils/file.js"; import { + assertGenerateMatrixCoversTargets, runGenerate, runImport, useGlobalTestDirectories, useTestDirectory, } from "./e2e-helper.js"; +// Tools whose root rule lands in a single memory file. +const rulesRootTargets = [ + { target: "claudecode", outputPath: "CLAUDE.md" }, + { target: "cursor", outputPath: join(".cursor", "rules", "overview.mdc") }, + { target: "aiassistant", outputPath: join(".aiassistant", "rules", "overview.md") }, + { target: "amp", outputPath: "AGENTS.md" }, + { target: "codexcli", outputPath: "AGENTS.md" }, + { target: "grokcli", outputPath: "AGENTS.md" }, + { target: "hermesagent", outputPath: ".hermes.md" }, + { target: "copilot", outputPath: join(".github", "copilot-instructions.md") }, + { target: "opencode", outputPath: "AGENTS.md" }, + { target: "antigravity-cli", outputPath: "AGENTS.md" }, + { target: "antigravity-ide", outputPath: "AGENTS.md" }, + { target: "goose", outputPath: ".goosehints" }, + { target: "copilotcli", outputPath: join(".github", "copilot-instructions.md") }, + { target: "kilo", outputPath: "AGENTS.md" }, + { target: "agentsmd", outputPath: "AGENTS.md" }, + { target: "factorydroid", outputPath: "AGENTS.md" }, + { target: "deepagents", outputPath: join(".deepagents", "AGENTS.md") }, + { target: "rovodev", outputPath: join(".rovodev", "AGENTS.md") }, + { target: "qwencode", outputPath: "QWEN.md" }, + { target: "junie", outputPath: join(".junie", "AGENTS.md") }, + { target: "warp", outputPath: "AGENTS.md" }, + { target: "replit", outputPath: "replit.md" }, + { target: "pi", outputPath: "AGENTS.md" }, + { target: "zed", outputPath: ".rules" }, + { target: "vibe", outputPath: "AGENTS.md" }, +] as const; + +// Tools that emit every rule as a directory entry. +const rulesNonRootTargets = [ + { target: "cline", outputPath: join(".clinerules", "overview.md") }, + { target: "roo", outputPath: join(".roo", "rules", "overview.md") }, + { target: "kiro", outputPath: join(".kiro", "steering", "overview.md") }, + { target: "kiro-cli", outputPath: join(".kiro", "steering", "overview.md") }, + { target: "kiro-ide", outputPath: join(".kiro", "steering", "overview.md") }, + { target: "antigravity-ide", outputPath: join(".agents", "rules", "overview.md") }, + { target: "augmentcode", outputPath: join(".augment", "rules", "overview.md") }, + { target: "devin", outputPath: join(".devin", "rules", "overview.md") }, + { target: "takt", outputPath: join(".takt", "facets", "policies", "overview.md") }, +] as const; + describe("E2E: rules", () => { const { getTestDir } = useTestDirectory(); - // Both codexcli and opencode generate AGENTS.md as their root rule output - it.each([ - { target: "claudecode", outputPath: "CLAUDE.md" }, - { target: "cursor", outputPath: join(".cursor", "rules", "overview.mdc") }, - { target: "aiassistant", outputPath: join(".aiassistant", "rules", "overview.md") }, - { target: "amp", outputPath: "AGENTS.md" }, - { target: "codexcli", outputPath: "AGENTS.md" }, - { target: "grokcli", outputPath: "AGENTS.md" }, - { target: "hermesagent", outputPath: ".hermes.md" }, - { target: "copilot", outputPath: join(".github", "copilot-instructions.md") }, - { target: "opencode", outputPath: "AGENTS.md" }, - { target: "antigravity-cli", outputPath: "AGENTS.md" }, - { target: "antigravity-ide", outputPath: "AGENTS.md" }, - { target: "goose", outputPath: ".goosehints" }, - { target: "copilotcli", outputPath: join(".github", "copilot-instructions.md") }, - { target: "kilo", outputPath: "AGENTS.md" }, - { target: "agentsmd", outputPath: "AGENTS.md" }, - { target: "factorydroid", outputPath: "AGENTS.md" }, - { target: "deepagents", outputPath: join(".deepagents", "AGENTS.md") }, - { target: "rovodev", outputPath: join(".rovodev", "AGENTS.md") }, - { target: "qwencode", outputPath: "QWEN.md" }, - { target: "junie", outputPath: join(".junie", "AGENTS.md") }, - { target: "warp", outputPath: "AGENTS.md" }, - { target: "replit", outputPath: "replit.md" }, - { target: "pi", outputPath: "AGENTS.md" }, - { target: "zed", outputPath: ".rules" }, - { target: "vibe", outputPath: "AGENTS.md" }, - ])("should generate $target rules", async ({ target, outputPath }) => { + it("generate matrix must cover every native rules tool target", () => { + // antigravity-ide appears in both matrices (root AGENTS.md vs .agents/rules), + // so the union of the two matrices is the set of natively-tested tools. + const tested = [ + ...rulesRootTargets.map((e) => e.target), + ...rulesNonRootTargets.map((e) => e.target), + ]; + assertGenerateMatrixCoversTargets({ processor: RulesProcessor, testedTargets: tested }); + }); + + it.each(rulesRootTargets)("should generate $target rules", async ({ target, outputPath }) => { const testDir = getTestDir(); - // Setup: Create necessary directories and a sample rule file const ruleContent = `--- root: true targets: ["*"] @@ -66,29 +92,18 @@ This is a test rule for E2E testing. ruleContent, ); - // Execute: Generate rules for the target await runGenerate({ target, features: "rules" }); - // Verify that the expected output file was generated const generatedContent = await readFileContent(join(testDir, outputPath)); expect(generatedContent).toContain("Test Rule"); }); - it.each([ - { target: "cline", outputPath: join(".clinerules", "overview.md") }, - { target: "roo", outputPath: join(".roo", "rules", "overview.md") }, - { target: "kiro", outputPath: join(".kiro", "steering", "overview.md") }, - { target: "kiro-cli", outputPath: join(".kiro", "steering", "overview.md") }, - { target: "kiro-ide", outputPath: join(".kiro", "steering", "overview.md") }, - { target: "antigravity-ide", outputPath: join(".agents", "rules", "overview.md") }, - { target: "augmentcode", outputPath: join(".augment", "rules", "overview.md") }, - { target: "devin", outputPath: join(".devin", "rules", "overview.md") }, - { target: "takt", outputPath: join(".takt", "facets", "policies", "overview.md") }, - ])("should generate $target rules (non-root)", async ({ target, outputPath }) => { - const testDir = getTestDir(); + it.each(rulesNonRootTargets)( + "should generate $target rules (non-root)", + async ({ target, outputPath }) => { + const testDir = getTestDir(); - // Setup: Create a non-root rule file - const ruleContent = `--- + const ruleContent = `--- targets: ["*"] description: "Test rule" globs: ["src/**/*"] @@ -98,18 +113,17 @@ globs: ["src/**/*"] This is a test rule for E2E testing. `; - await writeFileContent( - join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME), - ruleContent, - ); + await writeFileContent( + join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME), + ruleContent, + ); - // Execute: Generate rules for the target - await runGenerate({ target, features: "rules" }); + await runGenerate({ target, features: "rules" }); - // Verify that the expected output file was generated - const generatedContent = await readFileContent(join(testDir, outputPath)); - expect(generatedContent).toContain("Test Rule"); - }); + const generatedContent = await readFileContent(join(testDir, outputPath)); + expect(generatedContent).toContain("Test Rule"); + }, + ); it("should fold pi non-root rules into the root AGENTS.md", async () => { const testDir = getTestDir(); @@ -644,45 +658,59 @@ This is a test project for E2E testing. }); }); +const rulesGlobalTargets = [ + { target: "claudecode", outputPath: join(".claude", "CLAUDE.md") }, + { target: "copilot", outputPath: join(".copilot", "copilot-instructions.md") }, + { target: "opencode", outputPath: join(".config", "opencode", "AGENTS.md") }, + { target: "codexcli", outputPath: join(".codex", "AGENTS.md") }, + { target: "grokcli", outputPath: join(".grok", "AGENTS.md") }, + { target: "amp", outputPath: join(".config", "amp", "AGENTS.md") }, + { target: "cline", outputPath: join(".agents", "AGENTS.md") }, + { target: "antigravity-ide", outputPath: join(".gemini", "GEMINI.md") }, + { target: "antigravity-cli", outputPath: join(".gemini", "GEMINI.md") }, + { target: "goose", outputPath: join(".config", "goose", ".goosehints") }, + { target: "copilotcli", outputPath: join(".copilot", "copilot-instructions.md") }, + { target: "deepagents", outputPath: join(".deepagents", "deepagents", "AGENTS.md") }, + { target: "factorydroid", outputPath: join(".factory", "AGENTS.md") }, + { target: "kilo", outputPath: join(".config", "kilo", "AGENTS.md") }, + { target: "rovodev", outputPath: join(".rovodev", "AGENTS.md") }, + { target: "takt", outputPath: join(".takt", "facets", "policies", "overview.md") }, + { target: "pi", outputPath: join(".pi", "agent", "AGENTS.md") }, + { target: "zed", outputPath: join(".config", "zed", "AGENTS.md") }, + { target: "vibe", outputPath: join(".vibe", "AGENTS.md") }, + { target: "augmentcode", outputPath: join(".augment", "rules", "overview.md") }, + { + target: "devin", + outputPath: join(".config", "devin", "AGENTS.md"), + }, + { target: "junie", outputPath: join(".junie", "AGENTS.md") }, + { target: "qwencode", outputPath: join(".qwen", "QWEN.md") }, + { target: "kiro", outputPath: join(".kiro", "steering", "product.md") }, + { target: "kiro-cli", outputPath: join(".kiro", "steering", "product.md") }, + { target: "kiro-ide", outputPath: join(".kiro", "steering", "product.md") }, +] as const; + describe("E2E: rules (global mode)", () => { const { getProjectDir, getHomeDir } = useGlobalTestDirectories(); - it.each([ - { target: "claudecode", outputPath: join(".claude", "CLAUDE.md") }, - { target: "copilot", outputPath: join(".copilot", "copilot-instructions.md") }, - { target: "opencode", outputPath: join(".config", "opencode", "AGENTS.md") }, - { target: "codexcli", outputPath: join(".codex", "AGENTS.md") }, - { target: "grokcli", outputPath: join(".grok", "AGENTS.md") }, - { target: "amp", outputPath: join(".config", "amp", "AGENTS.md") }, - { target: "cline", outputPath: join(".agents", "AGENTS.md") }, - { target: "antigravity-ide", outputPath: join(".gemini", "GEMINI.md") }, - { target: "antigravity-cli", outputPath: join(".gemini", "GEMINI.md") }, - { target: "goose", outputPath: join(".config", "goose", ".goosehints") }, - { target: "copilotcli", outputPath: join(".copilot", "copilot-instructions.md") }, - { target: "deepagents", outputPath: join(".deepagents", "deepagents", "AGENTS.md") }, - { target: "factorydroid", outputPath: join(".factory", "AGENTS.md") }, - { target: "kilo", outputPath: join(".config", "kilo", "AGENTS.md") }, - { target: "rovodev", outputPath: join(".rovodev", "AGENTS.md") }, - { target: "takt", outputPath: join(".takt", "facets", "policies", "overview.md") }, - { target: "pi", outputPath: join(".pi", "agent", "AGENTS.md") }, - { target: "zed", outputPath: join(".config", "zed", "AGENTS.md") }, - { target: "vibe", outputPath: join(".vibe", "AGENTS.md") }, - { target: "augmentcode", outputPath: join(".augment", "rules", "overview.md") }, - { - target: "devin", - outputPath: join(".config", "devin", "AGENTS.md"), - }, - { target: "junie", outputPath: join(".junie", "AGENTS.md") }, - { target: "qwencode", outputPath: join(".qwen", "QWEN.md") }, - { target: "kiro", outputPath: join(".kiro", "steering", "product.md") }, - { target: "kiro-cli", outputPath: join(".kiro", "steering", "product.md") }, - { target: "kiro-ide", outputPath: join(".kiro", "steering", "product.md") }, - ])("should generate $target rules in home directory", async ({ target, outputPath }) => { - const projectDir = getProjectDir(); - const homeDir = getHomeDir(); + it("global matrix must cover every native global rules tool target", () => { + assertGenerateMatrixCoversTargets({ + processor: RulesProcessor, + testedTargets: rulesGlobalTargets.map((e) => e.target), + // Roo has no root memory file, so its global output is exercised by the + // dedicated "~/.roo/rules" non-root test below rather than this root matrix. + untested: ["roo"], + global: true, + }); + }); - // Setup: Create a root rule in the project directory - const ruleContent = `--- + it.each(rulesGlobalTargets)( + "should generate $target rules in home directory", + async ({ target, outputPath }) => { + const projectDir = getProjectDir(); + const homeDir = getHomeDir(); + + const ruleContent = `--- root: true targets: ["*"] description: "Global test rule" @@ -693,23 +721,22 @@ globs: ["**/*"] This is a global test rule for E2E testing. `; - await writeFileContent( - join(projectDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME), - ruleContent, - ); + await writeFileContent( + join(projectDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME), + ruleContent, + ); - // Execute: Generate rules in global mode with HOME pointed to temp dir - await runGenerate({ - target, - features: "rules", - global: true, - env: { HOME_DIR: homeDir }, - }); + await runGenerate({ + target, + features: "rules", + global: true, + env: { HOME_DIR: homeDir }, + }); - // Verify that the output file was written to the home directory - const generatedContent = await readFileContent(join(homeDir, outputPath)); - expect(generatedContent).toContain("Global Test Rule"); - }); + const generatedContent = await readFileContent(join(homeDir, outputPath)); + expect(generatedContent).toContain("Global Test Rule"); + }, + ); it("should ignore non-root rules in global mode", async () => { const projectDir = getProjectDir(); diff --git a/src/e2e/e2e-skills.spec.ts b/src/e2e/e2e-skills.spec.ts index dae6a8255..470bf8f72 100644 --- a/src/e2e/e2e-skills.spec.ts +++ b/src/e2e/e2e-skills.spec.ts @@ -3,157 +3,183 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { RULESYNC_SKILLS_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js"; +import { SkillsProcessor } from "../features/skills/skills-processor.js"; import { fileExists, readFileContent, writeFileContent } from "../utils/file.js"; import { + assertGenerateMatrixCoversTargets, runGenerate, runImport, useGlobalTestDirectories, useTestDirectory, } from "./e2e-helper.js"; +// One SKILL.md per tool skill directory. +const skillsGenerateTargets = [ + { + target: "augmentcode", + outputPath: join(".augment", "skills", "test-skill", "SKILL.md"), + }, + { + target: "claudecode", + outputPath: join(".claude", "skills", "test-skill", "SKILL.md"), + }, + { + target: "cursor", + outputPath: join(".cursor", "skills", "test-skill", "SKILL.md"), + }, + { + target: "codexcli", + outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "grokcli", + outputPath: join(".grok", "skills", "test-skill", "SKILL.md"), + }, + { + target: "goose", + outputPath: join(".goose", "skills", "test-skill", "SKILL.md"), + }, + { + target: "qwencode", + outputPath: join(".qwen", "skills", "test-skill", "SKILL.md"), + }, + { + target: "copilot", + outputPath: join(".github", "skills", "test-skill", "SKILL.md"), + }, + { + target: "copilotcli", + outputPath: join(".github", "skills", "test-skill", "SKILL.md"), + }, + { + target: "deepagents", + outputPath: join(".deepagents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "cline", + outputPath: join(".cline", "skills", "test-skill", "SKILL.md"), + }, + { + target: "kilo", + outputPath: join(".kilo", "skills", "test-skill", "SKILL.md"), + }, + { + target: "roo", + outputPath: join(".roo", "skills", "test-skill", "SKILL.md"), + }, + { + target: "rovodev", + outputPath: join(".rovodev", "skills", "test-skill", "SKILL.md"), + }, + { + target: "devin", + outputPath: join(".devin", "skills", "test-skill", "SKILL.md"), + }, + { + target: "warp", + outputPath: join(".warp", "skills", "test-skill", "SKILL.md"), + }, + { + target: "kiro", + outputPath: join(".kiro", "skills", "test-skill", "SKILL.md"), + }, + { + target: "antigravity-ide", + outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "antigravity-cli", + outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "junie", + outputPath: join(".junie", "skills", "test-skill", "SKILL.md"), + }, + { + target: "replit", + outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "agentsskills", + outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "aiassistant", + outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "amp", + outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "takt", + outputPath: join(".takt", "facets", "knowledge", "test-skill.md"), + }, + { + target: "pi", + outputPath: join(".pi", "skills", "test-skill", "SKILL.md"), + }, + { + target: "zed", + outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "factorydroid", + outputPath: join(".factory", "skills", "test-skill", "SKILL.md"), + }, + { + target: "vibe", + outputPath: join(".vibe", "skills", "test-skill", "SKILL.md"), + }, + { + target: "opencode", + outputPath: join(".opencode", "skills", "test-skill", "SKILL.md"), + }, + { + target: "kiro-cli", + outputPath: join(".kiro", "skills", "test-skill", "SKILL.md"), + }, + { + target: "kiro-ide", + outputPath: join(".kiro", "skills", "test-skill", "SKILL.md"), + }, +] as const; + describe("E2E: skills", () => { const { getTestDir } = useTestDirectory(); - it.each([ - { - target: "augmentcode", - outputPath: join(".augment", "skills", "test-skill", "SKILL.md"), - }, - { - target: "claudecode", - outputPath: join(".claude", "skills", "test-skill", "SKILL.md"), - }, - { - target: "cursor", - outputPath: join(".cursor", "skills", "test-skill", "SKILL.md"), - }, - { - target: "codexcli", - outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "grokcli", - outputPath: join(".grok", "skills", "test-skill", "SKILL.md"), - }, - { - target: "goose", - outputPath: join(".goose", "skills", "test-skill", "SKILL.md"), - }, - { - target: "qwencode", - outputPath: join(".qwen", "skills", "test-skill", "SKILL.md"), - }, - { - target: "copilot", - outputPath: join(".github", "skills", "test-skill", "SKILL.md"), - }, - { - target: "copilotcli", - outputPath: join(".github", "skills", "test-skill", "SKILL.md"), - }, - { - target: "deepagents", - outputPath: join(".deepagents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "cline", - outputPath: join(".cline", "skills", "test-skill", "SKILL.md"), - }, - { - target: "kilo", - outputPath: join(".kilo", "skills", "test-skill", "SKILL.md"), - }, - { - target: "roo", - outputPath: join(".roo", "skills", "test-skill", "SKILL.md"), - }, - { - target: "rovodev", - outputPath: join(".rovodev", "skills", "test-skill", "SKILL.md"), - }, - { - target: "devin", - outputPath: join(".devin", "skills", "test-skill", "SKILL.md"), - }, - { - target: "warp", - outputPath: join(".warp", "skills", "test-skill", "SKILL.md"), - }, - { - target: "kiro", - outputPath: join(".kiro", "skills", "test-skill", "SKILL.md"), - }, - { - target: "antigravity-ide", - outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "antigravity-cli", - outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "junie", - outputPath: join(".junie", "skills", "test-skill", "SKILL.md"), - }, - { - target: "replit", - outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "agentsskills", - outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "aiassistant", - outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "amp", - outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "takt", - outputPath: join(".takt", "facets", "knowledge", "test-skill.md"), - }, - { - target: "pi", - outputPath: join(".pi", "skills", "test-skill", "SKILL.md"), - }, - { - target: "zed", - outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "factorydroid", - outputPath: join(".factory", "skills", "test-skill", "SKILL.md"), - }, - { - target: "vibe", - outputPath: join(".vibe", "skills", "test-skill", "SKILL.md"), - }, - ])("should generate $target skills", async ({ target, outputPath }) => { - const testDir = getTestDir(); + it("generate matrix must cover every native skills tool target", () => { + // agentsmd is a simulated-only target (excluded from native getToolTargets), + // so it is exercised by the dedicated simulated-skills matrix instead. + assertGenerateMatrixCoversTargets({ + processor: SkillsProcessor, + testedTargets: skillsGenerateTargets.map((e) => e.target), + }); + }); - // Setup: Create .rulesync/skills/test-skill/SKILL.md - const skillContent = `--- + it.each(skillsGenerateTargets)( + "should generate $target skills", + async ({ target, outputPath }) => { + const testDir = getTestDir(); + + const skillContent = `--- name: test-skill description: "A test skill for E2E testing" targets: ["*"] --- This is the test skill body content. `; - await writeFileContent( - join(testDir, RULESYNC_SKILLS_RELATIVE_DIR_PATH, "test-skill", "SKILL.md"), - skillContent, - ); + await writeFileContent( + join(testDir, RULESYNC_SKILLS_RELATIVE_DIR_PATH, "test-skill", "SKILL.md"), + skillContent, + ); - // Execute: Generate skills for the target - await runGenerate({ target, features: "skills" }); + await runGenerate({ target, features: "skills" }); - // Verify that the expected output file was generated - const generatedContent = await readFileContent(join(testDir, outputPath)); - expect(generatedContent).toContain("test skill body content"); - }); + const generatedContent = await readFileContent(join(testDir, outputPath)); + expect(generatedContent).toContain("test skill body content"); + }, + ); it.each([ { @@ -304,138 +330,150 @@ This is the fallback skill body content.`; }); }); +// Skills written under the pseudo-home dir. +const skillsGlobalTargets = [ + { + target: "augmentcode", + outputPath: join(".augment", "skills", "test-skill", "SKILL.md"), + }, + { + target: "claudecode", + outputPath: join(".claude", "skills", "test-skill", "SKILL.md"), + }, + { + target: "cursor", + outputPath: join(".cursor", "skills", "test-skill", "SKILL.md"), + }, + { + target: "opencode", + outputPath: join(".config", "opencode", "skills", "test-skill", "SKILL.md"), + }, + { + target: "agentsskills", + outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "amp", + outputPath: join(".config", "agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "deepagents", + outputPath: join(".deepagents", "deepagents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "codexcli", + outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "copilot", + outputPath: join(".copilot", "skills", "test-skill", "SKILL.md"), + }, + { + target: "copilotcli", + outputPath: join(".copilot", "skills", "test-skill", "SKILL.md"), + }, + { + target: "grokcli", + outputPath: join(".grok", "skills", "test-skill", "SKILL.md"), + }, + { + target: "qwencode", + outputPath: join(".qwen", "skills", "test-skill", "SKILL.md"), + }, + { + target: "junie", + outputPath: join(".junie", "skills", "test-skill", "SKILL.md"), + }, + { + target: "cline", + outputPath: join(".cline", "skills", "test-skill", "SKILL.md"), + }, + { + target: "kilo", + outputPath: join(".kilo", "skills", "test-skill", "SKILL.md"), + }, + { + target: "roo", + outputPath: join(".roo", "skills", "test-skill", "SKILL.md"), + }, + { + target: "rovodev", + outputPath: join(".rovodev", "skills", "test-skill", "SKILL.md"), + }, + { + target: "devin", + outputPath: join(".codeium", "windsurf", "skills", "test-skill", "SKILL.md"), + }, + { + target: "warp", + outputPath: join(".warp", "skills", "test-skill", "SKILL.md"), + }, + { + target: "antigravity-ide", + outputPath: join(".gemini", "config", "skills", "test-skill", "SKILL.md"), + }, + { + target: "antigravity-cli", + outputPath: join(".gemini", "antigravity-cli", "skills", "test-skill", "SKILL.md"), + }, + { + target: "takt", + outputPath: join(".takt", "facets", "knowledge", "test-skill.md"), + }, + { + target: "pi", + outputPath: join(".pi", "agent", "skills", "test-skill", "SKILL.md"), + }, + { + target: "replit", + outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "zed", + outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), + }, + { + target: "factorydroid", + outputPath: join(".factory", "skills", "test-skill", "SKILL.md"), + }, + { + target: "vibe", + outputPath: join(".vibe", "skills", "test-skill", "SKILL.md"), + }, + { + // Hermes Agent reads skills from ~/.hermes/skills/ (global only). + target: "hermesagent", + outputPath: join(".hermes", "skills", "test-skill", "SKILL.md"), + }, + { + // Kiro reads global skills from ~/.kiro/skills/. + target: "kiro-cli", + outputPath: join(".kiro", "skills", "test-skill", "SKILL.md"), + }, + { + target: "kiro-ide", + outputPath: join(".kiro", "skills", "test-skill", "SKILL.md"), + }, +] as const; + describe("E2E: skills (global mode)", () => { const { getProjectDir, getHomeDir } = useGlobalTestDirectories(); - it.each([ - { - target: "augmentcode", - outputPath: join(".augment", "skills", "test-skill", "SKILL.md"), - }, - { - target: "claudecode", - outputPath: join(".claude", "skills", "test-skill", "SKILL.md"), - }, - { - target: "cursor", - outputPath: join(".cursor", "skills", "test-skill", "SKILL.md"), - }, - { - target: "opencode", - outputPath: join(".config", "opencode", "skills", "test-skill", "SKILL.md"), - }, - { - target: "agentsskills", - outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "amp", - outputPath: join(".config", "agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "deepagents", - outputPath: join(".deepagents", "deepagents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "codexcli", - outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "copilot", - outputPath: join(".copilot", "skills", "test-skill", "SKILL.md"), - }, - { - target: "copilotcli", - outputPath: join(".copilot", "skills", "test-skill", "SKILL.md"), - }, - { - target: "grokcli", - outputPath: join(".grok", "skills", "test-skill", "SKILL.md"), - }, - { - target: "qwencode", - outputPath: join(".qwen", "skills", "test-skill", "SKILL.md"), - }, - { - target: "junie", - outputPath: join(".junie", "skills", "test-skill", "SKILL.md"), - }, - { - target: "cline", - outputPath: join(".cline", "skills", "test-skill", "SKILL.md"), - }, - { - target: "kilo", - outputPath: join(".kilo", "skills", "test-skill", "SKILL.md"), - }, - { - target: "roo", - outputPath: join(".roo", "skills", "test-skill", "SKILL.md"), - }, - { - target: "rovodev", - outputPath: join(".rovodev", "skills", "test-skill", "SKILL.md"), - }, - { - target: "devin", - outputPath: join(".codeium", "windsurf", "skills", "test-skill", "SKILL.md"), - }, - { - target: "warp", - outputPath: join(".warp", "skills", "test-skill", "SKILL.md"), - }, - { - target: "antigravity-ide", - outputPath: join(".gemini", "config", "skills", "test-skill", "SKILL.md"), - }, - { - target: "antigravity-cli", - outputPath: join(".gemini", "antigravity-cli", "skills", "test-skill", "SKILL.md"), - }, - { - target: "takt", - outputPath: join(".takt", "facets", "knowledge", "test-skill.md"), - }, - { - target: "pi", - outputPath: join(".pi", "agent", "skills", "test-skill", "SKILL.md"), - }, - { - target: "replit", - outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "zed", - outputPath: join(".agents", "skills", "test-skill", "SKILL.md"), - }, - { - target: "factorydroid", - outputPath: join(".factory", "skills", "test-skill", "SKILL.md"), - }, - { - target: "vibe", - outputPath: join(".vibe", "skills", "test-skill", "SKILL.md"), - }, - { - // Hermes Agent reads skills from ~/.hermes/skills/ (global only). - target: "hermesagent", - outputPath: join(".hermes", "skills", "test-skill", "SKILL.md"), - }, - { - // Kiro reads global skills from ~/.kiro/skills/. - target: "kiro-cli", - outputPath: join(".kiro", "skills", "test-skill", "SKILL.md"), - }, - { - target: "kiro-ide", - outputPath: join(".kiro", "skills", "test-skill", "SKILL.md"), - }, - ])("should generate $target skills in home directory", async ({ target, outputPath }) => { - const projectDir = getProjectDir(); - const homeDir = getHomeDir(); + it("global matrix must cover every native global skills tool target", () => { + assertGenerateMatrixCoversTargets({ + processor: SkillsProcessor, + testedTargets: skillsGlobalTargets.map((e) => e.target), + global: true, + }); + }); - // Setup: Create .rulesync/skills/test-skill/SKILL.md with root: true - const skillContent = `--- + it.each(skillsGlobalTargets)( + "should generate $target skills in home directory", + async ({ target, outputPath }) => { + const projectDir = getProjectDir(); + const homeDir = getHomeDir(); + + const skillContent = `--- root: true name: test-skill description: "A test skill for E2E testing" @@ -443,23 +481,22 @@ targets: ["*"] --- This is the test skill body content. `; - await writeFileContent( - join(projectDir, RULESYNC_SKILLS_RELATIVE_DIR_PATH, "test-skill", "SKILL.md"), - skillContent, - ); + await writeFileContent( + join(projectDir, RULESYNC_SKILLS_RELATIVE_DIR_PATH, "test-skill", "SKILL.md"), + skillContent, + ); - // Execute: Generate skills in global mode with HOME pointed to temp dir - await runGenerate({ - target, - features: "skills", - global: true, - env: { HOME_DIR: homeDir }, - }); + await runGenerate({ + target, + features: "skills", + global: true, + env: { HOME_DIR: homeDir }, + }); - // Verify that the expected output file was generated - const generatedContent = await readFileContent(join(homeDir, outputPath)); - expect(generatedContent).toContain("test skill body content"); - }); + const generatedContent = await readFileContent(join(homeDir, outputPath)); + expect(generatedContent).toContain("test skill body content"); + }, + ); it("should ignore non-root skills in global mode", async () => { const projectDir = getProjectDir(); diff --git a/src/e2e/e2e-subagents.spec.ts b/src/e2e/e2e-subagents.spec.ts index 9f517e012..f86ba9a8c 100644 --- a/src/e2e/e2e-subagents.spec.ts +++ b/src/e2e/e2e-subagents.spec.ts @@ -3,122 +3,189 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH } from "../constants/rulesync-paths.js"; +import { SubagentsProcessor } from "../features/subagents/subagents-processor.js"; import { readFileContent, writeFileContent } from "../utils/file.js"; import { + assertGenerateMatrixCoversTargets, runGenerate, runImport, useGlobalTestDirectories, useTestDirectory, } from "./e2e-helper.js"; +const subagentsGenerateTargets = [ + { + target: "augmentcode", + outputPath: join(".augment", "agents", "planner.md"), + }, + { + target: "claudecode", + outputPath: join(".claude", "agents", "planner.md"), + }, + { + target: "cursor", + outputPath: join(".cursor", "agents", "planner.md"), + }, + { + target: "grokcli", + outputPath: join(".grok", "agents", "planner.md"), + }, + { + target: "qwencode", + outputPath: join(".qwen", "agents", "planner.md"), + }, + { + target: "codexcli", + outputPath: join(".codex", "agents", "planner.toml"), + }, + { + target: "copilot", + outputPath: join(".github", "agents", "planner.agent.md"), + }, + { + target: "copilotcli", + outputPath: join(".github", "agents", "planner.agent.md"), + }, + { + target: "deepagents", + outputPath: join(".deepagents", "agents", "planner", "AGENTS.md"), + }, + { + target: "devin", + outputPath: join(".devin", "agents", "planner", "AGENT.md"), + }, + { + target: "kiro", + outputPath: join(".kiro", "agents", "planner.json"), + }, + { + target: "kiro-cli", + outputPath: join(".kiro", "agents", "planner.json"), + }, + { + target: "kiro-ide", + outputPath: join(".kiro", "agents", "planner.md"), + }, + { + target: "kilo", + outputPath: join(".kilo", "agents", "planner.md"), + }, + { + target: "opencode", + outputPath: join(".opencode", "agents", "planner.md"), + }, + { + target: "rovodev", + outputPath: join(".rovodev", "subagents", "planner.md"), + }, + { + target: "junie", + outputPath: join(".junie", "agents", "planner.md"), + }, + { + target: "takt", + outputPath: join(".takt", "facets", "personas", "planner.md"), + }, + { + target: "factorydroid", + outputPath: join(".factory", "droids", "planner.md"), + }, + { + target: "cline", + outputPath: join(".cline", "agents", "planner.yaml"), + }, + { + target: "vibe", + outputPath: join(".vibe", "agents", "planner.toml"), + }, + { + target: "goose", + outputPath: join(".goose", "recipes", "subagents", "planner.yaml"), + }, + { + target: "roo", + outputPath: ".roomodes", + }, +] as const; + +const subagentsGlobalTargets = [ + { target: "augmentcode", outputPath: join(".augment", "agents", "planner.md") }, + { target: "claudecode", outputPath: join(".claude", "agents", "planner.md") }, + { target: "codexcli", outputPath: join(".codex", "agents", "planner.toml") }, + { target: "copilot", outputPath: join(".copilot", "agents", "planner.agent.md") }, + { target: "copilotcli", outputPath: join(".copilot", "agents", "planner.agent.md") }, + { target: "cursor", outputPath: join(".cursor", "agents", "planner.md") }, + { target: "grokcli", outputPath: join(".grok", "agents", "planner.md") }, + { target: "qwencode", outputPath: join(".qwen", "agents", "planner.md") }, + { target: "junie", outputPath: join(".junie", "agents", "planner.md") }, + { target: "kiro-cli", outputPath: join(".kiro", "agents", "planner.json") }, + { target: "kiro-ide", outputPath: join(".kiro", "agents", "planner.md") }, + { target: "kilo", outputPath: join(".config", "kilo", "agents", "planner.md") }, + { target: "opencode", outputPath: join(".config", "opencode", "agents", "planner.md") }, + { target: "rovodev", outputPath: join(".rovodev", "subagents", "planner.md") }, + { target: "takt", outputPath: join(".takt", "facets", "personas", "planner.md") }, + { target: "factorydroid", outputPath: join(".factory", "droids", "planner.md") }, + { target: "cline", outputPath: join(".cline", "agents", "planner.yaml") }, + { + target: "deepagents", + outputPath: join(".deepagents", "deepagents", "agents", "planner", "AGENTS.md"), + }, + { + target: "devin", + outputPath: join(".config", "devin", "agents", "planner", "AGENT.md"), + }, + { target: "vibe", outputPath: join(".vibe", "agents", "planner.toml") }, + { + target: "goose", + outputPath: join(".config", "goose", "recipes", "subagents", "planner.yaml"), + }, + { + // Hermes Agent has no project-scoped subagent location; subagents are + // emitted as JSON specs under ~/.hermes/rulesync/subagents/.json, + // discovered by the generated rulesync-subagents plugin (global only). + target: "hermesagent", + outputPath: join(".hermes", "rulesync", "subagents", "planner.json"), + }, +] as const; + describe("E2E: subagents", () => { const { getTestDir } = useTestDirectory(); - it.each([ - { - target: "augmentcode", - outputPath: join(".augment", "agents", "planner.md"), - }, - { - target: "claudecode", - outputPath: join(".claude", "agents", "planner.md"), - }, - { - target: "cursor", - outputPath: join(".cursor", "agents", "planner.md"), - }, - { - target: "grokcli", - outputPath: join(".grok", "agents", "planner.md"), - }, - { - target: "qwencode", - outputPath: join(".qwen", "agents", "planner.md"), - }, - { - target: "codexcli", - outputPath: join(".codex", "agents", "planner.toml"), - }, - { - target: "copilot", - outputPath: join(".github", "agents", "planner.agent.md"), - }, - { - target: "copilotcli", - outputPath: join(".github", "agents", "planner.agent.md"), - }, - { - target: "deepagents", - outputPath: join(".deepagents", "agents", "planner", "AGENTS.md"), - }, - { - target: "devin", - outputPath: join(".devin", "agents", "planner", "AGENT.md"), - }, - { - target: "kiro", - outputPath: join(".kiro", "agents", "planner.json"), - }, - { - target: "kiro-cli", - outputPath: join(".kiro", "agents", "planner.json"), - }, - { - target: "kiro-ide", - outputPath: join(".kiro", "agents", "planner.md"), - }, - { - target: "junie", - outputPath: join(".junie", "agents", "planner.md"), - }, - { - target: "takt", - outputPath: join(".takt", "facets", "personas", "planner.md"), - }, - { - target: "factorydroid", - outputPath: join(".factory", "droids", "planner.md"), - }, - { - target: "cline", - outputPath: join(".cline", "agents", "planner.yaml"), - }, - { - target: "vibe", - outputPath: join(".vibe", "agents", "planner.toml"), - }, - { - target: "goose", - outputPath: join(".goose", "recipes", "subagents", "planner.yaml"), - }, - { - target: "roo", - outputPath: ".roomodes", - }, - ])("should generate $target subagents", async ({ target, outputPath }) => { - const testDir = getTestDir(); + it("generate matrix must cover every native subagents tool target", () => { + assertGenerateMatrixCoversTargets({ + processor: SubagentsProcessor, + testedTargets: subagentsGenerateTargets.map((e) => e.target), + // Hermes Agent is a native subagents tool but has no project-scoped + // single-file output; its generate path emits only global JSON specs, so + // it is exercised by the global matrix instead of this project matrix. + untested: ["hermesagent"], + }); + }); - // Setup: Create .rulesync/subagents/planner.md - const subagentContent = `--- + it.each(subagentsGenerateTargets)( + "should generate $target subagents", + async ({ target, outputPath }) => { + const testDir = getTestDir(); + + const subagentContent = `--- name: planner targets: ["*"] description: "Plans implementation tasks" --- You are the planner. Analyze files and create a plan. `; - await writeFileContent( - join(testDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"), - subagentContent, - ); + await writeFileContent( + join(testDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"), + subagentContent, + ); - // Execute: Generate subagents for the target - await runGenerate({ target, features: "subagents" }); + await runGenerate({ target, features: "subagents" }); - // Verify that the expected output file was generated - const generatedContent = await readFileContent(join(testDir, outputPath)); - expect(generatedContent).toContain("planner"); - expect(generatedContent).toContain("Analyze files and create a plan."); - }); + const generatedContent = await readFileContent(join(testDir, outputPath)); + expect(generatedContent).toContain("planner"); + expect(generatedContent).toContain("Analyze files and create a plan."); + }, + ); it.each([{ target: "agentsmd", outputPath: join(".agents", "subagents", "planner.md") }])( "should generate $target simulated subagents", @@ -167,7 +234,6 @@ You are a primary agent. You appear in the Tab rotation. subagentContent, ); - // Execute: Generate subagents for opencode await runGenerate({ target: "opencode", features: "subagents" }); // Verify that the mode is preserved as primary, not defaulting to subagent @@ -435,48 +501,21 @@ Break down tasks into steps. describe("E2E: subagents (global mode)", () => { const { getProjectDir, getHomeDir } = useGlobalTestDirectories(); - it.each([ - { target: "augmentcode", outputPath: join(".augment", "agents", "planner.md") }, - { target: "claudecode", outputPath: join(".claude", "agents", "planner.md") }, - { target: "codexcli", outputPath: join(".codex", "agents", "planner.toml") }, - { target: "copilot", outputPath: join(".copilot", "agents", "planner.agent.md") }, - { target: "copilotcli", outputPath: join(".copilot", "agents", "planner.agent.md") }, - { target: "cursor", outputPath: join(".cursor", "agents", "planner.md") }, - { target: "grokcli", outputPath: join(".grok", "agents", "planner.md") }, - { target: "qwencode", outputPath: join(".qwen", "agents", "planner.md") }, - { target: "junie", outputPath: join(".junie", "agents", "planner.md") }, - { target: "kiro-cli", outputPath: join(".kiro", "agents", "planner.json") }, - { target: "opencode", outputPath: join(".config", "opencode", "agents", "planner.md") }, - { target: "rovodev", outputPath: join(".rovodev", "subagents", "planner.md") }, - { target: "takt", outputPath: join(".takt", "facets", "personas", "planner.md") }, - { target: "factorydroid", outputPath: join(".factory", "droids", "planner.md") }, - { target: "cline", outputPath: join(".cline", "agents", "planner.yaml") }, - { - target: "deepagents", - outputPath: join(".deepagents", "deepagents", "agents", "planner", "AGENTS.md"), - }, - { - target: "devin", - outputPath: join(".config", "devin", "agents", "planner", "AGENT.md"), - }, - { target: "vibe", outputPath: join(".vibe", "agents", "planner.toml") }, - { - target: "goose", - outputPath: join(".config", "goose", "recipes", "subagents", "planner.yaml"), - }, - { - // Hermes Agent has no project-scoped subagent location; subagents are - // emitted as JSON specs under ~/.hermes/rulesync/subagents/.json, - // discovered by the generated rulesync-subagents plugin (global only). - target: "hermesagent", - outputPath: join(".hermes", "rulesync", "subagents", "planner.json"), - }, - ])("should generate $target subagents in home directory", async ({ target, outputPath }) => { - const projectDir = getProjectDir(); - const homeDir = getHomeDir(); + it("global matrix must cover every native global subagents tool target", () => { + assertGenerateMatrixCoversTargets({ + processor: SubagentsProcessor, + testedTargets: subagentsGlobalTargets.map((e) => e.target), + global: true, + }); + }); - // Setup: Create .rulesync/subagents/planner.md with root: true - const subagentContent = `--- + it.each(subagentsGlobalTargets)( + "should generate $target subagents in home directory", + async ({ target, outputPath }) => { + const projectDir = getProjectDir(); + const homeDir = getHomeDir(); + + const subagentContent = `--- root: true name: planner targets: ["*"] @@ -484,24 +523,23 @@ description: "Plans implementation tasks" --- You are the planner. Analyze files and create a plan. `; - await writeFileContent( - join(projectDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"), - subagentContent, - ); + await writeFileContent( + join(projectDir, RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, "planner.md"), + subagentContent, + ); - // Execute: Generate subagents in global mode with HOME pointed to temp dir - await runGenerate({ - target, - features: "subagents", - global: true, - env: { HOME_DIR: homeDir }, - }); + await runGenerate({ + target, + features: "subagents", + global: true, + env: { HOME_DIR: homeDir }, + }); - // Verify that the expected output file was generated - const generatedContent = await readFileContent(join(homeDir, outputPath)); - expect(generatedContent).toContain("planner"); - expect(generatedContent).toContain("Analyze files and create a plan."); - }); + const generatedContent = await readFileContent(join(homeDir, outputPath)); + expect(generatedContent).toContain("planner"); + expect(generatedContent).toContain("Analyze files and create a plan."); + }, + ); it("should ignore non-root subagents in global mode", async () => { const projectDir = getProjectDir();