diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 24d8ec6..0c34332 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -779,8 +779,8 @@ describe('runList', () => { const json = JSON.parse(capture.stdout.join('\n')) as ListResult[]; expect(Array.isArray(json)).toBe(true); - // 8 targets × 2 default skills = 16 rows - expect(json).toHaveLength(16); + // 9 targets x 2 default skills = 18 rows + expect(json).toHaveLength(18); const targets = json.map(r => r.target); expect(targets).toContain('claude'); expect(targets).toContain('cursor'); diff --git a/src/commands/agent.ts b/src/commands/agent.ts index 7d7e7c8..c61fba9 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -1062,7 +1062,7 @@ function collect(v: string, prev: string[]): string[] { export function createAgentCommand(deps: AgentDeps = {}): Command { const agent = new Command('agent').description( - 'Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Antigravity, Kiro, Windsurf, Copilot, Codex)', + 'Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Antigravity, Kiro, Windsurf, Copilot, Gemini, Codex)', ); agent @@ -1072,7 +1072,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command { ) .option( '--target ', - 'Agent target(s): claude, cursor, cline, antigravity, kiro, windsurf, copilot, codex (comma-separated or repeated)', + 'Agent target(s): claude, cursor, cline, antigravity, kiro, windsurf, copilot, gemini, codex (comma-separated or repeated)', collect, [], ) diff --git a/src/lib/agent-targets.test.ts b/src/lib/agent-targets.test.ts index 2ac9158..8d6e8c0 100644 --- a/src/lib/agent-targets.test.ts +++ b/src/lib/agent-targets.test.ts @@ -82,7 +82,7 @@ testsprite test artifact get --out ./out/ // --------------------------------------------------------------------------- describe('TARGETS', () => { - it('has all eight required keys', () => { + it('has all nine required keys', () => { const keys = Object.keys(TARGETS).sort(); expect(keys).toEqual([ 'antigravity', @@ -91,6 +91,7 @@ describe('TARGETS', () => { 'codex', 'copilot', 'cursor', + 'gemini', 'kiro', 'windsurf', ]); @@ -100,11 +101,12 @@ describe('TARGETS', () => { expect(TARGETS.claude.status).toBe('ga'); }); - it('cursor, cline, windsurf, copilot, antigravity, kiro, and codex are experimental', () => { + it('cursor, cline, windsurf, copilot, gemini, antigravity, kiro, and codex are experimental', () => { expect(TARGETS.cursor.status).toBe('experimental'); expect(TARGETS.cline.status).toBe('experimental'); expect(TARGETS.windsurf.status).toBe('experimental'); expect(TARGETS.copilot.status).toBe('experimental'); + expect(TARGETS.gemini.status).toBe('experimental'); expect(TARGETS.antigravity.status).toBe('experimental'); expect(TARGETS.kiro.status).toBe('experimental'); expect(TARGETS.codex.status).toBe('experimental'); @@ -127,8 +129,9 @@ describe('TARGETS', () => { expect(TARGETS.copilot.mode).toBe('own-file'); }); - it('codex target has mode managed-section', () => { + it('codex and gemini targets have mode managed-section', () => { expect(TARGETS.codex.mode).toBe('managed-section'); + expect(TARGETS.gemini.mode).toBe('managed-section'); }); it('codex target path is AGENTS.md', () => { @@ -386,6 +389,31 @@ describe('renderForTarget("copilot")', () => { }); }); +describe('renderForTarget("gemini")', () => { + const result = renderForTarget('gemini', 'testsprite-verify', STUB_BODY); + + it('returns GEMINI.md as the landing path', () => { + expect(result.path).toBe('GEMINI.md'); + }); + + it('does not emit YAML frontmatter (plain Markdown, no --- fence)', () => { + expect(result.content.startsWith('---\n')).toBe(false); + expect(result.content).not.toContain('name:'); + expect(result.content).not.toContain('applyTo:'); + expect(result.content).not.toContain('trigger:'); + }); + + it('contains the stub body verbatim', () => { + expect(result.content).toContain(STUB_BODY); + }); + + it('renders the verify body content (managed-section, compact codex body)', () => { + // Uses real bodies: gemini uses the codex contribution body (compact). + const gemini = renderForTarget('gemini', 'testsprite-verify'); + expect(gemini.content).toContain('testsprite test run'); + }); +}); + // --------------------------------------------------------------------------- // Content integrity — load-bearing command strings must survive any body trim // --------------------------------------------------------------------------- diff --git a/src/lib/agent-targets.ts b/src/lib/agent-targets.ts index 7e6f94d..e7eeae8 100644 --- a/src/lib/agent-targets.ts +++ b/src/lib/agent-targets.ts @@ -10,7 +10,8 @@ export type AgentTarget = | 'codex' | 'kiro' | 'windsurf' - | 'copilot'; + | 'copilot' + | 'gemini'; export interface TargetSpec { status: 'ga' | 'experimental'; @@ -189,6 +190,8 @@ export function pathFor(target: AgentTarget, skill: string): string { return `.windsurf/rules/${skill}.md`; case 'copilot': return `.github/instructions/${skill}.instructions.md`; + case 'gemini': + return 'GEMINI.md'; case 'codex': return 'AGENTS.md'; } @@ -243,11 +246,34 @@ export const TARGETS: Record = { // GitHub Copilot path-specific instructions: frontmatter carries `applyTo`. // `applyTo: '**'` means the file is ALWAYS injected into Copilot requests // (there is no on-demand "model decides" mode like Cursor/Windsurf), so - // render the compact body to keep the always-on context cost small — the + // render the compact body to keep the always-on context cost small -- the // same reasoning that drives windsurf's compact render. compactBody: true, wrap: wrapCopilot, }, + /** + * gemini target -- managed-section mode (GEMINI.md). + * + * Gemini CLI reads project-level instructions from a `GEMINI.md` file in + * the repo root. The file is plain Markdown, loaded at the start of every + * session (always-on). Because all installed skills share this single file + * we use managed-section mode -- the same approach as codex/AGENTS.md -- + * writing only a sentinel-delimited section so any existing user content + * in GEMINI.md is never clobbered. + * + * The compact body is used (same reasoning as windsurf/copilot): GEMINI.md + * is always-injected, so keeping it small reduces context cost. + * + * --force replaces the managed section unconditionally but never touches + * content outside the sentinels. + */ + gemini: { + status: 'experimental', + path: pathFor('gemini', SKILL_NAME), + mode: 'managed-section', + // GEMINI.md is plain Markdown; wrap is a no-op (no frontmatter). + wrap: (_name, _description, body) => body, + }, /** * codex target — managed-section mode. * diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index 62efe5d..c14b5ee 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -4,7 +4,7 @@ exports[`--help snapshots > agent 1`] = ` "Usage: testsprite agent [options] [command] Install TestSprite guidance into coding-agent config (Claude Code, Cursor, -Cline, Antigravity, Kiro, Windsurf, Copilot, Codex) +Cline, Antigravity, Kiro, Windsurf, Copilot, Gemini, Codex) Options: -h, --help display help for command @@ -29,8 +29,8 @@ into a project for a coding agent Options: --target Agent target(s): claude, cursor, cline, antigravity, kiro, - windsurf, copilot, codex (comma-separated or repeated) - (default: []) + windsurf, copilot, gemini, codex (comma-separated or + repeated) (default: []) --skill Skill(s) to install: testsprite-verify, testsprite-onboard (comma-separated or repeated; default: all) (default: []) --dir Project root to write into (default: cwd) @@ -116,8 +116,8 @@ Options: --from-env Read TESTSPRITE_API_KEY from the environment instead of prompting (default: false) --agent Coding-agent target to install: claude, antigravity, - cursor, cline, kiro, windsurf, copilot, codex (default: - claude) (default: "claude") + cursor, cline, kiro, windsurf, copilot, gemini, codex + (default: claude) (default: "claude") --no-agent Skip the agent skill install (configure credentials only) --force Overwrite an existing skill file (a .bak backup is kept) --dir Project root for the skill install (default: current @@ -680,7 +680,7 @@ Commands: test Inspect TestSprite tests agent Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Antigravity, - Kiro, Windsurf, Copilot, Codex) + Kiro, Windsurf, Copilot, Gemini, Codex) usage|credits Show credit balance and plan/entitlement info (proactive pre-flight before a large test run) doctor Diagnose CLI setup: version, Node, profile, diff --git a/test/e2e/agent-install.e2e.test.ts b/test/e2e/agent-install.e2e.test.ts index 4fb4584..3f9ad3c 100644 --- a/test/e2e/agent-install.e2e.test.ts +++ b/test/e2e/agent-install.e2e.test.ts @@ -179,8 +179,12 @@ describe('content integrity', () => { expect(content.startsWith('---'), `copilot: should start with ---`).toBe(true); expect(content).toContain("applyTo: '**'"); expect(content).toContain('description:'); + } else if (target === 'gemini') { + // GEMINI.md is plain Markdown -- no frontmatter expected. + expect(content.startsWith('---'), `gemini: must NOT start with ---`).toBe(false); + expect(content).not.toContain('applyTo:'); + expect(content).not.toContain('trigger:'); } - // (b) branding — the renamed H1 must be present in every body variant expect(content).toContain('TestSprite Verification Loop'); // The full-body intro line lives only in the FULL body; compact-body targets @@ -219,6 +223,9 @@ describe('content integrity', () => { } else if (target === 'copilot') { expect(content.startsWith('---'), `copilot/onboard: should start with ---`).toBe(true); expect(content).toContain("applyTo: '**'"); + } else if (target === 'gemini') { + // GEMINI.md is plain Markdown -- no frontmatter. + expect(content.startsWith('---'), `gemini/onboard: must NOT start with ---`).toBe(false); } // Load-bearing onboard string: the skill body must reference setup @@ -264,8 +271,38 @@ describe('content integrity', () => { // (d) No frontmatter fence — AGENTS.md is plain prose expect(content.startsWith('---'), 'codex: must NOT start with ---').toBe(false); }); -}); + it('gemini GEMINI.md contains ONE managed section with verify and onboard content', () => { + const tmpDir = freshTmpDir(); + runCli(['agent', 'install', '--target=gemini', '--dir', tmpDir, '--output', 'json']); + + const filePath = join(tmpDir, TARGETS.gemini.path); + const content = readFileSync(filePath, 'utf8'); + + // (a) Exactly ONE pair of sentinels + const escRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const beginCount = (content.match(new RegExp(escRe(MANAGED_SECTION_BEGIN), 'g')) ?? []).length; + const endCount = (content.match(new RegExp(escRe(MANAGED_SECTION_END), 'g')) ?? []).length; + expect(beginCount, 'exactly one BEGIN sentinel').toBe(1); + expect(endCount, 'exactly one END sentinel').toBe(1); + + // BEGIN must come before END + expect(content.indexOf(MANAGED_SECTION_BEGIN)).toBeLessThan( + content.indexOf(MANAGED_SECTION_END), + ); + + // (b) Load-bearing command strings from the compact verify body + expect(content).toContain('testsprite test run'); + expect(content).toContain('--wait'); + expect(content).toContain('test artifact get'); + + // (c) Onboard one-liner must be present + expect(content).toContain('First-time setup'); + + // (d) No frontmatter fence -- GEMINI.md is plain prose + expect(content.startsWith('---'), 'gemini: must NOT start with ---').toBe(false); + }); +}); // --------------------------------------------------------------------------- // 3. Idempotent re-run // --------------------------------------------------------------------------- @@ -811,7 +848,7 @@ describe('agent list', () => { }>; expect(Array.isArray(parsed)).toBe(true); - // Expected: 8 targets × 2 skills = 16 rows + // Expected: targets x 2 skills per target = total rows (computed dynamically) const expectedCount = Object.keys(TARGETS).length * DEFAULT_SKILLS.length; expect(parsed.length).toBe(expectedCount); @@ -848,6 +885,7 @@ describe('matrix coverage guard', () => { 'kiro', 'windsurf', 'copilot', + 'gemini', 'codex', ]); }); diff --git a/test/e2e/setup.e2e.test.ts b/test/e2e/setup.e2e.test.ts index 5a07434..3ad17cb 100644 --- a/test/e2e/setup.e2e.test.ts +++ b/test/e2e/setup.e2e.test.ts @@ -230,6 +230,7 @@ describe('matrix coverage guard', () => { 'kiro', 'windsurf', 'copilot', + 'gemini', 'codex', ]); });