Skip to content
Merged
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
30 changes: 18 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,24 @@ Exit codes: `0` no errors (warnings allowed), `1` errors found, `2` bad input or

## Rules

<!-- rules:start -->
| 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 `await new Promise(r => setTimeout(r, ms))` and `timers/promises`, outside helpers |
| `no-if-in-test` | error | `if` inside a Scenario or hook. Use `tryTo()` from `codeceptjs/effects` |
| `no-fetch` | error | `fetch()` outside helpers. Use the REST helper: `I.sendGetRequest`, `I.sendPostRequest` |
| `no-console` | warn | `console.*` outside helpers. Use `I.say()` |
| `no-only` | error on CI, warn locally | `Scenario.only`, `Feature.only`, `Data(...).only` |
| `no-pause` | error on CI, warn locally | `pause()` |
| `no-skip` | warn | `Scenario.skip`, `Feature.skip`, `xScenario`, `xFeature` |
| `secret-credentials` | error | `I.fillField` on a password, token, secret or API key field, or such a `process.env.*` passed to `I.*`, without `secret()` |
| `await-grab` | error | `I.grab*()` assigned or used without `await` |
| `no-actor-in-helper` | error | `I` inside a class extending `Helper`. Use `this.helpers[...]` |
| `raw-browser-in-test` | warn | `I.use*To`, `I.executeScript` in a Scenario. Move it into a helper or page object |
| `no-fixed-wait` | error | I.wait(5) sleeps for a fixed time. Wait for a condition with I.waitForElement, I.waitForText or I.see |
| `no-sleep` | error | setTimeout, including window.setTimeout, await new Promise(r => setTimeout(r, ms)) and timers/promises, sleeps for a fixed time. Wait for a condition instead |
| `no-if-in-test` | error | if inside a Scenario or hook makes the test non-deterministic. Use tryTo() from codeceptjs/effects for optional steps |
| `no-fetch` | error | fetch() in tests or page objects, including window.fetch and globalThis.fetch, bypasses CodeceptJS steps and logs. Use the REST helper: I.sendGetRequest, I.sendPostRequest |
| `no-console` | warn | console.log in tests or page objects is not part of the test report. Use I.say() |
| `no-only` | error on CI, warn locally | Scenario.only, Feature.only and Data().only run only focused tests. Error on CI, warning locally |
| `no-pause` | error on CI, warn locally | pause() and this.pause() stop the test for interactive debugging. Error on CI, warning locally |
| `no-skip` | warn | Scenario.skip, Feature.skip, xScenario and xFeature leave tests out of the run |
| `secret-credentials` | error | Passwords, tokens and API keys passed to I.* in plain text are printed in logs. Wrap them with secret() |
| `await-grab` | error | I.grab*() returns a promise. Assigned, destructured or passed anywhere without await — including as an argument or object property, but not in Promise.all or .then() chains — it is a bug |
| `no-actor-in-helper` | error | I is not available inside a helper. Call other helpers via this.helpers[...] |
| `raw-browser-in-test` | warn | I.usePlaywrightTo, I.useWebDriverTo and I.executeScript in a Scenario. Move raw browser code into a helper or page object |
<!-- rules:end -->

Rules live as one file per rule in [`lib/rules/`](lib/rules); run `npm run docs` to regenerate this table after changing a rule.

"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.

Expand Down Expand Up @@ -67,6 +71,8 @@ I.wait(1)

TypeScript is read with Node's type stripping (Node 22.13+). Files that use `enum` or `namespace` are reported as parse errors.

A TypeScript config that imports `.js` paths of `.ts` files (the tsx/esbuild convention) can't be loaded by plain Node. If the linted project has `tsx` installed, the linter registers its loader automatically; otherwise loading such a config falls back to linting without it — pass paths explicitly in that case.

## CI

```yaml
Expand Down
6 changes: 3 additions & 3 deletions bin/lint.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import fs from 'fs'
import path from 'path'
import { parseArgs } from 'util'
import Linter from '../lib/Linter.js'
import rules from '../lib/rules.js'
import rules from '../lib/rules/index.js'

const { values: options, positionals: paths } = parseArgs({
allowPositionals: true,
Expand Down Expand Up @@ -69,7 +69,7 @@ for (const file of files) {
findings.push(...linter.lintFile(file))
} catch (err) {
process.exitCode = 2
console.error(`${path.relative(process.cwd(), file)} parse error ${err.message}`)
console.error(err.file ? `parse error ${err.message}` : `${path.relative(process.cwd(), file).replaceAll('\\', '/')} parse error ${err.message}`)
}
}

Expand Down Expand Up @@ -124,5 +124,5 @@ async function hook(linter) {
}

function format(finding) {
return `${path.relative(process.cwd(), finding.file)}:${finding.line}:${finding.column} ${finding.level} ${finding.rule} ${finding.message}`
return `${path.relative(process.cwd(), finding.file).replaceAll('\\', '/')}:${finding.line}:${finding.column} ${finding.level} ${finding.rule} ${finding.message}`
}
47 changes: 31 additions & 16 deletions lib/Linter.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import { pathToFileURL } from 'url'
import * as acorn from 'acorn'
import * as walk from 'acorn-walk'
import { globSync } from 'glob'
import rules from './rules.js'
import rules from './rules/index.js'

const EXTENSIONS = ['.js', '.ts', '.mjs', '.cjs']
const CONFIG_FILES = ['codecept.conf.js', 'codecept.conf.ts', 'codecept.conf.cjs', 'codecept.conf.mjs', 'codecept.config.js', 'codecept.config.ts']
const ACORN_OPTIONS = { ecmaVersion: 'latest', locations: true }
const MAX_SOURCE = 120

export default class Linter {
constructor(config = {}, root = process.cwd(), only = []) {
Expand All @@ -24,6 +26,7 @@ export default class Linter {
if (!file) file = CONFIG_FILES.find(name => fs.existsSync(name))
if (!file) return new Linter({}, process.cwd(), only)
file = path.resolve(file)
if (file.endsWith('.ts')) await registerTsx(file)
const mod = await import(pathToFileURL(file))
const config = mod.config || mod.default?.config || mod.default || {}
return new Linter(config, path.dirname(file), only)
Expand All @@ -49,7 +52,7 @@ export default class Linter {
}
files.push(...this.supportFiles())
}
return [...new Set(files)].filter(file => EXTENSIONS.includes(path.extname(file)))
return [...new Set(files)].filter(file => EXTENSIONS.includes(path.extname(file))).map(file => file.replaceAll('\\', '/'))
}

includes(file) {
Expand Down Expand Up @@ -83,21 +86,16 @@ export default class Linter {
const comments = []
let ast
try {
ast = acorn.parse(code, {
ecmaVersion: 'latest',
sourceType: 'module',
locations: true,
onComment: comments,
})
ast = acorn.parse(code, { ...ACORN_OPTIONS, sourceType: 'module', onComment: comments })
} catch (err) {
comments.length = 0
ast = acorn.parse(code, {
ecmaVersion: 'latest',
sourceType: 'script',
locations: true,
allowReturnOutsideFunction: true,
onComment: comments,
})
try {
ast = acorn.parse(code, { ...ACORN_OPTIONS, sourceType: 'script', allowReturnOutsideFunction: true, onComment: comments })
} catch (scriptErr) {
scriptErr.file = file
scriptErr.message = `${file}: ${scriptErr.message}`
throw scriptErr
}
}

const disabled = []
Expand All @@ -123,10 +121,27 @@ export default class Linter {
rule: rule.id,
level,
message,
source: code.slice(node.start, node.end),
source: truncate(code.slice(node.start, node.end)),
})
}
})
return findings.sort((a, b) => a.line - b.line || a.column - b.column)
}
}

function truncate(source) {
const line = source.split('\n')[0]
return line.length > MAX_SOURCE ? `${line.slice(0, MAX_SOURCE - 3)}...` : line
}

// TS configs often import './x.js' for an './x.ts' file, which plain Node cannot resolve.
// If the linted project has tsx installed, register its loader hooks so the config imports work.
async function registerTsx(configFile) {
try {
const require = module.createRequire(configFile)
const tsx = await import(pathToFileURL(require.resolve('tsx/esm/api')).href)
await tsx.register()
} catch {
// tsx not installed — configs limited to plain Node resolution
}
}
210 changes: 0 additions & 210 deletions lib/rules.js

This file was deleted.

18 changes: 18 additions & 0 deletions lib/rules/await-grab.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Rule } from './base.js'
import { actorMethod } from './utils.js'

const FLAGGED_PARENTS = ['VariableDeclarator', 'AssignmentExpression', 'BinaryExpression', 'TemplateLiteral', 'MemberExpression', 'CallExpression', 'Property']

export class AwaitGrab extends Rule {
id = 'await-grab'
description = 'I.grab*() returns a promise. Assigned, destructured or passed anywhere without await — including as an argument or object property, but not in Promise.all or .then() chains — it is a bug'

check(node, ancestors) {
const method = actorMethod(node)
if (!method || !method.startsWith('grab')) return
const parent = ancestors[ancestors.length - 2]
if (parent.type === 'MemberExpression' && parent.object === node && ['then', 'catch', 'finally'].includes(parent.property?.name)) return
if (!FLAGGED_PARENTS.includes(parent.type)) return
return `I.${method}() returns a promise. Use: await I.${method}()`
}
}
Loading
Loading