diff --git a/web/e2e/scripts/lint-specs.mjs b/web/e2e/scripts/lint-specs.mjs new file mode 100644 index 0000000000..b0d702f99a --- /dev/null +++ b/web/e2e/scripts/lint-specs.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +/** + * Lint script for e2e specs. Enforces the deepening checklist by + * banning lenient patterns and requiring a @mobile-tagged test in + * every spec under specs/ (excluding _meta/). + * + * Banned patterns: + * - page.waitForTimeout( — use expect().toPass() or waitFor instead + * - if (await ... .isVisible()) — assertions must be unconditional + * - if ((await ... .count()) > 0) — same as above + * - expect(... .length).toBeGreaterThan(0) on textContent results + * + * Escape hatch: append `// e2e-lint-allow` on any line to silence the + * check for that line. Use sparingly and explain why in a comment above. + * + * @mobile rule: every .spec.ts under specs/ (not specs/_meta/) must + * contain at least one test title or describe with the substring "@mobile". + * + * PHASE_3_PENDING list: specs that have not yet been rewritten to the + * deepening checklist are exempt until their dedicated rewrite PR. Each + * entry MUST be removed when its corresponding spec is rewritten. + */ + +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join, relative, resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SPECS_DIR = resolve(__dirname, "..", "specs"); +const META_PREFIX = resolve(SPECS_DIR, "_meta"); + +// Specs allowed to violate rules until they're rewritten in Phase 3. +// Each entry MUST be removed when its corresponding spec is rewritten. +const PHASE_3_PENDING = new Set([ + "auth.spec.ts", + "chat.spec.ts", + "classification.spec.ts", + "config-editor.spec.ts", + "explore.spec.ts", + "face-library.spec.ts", + "live.spec.ts", + "logs.spec.ts", + "navigation.spec.ts", + "replay.spec.ts", + "review.spec.ts", + "system.spec.ts", + // export.spec.ts is rewritten in Task 13; remove it before Task 13 + "export.spec.ts", +]); + +const BANNED_PATTERNS = [ + { + name: "page.waitForTimeout", + regex: /\bwaitForTimeout\s*\(/, + advice: + "Use expect.poll(), expect(...).toPass(), or waitFor() with a real condition.", + }, + { + name: "conditional isVisible() assertion", + regex: /\bif\s*\(\s*await\s+[^)]*\.isVisible\s*\(/, + advice: + "Assertions must be unconditional. Use expect(...).toBeVisible() instead.", + }, + { + name: "conditional count() assertion", + regex: /\bif\s*\(\s*\(?\s*await\s+[^)]*\.count\s*\(\s*\)\s*\)?\s*[><=!]/, + advice: + "Assertions must be unconditional. Use expect(...).toHaveCount(n).", + }, + { + name: "vacuous textContent length assertion", + regex: /expect\([^)]*\.length\)\.toBeGreaterThan\(0\)/, + advice: + "Assert specific content, not that some text exists.", + }, +]; + +function walk(dir) { + const entries = readdirSync(dir); + const out = []; + for (const entry of entries) { + const full = join(dir, entry); + const st = statSync(full); + if (st.isDirectory()) { + out.push(...walk(full)); + } else if (entry.endsWith(".spec.ts")) { + out.push(full); + } + } + return out; +} + +function lintFile(file) { + const basename = file.split("/").pop(); + if (PHASE_3_PENDING.has(basename)) return []; + if (file.includes("/specs/settings/")) return []; + + const errors = []; + const text = readFileSync(file, "utf8"); + const lines = text.split("\n"); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (line.includes("e2e-lint-allow")) continue; + for (const pat of BANNED_PATTERNS) { + if (pat.regex.test(line)) { + errors.push({ + file, + line: i + 1, + col: 1, + rule: pat.name, + message: `${pat.name}: ${pat.advice}`, + source: line.trim(), + }); + } + } + } + + // @mobile rule: skip _meta + const isMeta = file.startsWith(META_PREFIX); + if (!isMeta) { + if (!/@mobile\b/.test(text)) { + errors.push({ + file, + line: 1, + col: 1, + rule: "missing @mobile test", + message: + 'Spec must contain at least one test or describe tagged with "@mobile".', + source: "", + }); + } + } + + return errors; +} + +function main() { + const files = walk(SPECS_DIR); + const allErrors = []; + for (const f of files) { + allErrors.push(...lintFile(f)); + } + + if (allErrors.length === 0) { + console.log(`e2e:lint: ${files.length} spec files OK`); + process.exit(0); + } + + for (const err of allErrors) { + const rel = relative(process.cwd(), err.file); + console.error(`${rel}:${err.line}:${err.col} ${err.rule}`); + console.error(` ${err.message}`); + if (err.source) console.error(` > ${err.source}`); + } + console.error( + `\ne2e:lint: ${allErrors.length} error${allErrors.length === 1 ? "" : "s"} in ${files.length} files`, + ); + process.exit(1); +} + +main(); diff --git a/web/package.json b/web/package.json index e3fc4af9a5..0ece2d6fef 100644 --- a/web/package.json +++ b/web/package.json @@ -7,7 +7,8 @@ "dev": "vite --host", "postinstall": "patch-package", "build": "tsc && vite build --base=/BASE_PATH/", - "lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore .", + "lint": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore . && npm run e2e:lint", + "e2e:lint": "node e2e/scripts/lint-specs.mjs", "lint:fix": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --fix .", "preview": "vite preview", "prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",