Skip to content
Closed
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
89 changes: 89 additions & 0 deletions docs/lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
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. TypeScript is read with Node type stripping, so files using `enum` or `namespace` are reported as parse errors.

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))`) outside helper classes |
| `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 or used 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' },
}
```

Rule levels are `error`, `warn` or `off`.

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 checks only files that `codeceptjs lint` would check: tests, `include` files and helpers from the config. It 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.
91 changes: 91 additions & 0 deletions lib/command/lint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import fs from 'fs'
import path from 'path'
import output from '../output.js'
import Config from '../config.js'
import Linter from '../lint.js'
import { getTestRoot } from './utils.js'

export default async function (paths = [], options = {}) {
let config = {}
try {
config = await Config.load(options.config)
} catch (err) {
if (options.hook) return
if (!paths.length || options.config) {
output.error(err.message)
process.exitCode = 2
return
}
}
const linter = new Linter(config, getTestRoot(options.config))

if (options.hook) return hook(linter)

const findings = []
let failed = false
const files = linter.files(paths)
for (const file of files) {
try {
findings.push(...linter.lintFile(file))
} catch (err) {
failed = true
output.print(`${path.relative(process.cwd(), file)} ${output.colors.red('parse')} ${err.message}`)
}
}

const errors = findings.filter(f => f.level === 'error').length
if (options.json) {
output.print(JSON.stringify(findings, null, 2))
} else {
for (const finding of findings) output.print(format(finding))
output.print(`${files.length} file(s) checked, ${errors} error(s), ${findings.length - errors} warning(s)`)
}

if (errors) process.exitCode = 1
if (failed) process.exitCode = 2
}

async function hook(linter) {
let data = ''
for await (const chunk of process.stdin) data += chunk

try {
const { tool_name: tool, tool_input: input } = JSON.parse(data)
const file = path.resolve(input.file_path)
if (!linter.includes(file)) return

let before = ''
if (fs.existsSync(file)) before = fs.readFileSync(file, 'utf8')

let after = input.content
if (tool !== 'Write') {
after = before
for (const edit of input.edits || [input]) {
if (edit.replace_all) after = after.replaceAll(edit.old_string, () => edit.new_string)
else after = after.replace(edit.old_string, () => edit.new_string)
}
}

const existing = linter
.lint(before, file)
.filter(f => f.level === 'error')
.map(f => `${f.rule}:${f.source}`)
const added = []
for (const finding of linter.lint(after, file)) {
if (finding.level !== 'error') continue
const index = existing.indexOf(`${finding.rule}:${finding.source}`)
if (index >= 0) existing.splice(index, 1)
else added.push(finding)
}
if (!added.length) return

process.stderr.write(`codeceptjs lint blocked this edit:\n${added.map(format).join('\n')}\nFix the code and retry.\n`)
process.exitCode = 2
} catch (err) {
process.exitCode = 0
}
}

function format(finding) {
return `${path.relative(process.cwd(), finding.file)}:${finding.line}:${finding.column} ${finding.level} ${finding.rule} ${finding.message}`
}
Loading
Loading