frigate/web/e2e/scripts/lint-specs.mjs
Josh Hawkins d113be5e19
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
Improve frontend test framework (#22824)
* add error allowlist file for error collector

* add error collector for console + page + request errors

* wire error collector into frigateApp fixture

* add self-tests for error collector fixture

* gate strict error mode on E2E_STRICT_ERRORS=1

* triage pre-existing errors and seed allowlist

* add mockEmpty/mockError/mockDelay helpers for state-driven tests

* add self-tests for mock override helpers

* add mobile affordance helpers to BasePage

* add lint script for banned spec patterns and @mobile rule

* apply prettier fixes to new e2e files

* rewrite export.spec.ts

* clean up

* move export spec rewrite and bugfix to separate branch
2026-04-09 14:42:36 -06:00

161 lines
4.5 KiB
JavaScript

#!/usr/bin/env node
/**
* Lint script for e2e specs. Bans lenient test patterns and requires
* 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".
*
* Specs in PENDING_REWRITE are exempt from all rules until they are
* rewritten with proper assertions and mobile coverage. Remove each
* entry when its spec is updated.
*/
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 exempt from lint rules until they are rewritten with proper
// assertions and mobile coverage. Remove each entry when its spec is updated.
const PENDING_REWRITE = new Set([
"auth.spec.ts",
"chat.spec.ts",
"classification.spec.ts",
"config-editor.spec.ts",
"explore.spec.ts",
"export.spec.ts",
"face-library.spec.ts",
"live.spec.ts",
"logs.spec.ts",
"navigation.spec.ts",
"replay.spec.ts",
"review.spec.ts",
"system.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 (PENDING_REWRITE.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();