add lint script for banned spec patterns and @mobile rule

This commit is contained in:
Josh Hawkins 2026-04-08 16:32:15 -05:00
parent 7281d26035
commit 91c5b1b785
2 changed files with 164 additions and 1 deletions

View File

@ -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();

View File

@ -7,7 +7,8 @@
"dev": "vite --host", "dev": "vite --host",
"postinstall": "patch-package", "postinstall": "patch-package",
"build": "tsc && vite build --base=/BASE_PATH/", "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 .", "lint:fix": "eslint --ext .jsx,.js,.tsx,.ts --ignore-path .gitignore --fix .",
"preview": "vite preview", "preview": "vite preview",
"prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"", "prettier:write": "prettier -u -w --ignore-path .gitignore \"*.{ts,tsx,js,jsx,css,html}\"",