Skip to content

Commit a9aea30

Browse files
committed
Add ESLint rule: cmdr/no-arbitrary-sleep-in-e2e
Mechanical enforcement of the highest-cost E2E anti-pattern. Flags `await sleep(N)` (or `<receiver>.sleep(N)`) in *.spec.ts files inside `test/e2e-playwright/`. Helper files (helpers.ts, conflict-helpers.ts, mcp-client.ts) are NOT linted — `pollUntil` itself calls `sleep(interval)` between iterations, which is the legit use. Future agents adding a fixed sleep in a new E2E spec will see the rule fire with a clear pointer to docs/testing.md and the replacement patterns (pollUntil / waitForSelector / waitForFunction). Per-line opt-out is available via the standard eslint-disable-next-line comment with a `-- <reason>`, but should be rare. Follows the same plugin pattern as cmdr/no-raw-tauri-invoke and cmdr/no-error-string-match. Registered for the e2e-playwright spec files only — the rule does not apply to src/** code or to helper modules.
1 parent 9515add commit a9aea30

2 files changed

Lines changed: 108 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* ESLint rule: ban `await sleep(N)` in E2E spec files.
3+
*
4+
* Rationale: every fixed-duration sleep in an E2E test is a margin that's
5+
* either too tight (flake) or too loose (slow). The Step 2 speedup pass found
6+
* that 80% of Playwright wall-clock was fixed sleeps; replacing them with
7+
* `pollUntil` / `waitForSelector` cut wall-clock in half. New code should not
8+
* recreate the same pattern.
9+
*
10+
* What this rule flags:
11+
* await sleep(<any-arg>) // most common — `sleep` imported from helpers.ts
12+
* await helpers.sleep(<any-arg>) // qualified call form
13+
*
14+
* Scope: only `*.spec.ts` files inside `test/e2e-playwright/`. Helper files
15+
* (helpers.ts, conflict-helpers.ts, etc.) are NOT linted — they implement
16+
* `pollUntil` itself, which legitimately calls `sleep(interval)` between
17+
* iterations.
18+
*
19+
* Opt out per-line for genuine fixed-duration waits (e.g., file-watcher
20+
* debounce settling, where no observable signal exists to poll against):
21+
*
22+
* // eslint-disable-next-line cmdr/no-arbitrary-sleep-in-e2e -- <reason>
23+
* await sleep(500)
24+
*
25+
* Prefer `pollUntil(...)` / `tauriPage.waitForSelector(...)` /
26+
* `tauriPage.waitForFunction(...)` over an opt-out.
27+
*
28+
* See docs/testing.md § "❌ `await sleep(N)` in E2E specs" for the full
29+
* rationale and replacement patterns.
30+
*/
31+
32+
/** @type {import('eslint').Rule.RuleModule} */
33+
export default {
34+
meta: {
35+
type: 'problem',
36+
docs: {
37+
description:
38+
'Use `pollUntil` or `waitForSelector` in E2E specs; fixed sleeps are flaky-or-slow. See docs/testing.md.',
39+
recommended: true,
40+
},
41+
messages: {
42+
sleepInE2E:
43+
'`await sleep({{ arg }})` in an E2E spec is a fixed margin — either too tight (flake) or too loose (slow). ' +
44+
'Replace with `pollUntil(page, async () => …, timeout)` or `page.waitForSelector(selector, timeout)`. ' +
45+
'See `docs/testing.md` § "❌ `await sleep(N)` in E2E specs". ' +
46+
'Opt out per-line with `// eslint-disable-next-line cmdr/no-arbitrary-sleep-in-e2e -- <reason>` only when a ' +
47+
'genuine fixed wait is needed.',
48+
},
49+
schema: [],
50+
},
51+
create(context) {
52+
return {
53+
CallExpression(node) {
54+
const callee = node.callee
55+
56+
// Match: sleep(...) OR <anything>.sleep(...)
57+
const isBareSleep = callee.type === 'Identifier' && callee.name === 'sleep'
58+
const isMemberSleep =
59+
callee.type === 'MemberExpression' &&
60+
!callee.computed &&
61+
callee.property.type === 'Identifier' &&
62+
callee.property.name === 'sleep'
63+
64+
if (!isBareSleep && !isMemberSleep) return
65+
66+
// Render the arg as a short string for the error message
67+
const firstArg = node.arguments[0]
68+
let argText = '...'
69+
if (firstArg) {
70+
if (firstArg.type === 'Literal') {
71+
argText = String(firstArg.value)
72+
} else {
73+
const source = context.sourceCode ?? context.getSourceCode?.()
74+
if (source) {
75+
argText = source.getText(firstArg)
76+
if (argText.length > 40) argText = argText.slice(0, 40) + '…'
77+
}
78+
}
79+
}
80+
81+
context.report({
82+
node,
83+
messageId: 'sleepInE2E',
84+
data: { arg: argText },
85+
})
86+
},
87+
}
88+
},
89+
}

apps/desktop/eslint.config.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import globals from 'globals'
3030
import noIsolatedTests from './eslint-plugins/no-isolated-tests.js'
3131
import noErrorStringMatch from './eslint-plugins/no-error-string-match.js'
3232
import noRawTauriInvoke from './eslint-plugins/no-raw-tauri-invoke.js'
33+
import noArbitrarySleepInE2E from './eslint-plugins/no-arbitrary-sleep-in-e2e.js'
3334

3435
/* global process */
3536
const noTypecheck = process.env.ESLINT_NO_TYPECHECK === '1'
@@ -235,4 +236,22 @@ export default tseslint.config(
235236
'cmdr/no-raw-tauri-invoke': 'error',
236237
},
237238
},
239+
{
240+
// E2E specs must not use `await sleep(N)` — fixed sleeps are either too
241+
// tight (flake) or too loose (slow). Use `pollUntil` / `waitForSelector`
242+
// instead. Helper files (helpers.ts, conflict-helpers.ts, mcp-client.ts)
243+
// are excluded because `pollUntil` itself calls `sleep(interval)` between
244+
// iterations. See `docs/testing.md` § "❌ `await sleep(N)` in E2E specs".
245+
files: ['test/e2e-playwright/**/*.spec.ts'],
246+
plugins: {
247+
cmdr: {
248+
rules: {
249+
'no-arbitrary-sleep-in-e2e': noArbitrarySleepInE2E,
250+
},
251+
},
252+
},
253+
rules: {
254+
'cmdr/no-arbitrary-sleep-in-e2e': 'error',
255+
},
256+
},
238257
)

0 commit comments

Comments
 (0)