diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 3746f46..a58d50f 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -112,11 +112,12 @@ testsprite agent install claude # install the skill for Claude Code testsprite agent install codex # install into AGENTS.md for Codex (managed-section) testsprite agent install cursor # .cursor/rules/testsprite-verify.mdc testsprite agent install cline # .clinerules/testsprite-verify.md +testsprite agent install windsurf # .windsurf/rules/testsprite-verify.md testsprite agent install antigravity # .agents/skills/testsprite-verify/SKILL.md -testsprite agent list # list all 5 targets with status + mode + path +testsprite agent list # list all 6 targets with status + mode + path ``` -Supported targets: `claude` (GA), `codex` (experimental), `cursor` (experimental), `cline` (experimental), `antigravity` (experimental). +Supported targets: `claude` (GA), `codex` (experimental), `cursor` (experimental), `cline` (experimental), `windsurf` (experimental), `antigravity` (experimental). The `codex` target uses **managed-section mode** — it writes only a sentinel-delimited section inside your existing `AGENTS.md`, so your project instructions are never clobbered. Re-running without `--force` replaces the section in-place; user content outside the sentinels is always preserved. diff --git a/README.md b/README.md index 2d90012..e907a54 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ npm install -g @testsprite/testsprite-cli testsprite setup ``` -`testsprite setup` prompts for your [API key](https://www.testsprite.com), verifies it, and installs the verification-loop skill for your coding agent (`claude`, `cursor`, `cline`, `antigravity`, `codex`, etc.) — one command, so your agent is wired to verify its own work. Non-interactive (CI / onboarding scripts): +`testsprite setup` prompts for your [API key](https://www.testsprite.com), verifies it, and installs the verification-loop skill for your coding agent (`claude`, `cursor`, `cline`, `windsurf`, `antigravity`, `codex`, etc.) — one command, so your agent is wired to verify its own work. Non-interactive (CI / onboarding scripts): ```bash TESTSPRITE_API_KEY=sk-... testsprite setup --from-env --yes --agent claude @@ -110,7 +110,7 @@ Prefer to configure each step by hand (or learn the surface offline with `--dry- | | `test rerun` | Cheap replay of one/many tests (FE verbatim; BE with deps); `--all --project ` reruns all tests | | | `test wait` | Block on a `runId` until terminal | | | `test artifact get` | Download the failure bundle for a specific `runId` | -| **Agent** | `agent install` / `agent list` | Add or list coding-agent targets (pure-local): `claude`, `codex`, `cursor`, `cline`, `antigravity` | +| **Agent** | `agent install` / `agent list` | Add or list coding-agent targets (pure-local): `claude`, `codex`, `cursor`, `cline`, `windsurf`, `antigravity` | > The earlier command names — `init`, `auth configure`, `auth whoami`, `auth logout` — still work as hidden, deprecated aliases (each prints a one-line notice pointing at the new name), so existing scripts keep running. `auth configure` now runs the full `setup` (it also installs the skill). diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index bb558f3..bd58d73 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -723,11 +723,12 @@ describe('runList', () => { const json = JSON.parse(capture.stdout.join('\n')) as ListResult[]; expect(Array.isArray(json)).toBe(true); - expect(json).toHaveLength(5); + expect(json).toHaveLength(6); const targets = json.map(r => r.target); expect(targets).toContain('claude'); expect(targets).toContain('cursor'); expect(targets).toContain('cline'); + expect(targets).toContain('windsurf'); expect(targets).toContain('antigravity'); expect(targets).toContain('codex'); const claudeEntry = json.find(r => r.target === 'claude'); @@ -857,11 +858,11 @@ describe('createAgentCommand wiring', () => { }); // --------------------------------------------------------------------------- -// All four own-file targets installed at once +// All own-file targets installed at once // --------------------------------------------------------------------------- -describe('runInstall — all four own-file targets', () => { - it('installs all four own-file targets in one invocation', async () => { +describe('runInstall — all own-file targets', () => { + it('installs every own-file target in one invocation', async () => { const { store, fs: agentFs } = makeMemFs(); const { capture, deps } = makeCapture(); @@ -871,7 +872,7 @@ describe('runInstall — all four own-file targets', () => { output: 'text', debug: false, dryRun: false, - target: ['claude', 'cursor', 'cline', 'antigravity'], + target: [...OWN_FILE_TARGETS], force: false, }, { cwd: CWD, fs: agentFs, ...deps }, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index c27e2b8..641672a 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -566,8 +566,20 @@ export async function runInstall(opts: InstallOptions, deps: AgentDeps = {}): Pr // ----------------------------------------------------------------------- // own-file mode (all other targets) // ----------------------------------------------------------------------- - if (ownFileBody === undefined) ownFileBody = loadSkillBody(); - const content = renderForTarget(t, ownFileBody).content; + // Budget-capped targets (e.g. windsurf) render the compact body so the + // file is not truncated by the agent; everything else uses the full skill. + // Bodies are loaded lazily and cached so a multi-target install reads each + // source file at most once. Must match renderForTarget's default selection + // so the written bytes equal what the unit tests assert. + let body: string; + if (spec.compactBody === true) { + if (codexBody === undefined) codexBody = loadCodexSkillBody(); + body = codexBody; + } else { + if (ownFileBody === undefined) ownFileBody = loadSkillBody(); + body = ownFileBody; + } + const content = renderForTarget(t, body).content; if (opts.dryRun) { const bytes = Buffer.byteLength(content, 'utf8'); @@ -703,7 +715,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, Codex)', + 'Install TestSprite guidance into coding-agent config (Claude Code, Cursor, Cline, Windsurf, Antigravity, Codex)', ); agent @@ -713,7 +725,7 @@ export function createAgentCommand(deps: AgentDeps = {}): Command { ) .option( '--target ', - 'Agent target(s): claude, cursor, cline, antigravity, codex (comma-separated or repeated)', + 'Agent target(s): claude, cursor, cline, windsurf, antigravity, codex (comma-separated or repeated)', collect, [], ) diff --git a/src/lib/agent-targets.test.ts b/src/lib/agent-targets.test.ts index 2a2262c..f1493c4 100644 --- a/src/lib/agent-targets.test.ts +++ b/src/lib/agent-targets.test.ts @@ -60,18 +60,19 @@ testsprite test artifact get --out ./out/ // --------------------------------------------------------------------------- describe('TARGETS', () => { - it('has all five required keys', () => { + it('has all six required keys', () => { const keys = Object.keys(TARGETS).sort(); - expect(keys).toEqual(['antigravity', 'claude', 'cline', 'codex', 'cursor']); + expect(keys).toEqual(['antigravity', 'claude', 'cline', 'codex', 'cursor', 'windsurf']); }); it('claude is GA', () => { expect(TARGETS.claude.status).toBe('ga'); }); - it('cursor, cline, antigravity, and codex are experimental', () => { + it('cursor, cline, windsurf, antigravity, and codex are experimental', () => { expect(TARGETS.cursor.status).toBe('experimental'); expect(TARGETS.cline.status).toBe('experimental'); + expect(TARGETS.windsurf.status).toBe('experimental'); expect(TARGETS.antigravity.status).toBe('experimental'); expect(TARGETS.codex.status).toBe('experimental'); }); @@ -88,6 +89,7 @@ describe('TARGETS', () => { expect(TARGETS.antigravity.mode).toBe('own-file'); expect(TARGETS.cursor.mode).toBe('own-file'); expect(TARGETS.cline.mode).toBe('own-file'); + expect(TARGETS.windsurf.mode).toBe('own-file'); }); it('codex target has mode managed-section', () => { @@ -255,11 +257,66 @@ describe('renderForTarget("cline")', () => { }); }); +describe('renderForTarget("windsurf")', () => { + const result = renderForTarget('windsurf', STUB_BODY); + + it('returns the .windsurf/rules path', () => { + expect(result.path).toBe('.windsurf/rules/testsprite-verify.md'); + }); + + it('uses the Cascade frontmatter (trigger: model_decision + description)', () => { + expect(result.content.startsWith('---\n')).toBe(true); + expect(result.content).toContain('trigger: model_decision'); + expect(result.content).toContain(`description: ${SKILL_DESCRIPTION}`); + }); + + it('does NOT carry the Claude/Cursor frontmatter keys', () => { + const match = /^---\n([\s\S]*?)\n---/.exec(result.content); + const fm = match?.[1] ?? ''; + expect(fm).not.toContain('name:'); // claude/kiro key + expect(fm).not.toContain('alwaysApply:'); // cursor .mdc key + }); + + it('preserves the skill body after the frontmatter and ends with a newline', () => { + expect(result.content).toContain('# TestSprite Verification Loop'); + expect(result.content.endsWith('\n')).toBe(true); + }); +}); + +describe('windsurf renders the compact body within the rules budget', () => { + // Regression: the full skill body (~21 KB) rendered to ~22 KB, which exceeds + // the Windsurf `.windsurf/rules/*.md` 12 K-character cap and would be silently + // truncated. The windsurf target therefore renders the COMPACT body. + // Uses the REAL bodies (no stub) so the size is the bytes a user receives. + const windsurf = renderForTarget('windsurf'); + const claude = renderForTarget('claude'); + + it('fits within the 12 000-character Windsurf rules budget', () => { + expect(windsurf.content.length).toBeLessThan(12_000); + }); + + it('uses the compact body, not the full one', () => { + // 'The verification loop that flies' appears only in the full body. + expect(claude.content).toContain('The verification loop that flies'); + expect(windsurf.content).not.toContain('The verification loop that flies'); + // ...but the core skill identity and a load-bearing command survive. + expect(windsurf.content).toContain('# TestSprite Verification Loop'); + expect(windsurf.content).toContain('testsprite test run'); + }); + + it('is materially smaller than the full-body claude render', () => { + expect(windsurf.content.length).toBeLessThan(claude.content.length); + }); +}); + // --------------------------------------------------------------------------- // Content integrity — load-bearing command strings must survive any body trim // --------------------------------------------------------------------------- describe('content integrity — own-file targets', () => { + // windsurf is intentionally excluded: it renders the COMPACT body (its rules + // file is budget-capped), so it is covered by its own budget test below + // rather than these full-body load-bearing-string checks. const ownFileTargets: Array<'claude' | 'cursor' | 'cline' | 'antigravity'> = [ 'claude', 'cursor', diff --git a/src/lib/agent-targets.ts b/src/lib/agent-targets.ts index fb83ca2..0a140ea 100644 --- a/src/lib/agent-targets.ts +++ b/src/lib/agent-targets.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs'; -export type AgentTarget = 'claude' | 'cursor' | 'cline' | 'antigravity' | 'codex'; +export type AgentTarget = 'claude' | 'cursor' | 'cline' | 'antigravity' | 'codex' | 'windsurf'; export interface TargetSpec { status: 'ga' | 'experimental'; @@ -12,6 +12,15 @@ export interface TargetSpec { * a potentially user-authored file (codex target, AGENTS.md). */ mode: 'own-file' | 'managed-section'; + /** + * When true, render the trimmed/compact skill body (see {@link loadCodexSkillBody}) + * instead of the full one. Used for targets with a tight per-rule budget where + * the full ~21 KB skill would be silently truncated — currently `windsurf` + * (`.windsurf/rules/*.md` files cap at 12 K characters). `managed-section` + * (codex) already uses the compact body via its own path; this flag covers + * own-file targets that need it too. + */ + compactBody?: boolean; /** wrap the canonical body in this target's frontmatter/header */ wrap(body: string): string; } @@ -33,6 +42,19 @@ function wrapMdc(body: string): string { return `---\ndescription: ${SKILL_DESCRIPTION}\nalwaysApply: false\n---\n\n${body}\n`; } +/** + * Windsurf (Cascade) reads workspace rules from `.windsurf/rules/*.md` with + * YAML frontmatter. `trigger: model_decision` is the Cascade equivalent of the + * Cursor `.mdc` `alwaysApply: false` mode: only the `description` is surfaced + * up front, and Cascade pulls in the full rule body when the description shows + * it is relevant — exactly the on-demand activation this verification skill + * wants. (The other triggers are `always_on`, `manual`, and `glob`.) + * Workspace rule files have a 12 KB budget, well above this skill's size. + */ +function wrapWindsurf(body: string): string { + return `---\ntrigger: model_decision\ndescription: ${SKILL_DESCRIPTION}\n---\n\n${body}\n`; +} + export const TARGETS: Record = { claude: { status: 'ga', @@ -58,6 +80,16 @@ export const TARGETS: Record = { mode: 'own-file', wrap: body => body, }, + windsurf: { + status: 'experimental', + path: '.windsurf/rules/testsprite-verify.md', + mode: 'own-file', + // Windsurf workspace rules are budget-capped (12 K chars for a + // `.windsurf/rules/*.md` file); the full skill body (~21 KB) would be + // truncated, so render the compact variant — it still fits comfortably. + compactBody: true, + wrap: wrapWindsurf, + }, /** * codex target — managed-section mode. * @@ -124,7 +156,7 @@ export function renderForTarget(t: AgentTarget, body?: string): { path: string; const resolvedBody = body !== undefined ? body - : spec.mode === 'managed-section' + : spec.mode === 'managed-section' || spec.compactBody === true ? loadCodexSkillBody() : loadSkillBody(); return { path: spec.path, content: spec.wrap(resolvedBody) }; diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index b4ea59b..c2a59c6 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, Codex) +Cline, Windsurf, Antigravity, Codex) Options: -h, --help display help for command @@ -25,8 +25,8 @@ Write the TestSprite verification-loop skill file into a project for a coding agent Options: - --target Agent target(s): claude, cursor, cline, antigravity, codex - (comma-separated or repeated) (default: []) + --target Agent target(s): claude, cursor, cline, windsurf, antigravity, + codex (comma-separated or repeated) (default: []) --dir Project root to write into (default: cwd) --force For own-file targets: overwrite existing file (a .bak backup is kept). For codex (managed-section): replaces the section @@ -110,7 +110,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, codex (default: claude) (default: "claude") + cursor, cline, windsurf, 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 @@ -579,8 +580,8 @@ Commands: project Manage TestSprite projects test Inspect TestSprite tests agent Install TestSprite guidance into coding-agent - config (Claude Code, Cursor, Cline, Antigravity, - Codex) + config (Claude Code, Cursor, Cline, Windsurf, + Antigravity, Codex) usage|credits Show credit balance and plan/entitlement info (proactive pre-flight before a large test run) help [command] display help for command diff --git a/test/e2e/agent-install.e2e.test.ts b/test/e2e/agent-install.e2e.test.ts index c32c8bc..3219083 100644 --- a/test/e2e/agent-install.e2e.test.ts +++ b/test/e2e/agent-install.e2e.test.ts @@ -161,13 +161,20 @@ describe('content integrity', () => { content.trimStart().startsWith('#'), `cline: should start with a markdown heading`, ).toBe(true); + } else if (target === 'windsurf') { + // Windsurf Cascade frontmatter: trigger + description (no name/alwaysApply) + expect(content.startsWith('---'), `windsurf: should start with ---`).toBe(true); + expect(content).toContain('trigger: model_decision'); } - // (b) branding — the renamed H1 must be present. - + // (b) branding — the renamed H1 must be present in every body variant. expect(content).toContain('TestSprite Verification Loop'); - // Match the verification-loop intro line used in the asset - expect(content).toContain('The verification loop that flies'); + // The verification-loop intro line lives only in the FULL body; compact- + // body targets (e.g. windsurf, budget-capped) ship the trimmed body and + // legitimately omit it. + if (!TARGETS[target].compactBody) { + expect(content).toContain('The verification loop that flies'); + } // (c) Load-bearing command strings — a body trim that drops these must fail CI expect(content, `${target}: missing 'testsprite test run'`).toContain('testsprite test run'); @@ -381,13 +388,13 @@ describe('dry-run', () => { // --------------------------------------------------------------------------- describe('multi-target install', () => { - it('--target=claude,cursor,cline,antigravity,codex writes all five targets, exit 0', () => { + it('--target=claude,cursor,cline,windsurf,antigravity,codex writes all six targets, exit 0', () => { const tmpDir = freshTmpDir(); const result = runCli([ 'agent', 'install', - '--target=claude,cursor,cline,antigravity,codex', + '--target=claude,cursor,cline,windsurf,antigravity,codex', '--dir', tmpDir, '--output', @@ -400,7 +407,14 @@ describe('multi-target install', () => { action: string; path: string; }>; - const allTargets: AgentTarget[] = ['claude', 'cursor', 'cline', 'antigravity', 'codex']; + const allTargets: AgentTarget[] = [ + 'claude', + 'cursor', + 'cline', + 'windsurf', + 'antigravity', + 'codex', + ]; for (const target of allTargets) { const entry = parsed.find(r => r.target === target); @@ -636,7 +650,14 @@ describe('managed-section (codex target)', () => { // --------------------------------------------------------------------------- describe('matrix coverage guard', () => { it('TARGETS matches the documented, e2e-covered set (update this list when adding a target)', () => { - expect(Object.keys(TARGETS)).toEqual(['claude', 'antigravity', 'cursor', 'cline', 'codex']); + expect(Object.keys(TARGETS)).toEqual([ + 'claude', + 'antigravity', + 'cursor', + 'cline', + 'windsurf', + 'codex', + ]); }); }); diff --git a/test/e2e/setup.e2e.test.ts b/test/e2e/setup.e2e.test.ts index 961d2dc..f92bf6e 100644 --- a/test/e2e/setup.e2e.test.ts +++ b/test/e2e/setup.e2e.test.ts @@ -216,7 +216,14 @@ describe('deprecated `init` alias', () => { describe('matrix coverage guard', () => { it('TARGETS matches the documented set (update this list when adding a target)', () => { - expect(Object.keys(TARGETS)).toEqual(['claude', 'antigravity', 'cursor', 'cline', 'codex']); + expect(Object.keys(TARGETS)).toEqual([ + 'claude', + 'antigravity', + 'cursor', + 'cline', + 'windsurf', + 'codex', + ]); }); });