Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions bin/codecept.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>', '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)
Expand Down
10 changes: 9 additions & 1 deletion docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
10 changes: 10 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
8 changes: 7 additions & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions lib/command/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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:')

Expand Down
78 changes: 78 additions & 0 deletions lib/command/initAgent.js
Original file line number Diff line number Diff line change
@@ -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`)
}
124 changes: 124 additions & 0 deletions test/unit/command/initAgent_test.js
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading