diff --git a/bin/codecept.js b/bin/codecept.js index e270e2920..cb8ec7e65 100755 --- a/bin/codecept.js +++ b/bin/codecept.js @@ -108,6 +108,14 @@ program .option('--action ', '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 ', '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.') diff --git a/docs/agents.md b/docs/agents.md index ea7153c88..2c4acc1e6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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. diff --git a/docs/lint.md b/docs/lint.md new file mode 100644 index 000000000..dc820d182 --- /dev/null +++ b/docs/lint.md @@ -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. diff --git a/lib/command/lint.js b/lib/command/lint.js new file mode 100644 index 000000000..1caf3a0c3 --- /dev/null +++ b/lib/command/lint.js @@ -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}` +} diff --git a/lib/lint.js b/lib/lint.js new file mode 100644 index 000000000..65c89f8a8 --- /dev/null +++ b/lib/lint.js @@ -0,0 +1,229 @@ +import fs from 'fs' +import path from 'path' +import module from 'module' +import * as acorn from 'acorn' +import * as walk from 'acorn-walk' +import { globSync } from 'glob' + +const EXTENSIONS = ['.js', '.ts', '.mjs', '.cjs'] +const TEST_BLOCKS = ['Scenario', 'Before', 'After', 'BeforeSuite', 'AfterSuite'] +const CREDENTIALS = /pass(word|wd)|(^|_)pass(_|$)|token|secret|api[\s_-]?key/i + +function actorMethod(node) { + if (node.type !== 'CallExpression') return + if (node.callee.type !== 'MemberExpression') return + if (node.callee.object.name !== 'I') return + return node.callee.property.name +} + +function insideTestBlock(ancestors) { + return ancestors.some(node => { + if (node.type !== 'CallExpression') return false + let callee = node.callee + if (callee.type === 'MemberExpression') callee = callee.object + return TEST_BLOCKS.includes(callee.name) + }) +} + +function insideHelper(ancestors) { + return ancestors.some(node => node.superClass && node.superClass.name === 'Helper') +} + +class Rule { + get level() { + return 'error' + } +} + +class NoFixedWait extends Rule { + id = 'no-fixed-wait' + + check(node) { + if (actorMethod(node) !== 'wait') return + if (typeof node.arguments[0]?.value !== 'number') return + return 'I.wait() sleeps unconditionally. Wait for a condition: I.waitForElement / I.waitForText / I.see' + } +} + +class NoSleep extends Rule { + id = 'no-sleep' + + check(node, ancestors) { + if (node.type !== 'CallExpression' || node.callee.name !== 'setTimeout') return + if (insideHelper(ancestors)) return + return 'setTimeout pauses for a fixed time. Wait for a condition: I.waitForElement / I.waitForText / I.waitForFunction' + } +} + +class NoOnly extends Rule { + id = 'no-only' + + get level() { + if (process.env.CI) return 'error' + return 'warn' + } + + check(node) { + if (node.type !== 'MemberExpression' || node.property.name !== 'only') return + const target = node.object.name || node.object.callee?.name + if (!['Scenario', 'Feature', 'Data'].includes(target)) return + return `${target}.only limits the run to focused tests. Remove it before commit` + } +} + +class NoPause extends Rule { + id = 'no-pause' + + get level() { + if (process.env.CI) return 'error' + return 'warn' + } + + check(node) { + if (node.type !== 'CallExpression' || node.callee.name !== 'pause') return + return 'pause() stops the test for debugging. Remove it before commit' + } +} + +class SecretCredentials extends Rule { + id = 'secret-credentials' + + check(node) { + const method = actorMethod(node) + if (!method) return + for (const arg of node.arguments) { + const env = arg.type === 'MemberExpression' && arg.object.property?.name === 'env' && arg.property.name + if (env && CREDENTIALS.test(env)) return `process.env.${env} is printed in logs. Wrap it: secret(process.env.${env})` + } + if (method !== 'fillField') return + const [locator, value] = node.arguments + if (typeof locator?.value !== 'string' || !CREDENTIALS.test(locator.value)) return + if (!value || value.callee?.name === 'secret') return + return `I.fillField('${locator.value}', ...) types a credential that is printed in logs. Wrap the value: secret(...)` + } +} + +class AwaitGrab extends Rule { + id = 'await-grab' + + check(node, ancestors) { + const method = actorMethod(node) + if (!method || !method.startsWith('grab')) return + const parent = ancestors[ancestors.length - 2] + if (!['VariableDeclarator', 'AssignmentExpression', 'BinaryExpression', 'TemplateLiteral', 'MemberExpression'].includes(parent.type)) return + return `I.${method}() returns a promise. Use: await I.${method}()` + } +} + +class NoActorInHelper extends Rule { + id = 'no-actor-in-helper' + + check(node, ancestors) { + if (node.type !== 'Identifier' || node.name !== 'I') return + if (!insideHelper(ancestors)) return + return 'I is not available inside a helper. Call other helpers via this.helpers[...]' + } +} + +class RawBrowserInTest extends Rule { + id = 'raw-browser-in-test' + + get level() { + return 'warn' + } + + check(node, ancestors) { + const method = actorMethod(node) + if (!method) return + if (method !== 'executeScript' && !/^use\w+To$/.test(method)) return + if (!insideTestBlock(ancestors)) return + return `I.${method} runs raw browser code in a test. Move it into a helper or page object` + } +} + +export const rules = [new NoFixedWait(), new NoSleep(), new NoOnly(), new NoPause(), new SecretCredentials(), new AwaitGrab(), new NoActorInHelper(), new RawBrowserInTest()] + +export default class Linter { + constructor(config = {}, root = process.cwd()) { + this.config = config + this.root = root + this.levels = config.lint?.rules || {} + } + + files(paths = []) { + const files = [] + for (const pattern of paths) { + if (fs.existsSync(pattern) && fs.statSync(pattern).isDirectory()) { + files.push(...globSync(`${pattern}/**/*.{js,ts,mjs,cjs}`, { absolute: true, ignore: '**/node_modules/**' })) + } else { + files.push(...globSync(pattern, { absolute: true })) + } + } + if (!paths.length) { + for (const pattern of [].concat(this.config.tests || [])) { + files.push(...globSync(pattern, { cwd: this.root, absolute: true })) + } + files.push(...this.supportFiles()) + } + return [...new Set(files)].filter(file => EXTENSIONS.includes(path.extname(file))) + } + + includes(file) { + for (const pattern of [].concat(this.config.tests || [])) { + if (path.matchesGlob(file, path.resolve(this.root, pattern))) return true + } + return this.supportFiles().includes(file) + } + + supportFiles() { + const entries = Object.values(this.config.include || {}) + for (const helper of Object.values(this.config.helpers || {})) entries.push(helper.require) + const files = [] + for (const entry of entries) { + if (typeof entry !== 'string' || !entry.startsWith('.')) continue + const file = path.resolve(this.root, entry) + for (const candidate of [file, ...EXTENSIONS.map(ext => file + ext)]) { + if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) files.push(candidate) + } + } + return files + } + + lintFile(file) { + return this.lint(fs.readFileSync(file, 'utf8'), file) + } + + lint(code, file) { + if (file.endsWith('.ts')) code = module.stripTypeScriptTypes(code) + + const comments = [] + let ast + try { + ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'module', locations: true, onComment: comments }) + } catch (err) { + comments.length = 0 + ast = acorn.parse(code, { ecmaVersion: 'latest', sourceType: 'script', locations: true, allowReturnOutsideFunction: true, onComment: comments }) + } + + const disabled = [] + for (const comment of comments) { + const [directive, rule] = comment.value.trim().split(/\s+/) + if (directive === 'codeceptjs-lint-disable-line') disabled.push(`${comment.loc.start.line}:${rule}`) + if (directive === 'codeceptjs-lint-disable-next-line') disabled.push(`${comment.loc.end.line + 1}:${rule}`) + } + + const findings = [] + walk.fullAncestor(ast, (node, state, ancestors) => { + for (const rule of rules) { + const level = this.levels[rule.id] || rule.level + if (level === 'off') continue + const message = rule.check(node, ancestors) + if (!message) continue + const { line, column } = node.loc.start + if (disabled.includes(`${line}:${rule.id}`)) continue + findings.push({ file, line, column: column + 1, rule: rule.id, level, message, source: code.slice(node.start, node.end) }) + } + }) + return findings.sort((a, b) => a.line - b.line || a.column - b.column) + } +} diff --git a/package.json b/package.json index d6771005d..5c25c2857 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,7 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@xmldom/xmldom": "0.9.10", "acorn": "8.15.0", + "acorn-walk": "8.3.5", "ai": "^6.0.43", "arrify": "3.0.0", "axios": "1.16.1", diff --git a/test/data/lint/project/checkout_test.js b/test/data/lint/project/checkout_test.js new file mode 100644 index 000000000..580c06630 --- /dev/null +++ b/test/data/lint/project/checkout_test.js @@ -0,0 +1,6 @@ +Feature('checkout') + +Scenario('pay', ({ I }) => { + I.wait(5) + I.executeScript(() => 1) +}) diff --git a/test/data/lint/project/codecept.conf.js b/test/data/lint/project/codecept.conf.js new file mode 100644 index 000000000..cd1276f24 --- /dev/null +++ b/test/data/lint/project/codecept.conf.js @@ -0,0 +1,16 @@ +export const config = { + tests: './*_test.js', + include: { + I: './steps_file.js', + loginPage: './pages/login.js', + externalModule: 'some-package', + }, + helpers: { + Custom: { + require: './custom_helper.js', + }, + }, + lint: { + rules: { 'raw-browser-in-test': 'off' }, + }, +} diff --git a/test/data/lint/project/custom_helper.js b/test/data/lint/project/custom_helper.js new file mode 100644 index 000000000..744cb1f6e --- /dev/null +++ b/test/data/lint/project/custom_helper.js @@ -0,0 +1,9 @@ +import Helper from '@codeceptjs/helper' + +class Custom extends Helper { + hello() { + const { I } = inject() + } +} + +export default Custom diff --git a/test/data/lint/project/existing_test.js b/test/data/lint/project/existing_test.js new file mode 100644 index 000000000..b519ac020 --- /dev/null +++ b/test/data/lint/project/existing_test.js @@ -0,0 +1,7 @@ +Feature('existing') + +Scenario('existing', ({ I }) => { + I.amOnPage('/') + I.wait(5) + I.see('Welcome') +}) diff --git a/test/data/lint/project/pages/login.js b/test/data/lint/project/pages/login.js new file mode 100644 index 000000000..a50b43885 --- /dev/null +++ b/test/data/lint/project/pages/login.js @@ -0,0 +1,7 @@ +const { I } = inject() + +export default { + login() { + I.wait(1) + }, +} diff --git a/test/data/lint/project/steps_file.js b/test/data/lint/project/steps_file.js new file mode 100644 index 000000000..6e3d29dcd --- /dev/null +++ b/test/data/lint/project/steps_file.js @@ -0,0 +1,3 @@ +export default function () { + return actor({}) +} diff --git a/test/unit/command/lint_test.js b/test/unit/command/lint_test.js new file mode 100644 index 000000000..e6e57d449 --- /dev/null +++ b/test/unit/command/lint_test.js @@ -0,0 +1,145 @@ +import { expect } from 'chai' +import fs from 'fs' +import path from 'path' +import { spawnSync } from 'child_process' +import { fileURLToPath } from 'url' +import Linter from '../../../lib/lint.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const project = path.join(__dirname, '../../data/lint/project') +const bin = path.join(__dirname, '../../../bin/codecept.js') + +const lint = (code, file = 'test.js') => new Linter().lint(code, file) +const rules = code => lint(code).map(f => `${f.line}:${f.rule}`) + +const runHook = payload => + spawnSync(process.execPath, [bin, 'lint', '--hook', 'claude'], { + cwd: project, + input: JSON.stringify(payload), + encoding: 'utf8', + env: { ...process.env, CI: '' }, + }) + +describe('lint', () => { + let ci + + beforeEach(() => { + ci = process.env.CI + delete process.env.CI + }) + + afterEach(() => { + if (ci !== undefined) process.env.CI = ci + }) + + describe('rules', () => { + it('no-fixed-wait', () => { + expect(rules("I.wait(5)\nI.waitForElement('#a', 5)\nI.wait(timeout)")).to.deep.equal(['1:no-fixed-wait']) + }) + + it('no-sleep', () => { + expect(rules("Scenario('a', async ({ I }) => {\n await new Promise(r => setTimeout(r, 100))\n})")).to.deep.equal(['2:no-sleep']) + expect(rules('class X extends Helper {\n m() { setTimeout(() => {}, 1) }\n}')).to.deep.equal([]) + }) + + it('no-only and no-pause are warnings locally and errors on CI', () => { + const code = "Scenario.only('a', () => {})\nFeature.only('f')\nData([]).only.Scenario('d', () => {})\npause()" + expect(lint(code).map(f => `${f.rule}:${f.level}`)).to.deep.equal(['no-only:warn', 'no-only:warn', 'no-only:warn', 'no-pause:warn']) + process.env.CI = 'true' + expect(lint(code).map(f => f.level)).to.deep.equal(['error', 'error', 'error', 'error']) + }) + + it('secret-credentials', () => { + const code = [ + "I.fillField('Password', '123456')", + "I.fillField('Password', secret('123456'))", + "I.fillField('Email', 'a@b.c')", + 'I.fillField(loc, process.env.API_TOKEN)', + 'I.fillField(loc, secret(process.env.API_TOKEN))', + ].join('\n') + expect(rules(code)).to.deep.equal(['1:secret-credentials', '4:secret-credentials']) + }) + + it('await-grab', () => { + const code = ['const a = I.grabTextFrom("h1")', 'const b = await I.grabTextFrom("h1")', 'return I.grabTextFrom("h1")', 'I.grabTextFrom("h1").length', 'await Promise.all([I.grabTitle()])'].join('\n') + expect(new Linter().lint(`async function f() {\n${code}\n}`, 'test.js').map(f => `${f.line}:${f.rule}`)).to.deep.equal(['2:await-grab', '5:await-grab']) + }) + + it('no-actor-in-helper', () => { + const code = 'class X extends Helper {\n m() {\n I.click("a")\n }\n}\nI.click("b")' + expect(rules(code)).to.deep.equal(['3:no-actor-in-helper']) + }) + + it('raw-browser-in-test', () => { + const code = "Scenario('a', ({ I }) => {\n I.usePlaywrightTo('x', () => {})\n I.executeScript(() => 1)\n})\nI.executeScript(() => 1)" + expect(lint(code).map(f => `${f.line}:${f.rule}:${f.level}`)).to.deep.equal(['2:raw-browser-in-test:warn', '3:raw-browser-in-test:warn']) + }) + }) + + it('suppresses a rule with a comment', () => { + const code = 'I.wait(1) // codeceptjs-lint-disable-line no-fixed-wait\n// codeceptjs-lint-disable-next-line no-fixed-wait\nI.wait(2)\nI.wait(3)' + expect(rules(code)).to.deep.equal(['4:no-fixed-wait']) + }) + + it('turns rules off from config', () => { + const linter = new Linter({ lint: { rules: { 'no-fixed-wait': 'off', 'no-pause': 'error' } } }) + expect(linter.lint('I.wait(1)\npause()', 'test.js').map(f => `${f.rule}:${f.level}`)).to.deep.equal(['no-pause:error']) + }) + + it('lints TypeScript with correct lines', () => { + expect(lint('const n: number = 5\n\nI.wait(n as number)\nI.wait(5)', 'test.ts').map(f => f.line)).to.deep.equal([4]) + }) + + it('parses CommonJS files', () => { + expect(rules("const x = require('x')\nreturn I.wait(1)")).to.deep.equal(['2:no-fixed-wait']) + }) + + it('collects tests, include and helper files from config', () => { + const linter = new Linter({ tests: './*_test.js', include: { I: './steps_file.js', page: './pages/login.js', other: 'some-package' }, helpers: { Custom: { require: './custom_helper.js' } } }, project) + const files = linter + .files() + .map(f => path.relative(project, f)) + .sort() + expect(files).to.deep.equal(['checkout_test.js', 'custom_helper.js', 'existing_test.js', 'pages/login.js', 'steps_file.js']) + expect(linter.includes(path.join(project, 'new_test.js'))).to.be.true + expect(linter.includes(path.join(project, 'app.js'))).to.be.false + }) + + describe('CLI', () => { + it('prints findings and exits 1 on errors', () => { + const result = spawnSync(process.execPath, [bin, 'lint'], { cwd: project, encoding: 'utf8', env: { ...process.env, CI: '' } }) + expect(result.stdout).to.include('checkout_test.js:4:3 error no-fixed-wait') + expect(result.stdout).to.include('custom_helper.js:5:13 error no-actor-in-helper') + expect(result.stdout).not.to.include('raw-browser-in-test') + expect(result.status).to.equal(1) + }) + }) + + describe('hook', () => { + it('blocks a write that adds an error', () => { + const result = runHook({ tool_name: 'Write', tool_input: { file_path: path.join(project, 'new_test.js'), content: "Scenario('a', ({ I }) => {\n I.wait(3)\n})\n" } }) + expect(result.status).to.equal(2) + expect(result.stderr).to.include('new_test.js:2:3 error no-fixed-wait') + }) + + it('allows an edit that keeps existing errors', () => { + const result = runHook({ tool_name: 'Edit', tool_input: { file_path: path.join(project, 'existing_test.js'), old_string: "I.see('Welcome')", new_string: "I.see('Hello')" } }) + expect(result.status).to.equal(0) + }) + + it('blocks an edit that adds a second identical error', () => { + const result = runHook({ tool_name: 'Edit', tool_input: { file_path: path.join(project, 'existing_test.js'), old_string: "I.see('Welcome')", new_string: "I.wait(5)\n I.see('Welcome')" } }) + expect(result.status).to.equal(2) + }) + + it('allows warnings, files outside the project config and bad payloads', () => { + expect(runHook({ tool_name: 'Write', tool_input: { file_path: path.join(project, 'new_test.js'), content: 'pause()' } }).status).to.equal(0) + expect(runHook({ tool_name: 'Write', tool_input: { file_path: path.join(project, 'app.js'), content: 'I.wait(1)' } }).status).to.equal(0) + expect(runHook({ nonsense: true }).status).to.equal(0) + }) + }) + + it('does not touch fixture files', () => { + expect(fs.existsSync(path.join(project, 'new_test.js'))).to.be.false + }) +})