diff --git a/bin/codecept.js b/bin/codecept.js index e270e2920..b176da9a8 100755 --- a/bin/codecept.js +++ b/bin/codecept.js @@ -78,8 +78,16 @@ program .command('init [path]') .description('Creates dummy config in current dir or [path]') .option('-y, --yes', 'skip prompts and use defaults (Playwright + chromium, BASE_URL env for url)') + .option('--agent ', 'set up an AI coding agent: claude, codex, cursor, opencode') .action(commandHandler('../lib/command/init.js')) +program + .command('init:agent [agent]') + .alias('init-agent') + .description('Set up CodeceptJS MCP server and skills for a coding agent: claude, codex, cursor, opencode') + .option('-y, --yes', 'skip prompts, requires [agent]') + .action(commandHandler('../lib/command/initAgent.js')) + program .command('check') .option(commandFlags.config.flag, commandFlags.config.description) diff --git a/docs/agents.md b/docs/agents.md index ea7153c88..621c516ea 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -25,7 +25,15 @@ CodeceptJS is token-efficient: it stores HTML, ARIA, logs, and HTTP request data ## Essential Setup -Two things make agent testing work: the **skills** that teach the agent CodeceptJS, and the **MCP server** that lets it drive the browser. Set both up once, from your project directory. +Two things make agent testing work: the **skills** that teach the agent CodeceptJS, and the **MCP server** that lets it drive the browser. Set both up once, from your project directory: + +```bash +npx codeceptjs init:agent claude +``` + +Use `codex`, `cursor`, or `opencode` instead of `claude` for other agents, or omit the name to pick from a list. `npx codeceptjs init` offers the same step at the end. + +### Manual setup Install the skills: diff --git a/docs/commands.md b/docs/commands.md index dff6a23b6..f27c5014b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -262,6 +262,16 @@ Or in provided path npx codecept init test ``` +## Init Agent + +Registers the CodeceptJS MCP server and installs CodeceptJS skills for a coding agent (`claude`, `codex`, `cursor`, `opencode`): + +```sh +npx codeceptjs init:agent claude +``` + +Omit the agent to pick it from a list. See [Agentic Testing](/agents). + ## Migrate Migrate your current `codecept.json` to `codecept.conf.js` diff --git a/docs/mcp.md b/docs/mcp.md index bb6585ebe..5469d1f1f 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -27,7 +27,13 @@ Two ways to launch the server: ## Configuration -Set up the MCP server in your client (Claude Desktop, Cursor, Continue, etc.): +For Claude Code, Codex, Cursor, or OpenCode, one command registers the server and installs the CodeceptJS skills: + +```bash +npx codeceptjs init:agent claude +``` + +To set up the MCP server in another client (Claude Desktop, Continue, etc.), add it to the client config: ### Basic diff --git a/lib/command/init.js b/lib/command/init.js index 8aa310ed7..7a18e6def 100644 --- a/lib/command/init.js +++ b/lib/command/init.js @@ -12,6 +12,7 @@ import { fileExists, beautify, installedLocally } from '../utils.js' import { getTestRoot } from './utils.js' import generateDefinitions from './definitions.js' import { test as generateTest } from './generate.js' +import initAgent, { agents } from './initAgent.js' const isLocal = installedLocally() const defaultConfig = { @@ -318,6 +319,27 @@ export default async function (initPath, options = {}) { print(generateDefinitionsManually) } + let agentName = options.agent + if (!agentName && !skipPrompts) { + const answer = await inquirer.prompt([ + { + name: 'agent', + type: 'list', + message: 'Set up an AI coding agent?', + choices: [...Object.keys(agents), 'none'], + default: 'none', + }, + ]) + agentName = answer?.agent + } + + if (agentName && agentName !== 'none') { + await initAgent(agentName, { yes: true }) + } else { + print() + print(`Using an AI agent? Run: ${colors.green('npx codeceptjs init:agent')}`) + } + print('') success(' Almost ready... Next step:') diff --git a/lib/command/initAgent.js b/lib/command/initAgent.js new file mode 100644 index 000000000..f80a71a3f --- /dev/null +++ b/lib/command/initAgent.js @@ -0,0 +1,78 @@ +import fs from 'fs' +import path from 'path' +import inquirer from 'inquirer' +import { execSync } from 'child_process' + +import output from '../output.js' + +const { print, success, error } = output + +export const agents = { + claude: { + skills: 'claude-code', + mcp: 'claude mcp add codeceptjs -- npx codeceptjs-mcp', + }, + codex: { + skills: 'codex', + mcp: 'codex mcp add codeceptjs -- npx codeceptjs-mcp', + }, + cursor: { + skills: 'cursor', + file: '.cursor/mcp.json', + key: 'mcpServers', + server: { command: 'npx', args: ['codeceptjs-mcp'] }, + }, + opencode: { + skills: 'opencode', + file: 'opencode.json', + key: 'mcp', + server: { type: 'local', command: ['npx', 'codeceptjs-mcp'], enabled: true }, + }, +} + +export default async function (agentName, options = {}) { + if (!agentName && !options.yes) { + const answer = await inquirer.prompt([{ name: 'agent', type: 'list', message: 'Which coding agent do you use?', choices: Object.keys(agents) }]) + agentName = answer.agent + } + + const agent = agents[agentName] + if (!agent) { + error(`Agent must be one of: ${Object.keys(agents).join(', ')}`) + process.exitCode = 1 + return + } + + let ready = true + try { + if (agent.mcp) { + execSync(agent.mcp, { stdio: 'inherit' }) + } else { + addServerToFile(agent) + print(`Added codeceptjs MCP server to ${agent.file}`) + } + } catch (err) { + ready = false + error(`Could not register the codeceptjs MCP server: ${err.message}`) + if (agent.mcp) print(`Run: ${agent.mcp}`) + if (agent.file) print(`Add to "${agent.key}" in ${agent.file}: ${JSON.stringify({ codeceptjs: agent.server })}`) + } + + const skills = `npx skills add codeceptjs/skills -a ${agent.skills} -y` + try { + execSync(skills, { stdio: 'inherit' }) + } catch (err) { + ready = false + error(`Could not install skills. Run: ${skills}`) + } + + if (ready) success(`Start ${agentName} in this project. CodeceptJS skills and MCP are ready.`) +} + +function addServerToFile(agent) { + let config = {} + if (fs.existsSync(agent.file)) config = JSON.parse(fs.readFileSync(agent.file, 'utf8')) + config[agent.key] = { ...config[agent.key], codeceptjs: agent.server } + fs.mkdirSync(path.dirname(agent.file), { recursive: true }) + fs.writeFileSync(agent.file, `${JSON.stringify(config, null, 2)}\n`) +} diff --git a/test/unit/command/initAgent_test.js b/test/unit/command/initAgent_test.js new file mode 100644 index 000000000..e619f55cd --- /dev/null +++ b/test/unit/command/initAgent_test.js @@ -0,0 +1,124 @@ +import { expect } from 'chai' +import fs from 'fs' +import os from 'os' +import path from 'path' +import initAgent from '../../../lib/command/initAgent.js' + +describe('init:agent command', () => { + let tempDir + let binDir + let logFile + let originalCwd + let originalPath + + const fakeBin = (name, exitCode = 0) => { + const file = path.join(binDir, name) + fs.writeFileSync(file, `#!/bin/sh\necho "${name} $*" >> "${logFile}"\nexit ${exitCode}\n`) + fs.chmodSync(file, 0o755) + } + + const calls = () => { + if (!fs.existsSync(logFile)) return [] + return fs.readFileSync(logFile, 'utf8').trim().split('\n') + } + + const readJson = file => JSON.parse(fs.readFileSync(path.join(tempDir, file), 'utf8')) + + before(function () { + if (process.platform === 'win32') this.skip() + }) + + beforeEach(() => { + originalCwd = process.cwd() + originalPath = process.env.PATH + tempDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'codecept-init-agent-'))) + binDir = path.join(tempDir, '.bin') + logFile = path.join(binDir, 'calls.log') + fs.mkdirSync(binDir) + process.env.PATH = `${binDir}${path.delimiter}${originalPath}` + process.chdir(tempDir) + fakeBin('claude') + fakeBin('codex') + fakeBin('npx') + process.exitCode = 0 + }) + + afterEach(() => { + process.chdir(originalCwd) + process.env.PATH = originalPath + process.exitCode = 0 + fs.rmSync(tempDir, { recursive: true, force: true }) + }) + + it('claude: registers MCP with claude CLI and installs skills', async () => { + await initAgent('claude', { yes: true }) + + expect(calls()).to.deep.equal(['claude mcp add codeceptjs -- npx codeceptjs-mcp', 'npx skills add codeceptjs/skills -a claude-code -y']) + expect(fs.readdirSync(tempDir)).to.deep.equal(['.bin']) + }) + + it('codex: registers MCP with codex CLI and installs skills', async () => { + await initAgent('codex', { yes: true }) + + expect(calls()).to.deep.equal(['codex mcp add codeceptjs -- npx codeceptjs-mcp', 'npx skills add codeceptjs/skills -a codex -y']) + }) + + it('installs skills even when MCP registration fails', async () => { + fakeBin('claude', 1) + + await initAgent('claude', { yes: true }) + + expect(calls()).to.include('npx skills add codeceptjs/skills -a claude-code -y') + }) + + it('cursor: writes .cursor/mcp.json and keeps other servers', async () => { + fs.mkdirSync(path.join(tempDir, '.cursor')) + fs.writeFileSync(path.join(tempDir, '.cursor/mcp.json'), JSON.stringify({ mcpServers: { other: { command: 'other' } } })) + + await initAgent('cursor', { yes: true }) + + expect(readJson('.cursor/mcp.json')).to.deep.equal({ + mcpServers: { + other: { command: 'other' }, + codeceptjs: { command: 'npx', args: ['codeceptjs-mcp'] }, + }, + }) + expect(calls()).to.deep.equal(['npx skills add codeceptjs/skills -a cursor -y']) + }) + + it('opencode: writes opencode.json, second run gives the same file', async () => { + fs.writeFileSync(path.join(tempDir, 'opencode.json'), JSON.stringify({ theme: 'dark' })) + + await initAgent('opencode', { yes: true }) + const first = fs.readFileSync(path.join(tempDir, 'opencode.json'), 'utf8') + await initAgent('opencode', { yes: true }) + + expect(fs.readFileSync(path.join(tempDir, 'opencode.json'), 'utf8')).to.equal(first) + expect(readJson('opencode.json')).to.deep.equal({ + theme: 'dark', + mcp: { codeceptjs: { type: 'local', command: ['npx', 'codeceptjs-mcp'], enabled: true } }, + }) + }) + + it('leaves unparseable JSON untouched', async () => { + const jsonc = '{\n // comment\n "mcp": {}\n}\n' + fs.writeFileSync(path.join(tempDir, 'opencode.json'), jsonc) + + await initAgent('opencode', { yes: true }) + + expect(fs.readFileSync(path.join(tempDir, 'opencode.json'), 'utf8')).to.equal(jsonc) + }) + + it('fails on unknown agent', async () => { + await initAgent('vim', { yes: true }) + + expect(process.exitCode).to.equal(1) + expect(calls()).to.deep.equal([]) + }) + + it('fails with --yes and no agent', async () => { + await initAgent(undefined, { yes: true }) + + expect(process.exitCode).to.equal(1) + }) +})