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 @@ -108,6 +108,14 @@ program
.option('--action <name>', 'show docs for a single action (e.g. amOnPage or I.amOnPage)')
.action(commandHandler('../lib/command/list.js'))

program
.command('lint [paths...]')
.description('Checks tests, page objects and helpers for CodeceptJS anti-patterns')
.option(commandFlags.config.flag, commandFlags.config.description)
.option('--json', 'print findings as JSON')
.option('--hook <agent>', 'run as a coding agent pre-write hook reading the payload from stdin (supported: claude)')
.action(commandHandler('../lib/command/lint.js'))

program
.command('def [path]')
.description('Generates TypeScript definitions for all I actions.')
Expand Down
2 changes: 2 additions & 0 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ codex mcp add codeceptjs -- npx codeceptjs-mcp

See [/mcp](/mcp) for full client setup. Now the agent is ready to run the loop.

Optionally, add a lint hook. Skills tell the agent what not to do; `npx codeceptjs lint --hook claude` enforces it. As a Claude Code `PreToolUse` hook, it blocks an edit that adds a fixed `I.wait(5)`, an un-awaited grabber or a plain-text password, and returns the reason so the agent rewrites the edit. Setup and rules are in [/lint](/lint).

## The loop

Whether the agent is writing a new test or fixing an old one, it follows the same cycle.
Expand Down
90 changes: 90 additions & 0 deletions docs/lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
permalink: /lint
title: Lint
---

# Lint

`codeceptjs lint` checks tests, page objects and helpers for CodeceptJS anti-patterns: fixed sleeps, missing `await` on grabbers, plain-text credentials, leftover `pause()` and `.only`. It parses files into an AST, so it never runs a browser and finishes in a second.

```bash
npx codeceptjs lint # tests, include and helpers from codecept.conf.js
npx codeceptjs lint tests/checkout_test.js pages/
npx codeceptjs lint --json # machine-readable output
npx codeceptjs lint -c path/to/codecept.conf.js
```

Each finding is printed on one line:

```
tests/checkout_test.js:14:3 error no-fixed-wait I.wait(5) sleeps unconditionally. Wait for a condition: I.waitForElement / I.waitForText / I.see
```

Without paths, lint checks files matched by `tests`, local files from `include` (page objects, steps file) and custom helpers loaded with `require`. JavaScript and TypeScript files are supported.

Exit codes: `0` no errors (warnings allowed), `1` errors found, `2` bad input or a file that can't be parsed.

## Rules

| Rule | Default | Detects |
| --- | --- | --- |
| `no-fixed-wait` | error | `I.wait(5)` with a number. Use `I.waitForElement`, `I.waitForText`, `I.see` |
| `no-sleep` | error | `setTimeout` (including `new Promise(r => setTimeout(r, ms))`) in a Scenario, hook or page object method |
| `no-only` | error on CI, warning locally | `Scenario.only`, `Feature.only`, `Data(...).only.Scenario` |
| `no-pause` | error on CI, warning locally | `pause()` |
| `secret-credentials` | error | `I.fillField` on a password, token, secret or API key field without `secret()`; `process.env.*` with such a name passed to an `I.*` call without `secret()` |
| `await-grab` | error | `I.grab*()` result assigned, returned or passed on without `await` |
| `no-actor-in-helper` | error | `I` (including `const { I } = inject()`) inside a class extending `Helper`. Use `this.helpers[...]` |
| `raw-browser-in-test` | warning | `I.usePlaywrightTo`, `I.usePuppeteerTo`, `I.useWebDriverTo` and other `use*To`, `I.executeScript` in a Scenario body. Move it into a helper or page object |

"On CI" means the `CI` environment variable is set, which every CI provider does. Locally `pause()` and `.only` stay warnings, so a debugging stub doesn't fail the lint.

## Configuration

Add an optional `lint` section to `codecept.conf.js`:

```js
lint: {
rules: { 'raw-browser-in-test': 'off', 'no-fixed-wait': 'warn' },
ignore: ['tests/legacy/**'],
}
```

Rule levels are `error`, `warn` or `off`. `ignore` takes glob patterns relative to the config file.

To allow a single case, suppress it inline. The rule id is required:

```js
I.wait(1) // codeceptjs-lint-disable-line no-fixed-wait

// codeceptjs-lint-disable-next-line no-fixed-wait
I.wait(1)
```

## CI

Run lint before the tests:

```yaml
- run: npx codeceptjs lint
- run: npx codeceptjs run
```

## Claude Code Hook

Lint can block a bad edit before an agent writes it. Add a `PreToolUse` hook to `.claude/settings.json`:

```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [{ "type": "command", "command": "npx codeceptjs lint --hook claude" }]
}
]
}
}
```

The hook builds the file as it would look after the edit and lints it. The edit is blocked only when it adds a new error. The agent receives the findings and rewrites the edit. Errors already in the file and warnings never block, so an agent can still add a `pause()` stub or touch a legacy test. If the hook fails or the resulting file can't be parsed, the edit is allowed.
196 changes: 196 additions & 0 deletions lib/command/lint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import fs from 'fs'
import path from 'path'
import output from '../output.js'
import Config from '../config.js'
import { captureStream } from './utils.js'
import { LINT_EXTENSIONS, collectFiles, isIgnored, lintFile, lintOptions, lintSource, newErrors } from '../lint.js'

const HOOK_AGENTS = ['claude']
const CONFIG_NAMES = ['codecept.config.js', 'codecept.conf.js', 'codecept.js', 'codecept.config.cjs', 'codecept.conf.cjs', 'codecept.config.ts', 'codecept.conf.ts']

function findConfig(dir) {
return CONFIG_NAMES.map(name => path.join(dir, name)).find(f => fs.existsSync(f)) || null
}

async function loadConfig(configPath, dir) {
const file = configPath ? path.resolve(configPath) : findConfig(dir)
if (!file) return { config: null, root: dir }
const root = fs.existsSync(file) && fs.statSync(file).isDirectory() ? file : path.dirname(file)
return { config: await Config.load(file), root }
}

function relative(file) {
const rel = path.relative(process.cwd(), file)
return rel.startsWith('..') ? file : rel
}

function formatFinding(f, colors = true) {
const level = f.level === 'error' ? 'error' : 'warn '
const levelText = colors ? (f.level === 'error' ? output.colors.red(level) : output.colors.yellow(level)) : level
const location = `${relative(f.file)}:${f.line}:${f.column}`
return `${colors ? output.colors.bold(location) : location} ${levelText} ${colors ? output.colors.grey(f.rule) : f.rule} ${f.message}`
}

export default async function lint(paths = [], options = {}) {
if (options.hook) return runHookCommand(options)

let loaded
try {
loaded = await loadConfig(options.config, process.cwd())
} catch (err) {
output.error(`Can't load config: ${err.message}`)
process.exitCode = 2
return
}
const { config, root } = loaded
if (!config && !paths.length) {
output.error('No codecept config found. Pass files to lint or use -c to point to a config')
process.exitCode = 2
return
}

const files = collectFiles(config || {}, root, paths)
const opts = lintOptions(config || {})
const findings = []
const failures = []
const skipped = []

for (const file of files) {
try {
const result = await lintFile(file, opts)
if (result.skipped) skipped.push({ file, reason: result.skipped })
findings.push(...result.findings)
} catch (err) {
failures.push({ file, message: err.message })
}
}

const errors = findings.filter(f => f.level === 'error').length
const warnings = findings.length - errors

if (options.json) {
process.stdout.write(`${JSON.stringify({ files: files.length, errors, warnings, findings, failures, skipped }, null, 2)}\n`)
} else {
for (const f of findings) output.print(formatFinding(f))
for (const s of skipped) output.print(`${output.colors.bold(relative(s.file))} ${output.colors.yellow('skip ')} ${s.reason}`)
for (const e of failures) output.print(`${output.colors.bold(relative(e.file))} ${output.colors.red('parse')} ${e.message}`)
const summary = `${files.length} file(s) checked, ${errors} error(s), ${warnings} warning(s)`
output.print(errors || failures.length ? output.colors.red(summary) : output.colors.green(summary))
}

if (failures.length || (!files.length && paths.length)) process.exitCode = 2
else if (errors) process.exitCode = 1
}

function readStdin() {
return new Promise((resolve, reject) => {
let data = ''
process.stdin.setEncoding('utf8')
process.stdin.on('data', chunk => (data += chunk))
process.stdin.on('end', () => resolve(data))
process.stdin.on('error', reject)
})
}

function applyEdit(content, oldString, newString, replaceAll) {
if (typeof oldString !== 'string' || typeof newString !== 'string') return null
if (oldString === '') return content === '' ? newString : null
if (!content.includes(oldString)) return null
return replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, () => newString)
}

function resultingContent(toolName, input, current) {
if (toolName === 'Write') return typeof input.content === 'string' ? input.content : null
if (toolName === 'Edit') return applyEdit(current, input.old_string, input.new_string, input.replace_all)
if (toolName === 'MultiEdit') {
let content = current
for (const edit of input.edits || []) {
content = applyEdit(content, edit.old_string, edit.new_string, edit.replace_all)
if (content === null) return null
}
return content
}
return null
}

export async function runHook(payload, { agent = 'claude', config: configPath } = {}) {
const allow = { code: 0, stderr: '' }
if (!HOOK_AGENTS.includes(agent)) return { code: 0, stderr: `codeceptjs lint: unsupported hook agent "${agent}", supported: ${HOOK_AGENTS.join(', ')}\n` }
if (!payload || typeof payload !== 'object') return allow

const toolName = payload.tool_name
const input = payload.tool_input || {}
if (!['Write', 'Edit', 'MultiEdit'].includes(toolName) || typeof input.file_path !== 'string') return allow

const projectDir = path.resolve(process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd())
const file = path.resolve(payload.cwd || projectDir, input.file_path)
const rel = path.relative(projectDir, file)
if (rel.startsWith('..') || path.isAbsolute(rel) || rel.split(path.sep).includes('node_modules')) return allow
if (!LINT_EXTENSIONS.includes(path.extname(file))) return allow

let config = {}
let root = projectDir
const stdout = captureStream(process.stdout)
stdout.startCapture()
try {
const loaded = await loadConfig(configPath, projectDir)
if (loaded.config) {
config = loaded.config
root = loaded.root
}
} catch {
config = {}
} finally {
stdout.stopCapture()
}
if (isIgnored(file, config, root)) return allow

const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : ''
const after = resultingContent(toolName, input, current)
if (after === null) return allow

const opts = lintOptions(config)
let afterResult
try {
afterResult = await lintSource(after, file, opts)
} catch (err) {
return { code: 0, stderr: `codeceptjs lint: ${relative(file)} could not be parsed after this edit (${err.message})\n` }
}
if (afterResult.skipped) return allow

let beforeFindings = []
if (current) {
try {
beforeFindings = (await lintSource(current, file, opts)).findings
} catch {
beforeFindings = []
}
}

const added = newErrors(beforeFindings, afterResult.findings)
if (!added.length) return allow

const lines = added.map(f => formatFinding(f, false))
return {
code: 2,
stderr: `codeceptjs lint blocked this edit, ${added.length} new error(s):\n${lines.join('\n')}\nFix the code and retry.\n`,
}
}

async function runHookCommand(options) {
let result = { code: 0, stderr: '' }
try {
const raw = await readStdin()
let payload = null
try {
payload = JSON.parse(raw)
} catch {
payload = null
}
result = await runHook(payload, { agent: options.hook, config: options.config })
} catch (err) {
result = { code: 0, stderr: `codeceptjs lint: hook failed (${err.message})\n` }
}
process.exitCode = result.code
process.stderr.write(result.stderr, () => process.exit(result.code))
}
Loading
Loading