Files
TREK/client/scripts/lib/skips.mjs
T
Maurice 0f1020cb80 ci(client): lint:skips forbids .only and ratchets skipped tests
JourneyDetailPage.test.tsx has carried 35 describe.skip blocks since
April, for contributors, trip linking and the map view, features that
were reworked rather than removed. Nothing stops the next skip, and
nothing stops a stray .only from passing CI while the rest of its file
never runs.

lint:skips reads every vitest file under src/ and tests/ and every
Playwright spec under e2e/ from its syntax tree. A .only on any runner
fails the check, always. Skipped and todo tests (it.skip, describe.skip,
test.todo, the x-shorthands, skip.each) are counted per file against
scripts/skip-baseline.json, which only lowers. A skip that decides at
run time is not a declaration and is not counted: skipIf, runIf,
Playwright's test.skip(condition, 'why') and a bare test.skip() inside a
test.
2026-10-08 13:04:40 +02:00

114 lines
4.8 KiB
JavaScript

/*
* The matching and the comparison behind lint:skips (scripts/skip-lint.mjs).
*/
import { join } from 'node:path'
import ts from 'typescript'
import { countMap, listFiles, lowerCounts, readBaseline, readText, TEST_FILE, toKey, writeBaseline } from './ratchet.mjs'
/** The test functions of vitest and Playwright a modifier can hang off. */
const RUNNERS = new Set(['it', 'test', 'describe', 'suite', 'bench'])
/** The jasmine-style shorthands vitest also knows. */
const SHORTHAND = new Set(['xit', 'xtest', 'xdescribe'])
export const DIRS = ['src', 'tests', 'e2e']
const accepts = (key) =>
/\.tsx?$/.test(key) && (key.startsWith('src/') ? TEST_FILE.test(key) : true)
/** The identifier a chain like test.describe.skip.each starts from. */
function rootOf(node) {
while (ts.isPropertyAccessExpression(node) || ts.isCallExpression(node)) node = node.expression
return ts.isIdentifier(node) ? node.text : null
}
const isTitle = (node) => Boolean(node) && (ts.isStringLiteralLike(node) || ts.isTemplateExpression(node))
/**
* The tests a file switches off or narrows down, as `line: code`.
*
* skipped: it.skip / describe.skip / test.todo and the x-shorthands, each
* declaring a test that never runs. A skip with a condition first
* (Playwright's test.skip(!seed.id, 'why'), or called bare inside a test)
* decides at run time and is no declaration; neither are skipIf and runIf.
*
* only: .only anywhere, which silently drops every other test in the file.
*/
export function modifiers(source, file) {
const kind = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS
const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, kind)
const skipped = []
const only = []
const at = (node) => `${sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1}: ${node.getText(sf).slice(0, 60)}`
const visit = (node) => {
if (ts.isPropertyAccessExpression(node) && RUNNERS.has(rootOf(node.expression) ?? '')) {
const name = node.name.text
if (name === 'only') only.push(at(node))
else if (name === 'todo') skipped.push(at(node))
else if (name === 'skip') {
const call = ts.isCallExpression(node.parent) && node.parent.expression === node ? node.parent : null
// test.skip.each(table)(...) declares skipped tests; test.skip('title', fn) declares one.
if (!call || isTitle(call.arguments[0])) skipped.push(at(node))
}
} else if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && SHORTHAND.has(node.expression.text)) {
skipped.push(at(node.expression))
}
ts.forEachChild(node, visit)
}
visit(sf)
return { skipped, only }
}
/** The skipped and focused tests per file under src/, tests/ and e2e/, keyed by the path from root. */
export function scan(root) {
const skipped = {}
const only = {}
for (const path of listFiles(root, DIRS, accepts)) {
const found = modifiers(readText(path), path)
const key = toKey(root, path)
if (found.skipped.length) skipped[key] = found.skipped
if (found.only.length) only[key] = found.only
}
return { skipped, only }
}
/**
* Runs the check: no .only anywhere, and no file with more skipped tests than
* its entry in the baseline at baselinePath. Returns the exit code.
*/
export function check({
root,
baselinePath = join(root, 'scripts/skip-baseline.json'),
update = false,
log = console.log,
error = console.error,
}) {
const { skipped, only } = scan(root)
const counts = Object.fromEntries(Object.entries(skipped).map(([key, list]) => [key, list.length]))
let baseline = readBaseline(baselinePath, countMap)
if (update) {
baseline = lowerCounts(baseline, counts)
writeBaseline(baselinePath, baseline)
}
for (const [key, list] of Object.entries(only)) {
error(`FAIL ${key}: .only runs this test alone and drops every other one in the file:`)
for (const line of list) error(` ${line}`)
}
const grown = Object.entries(counts).filter(([key, n]) => n > (baseline[key] ?? 0))
for (const [key, n] of grown) {
error(`FAIL ${key}: ${n} skipped or todo test(s), baseline ${baseline[key] ?? 0}:`)
for (const line of skipped[key]) error(` ${line}`)
}
if (grown.length) {
error(
'Fix the test, delete it if its feature is gone, or use skipIf/runIf when it depends on the environment.',
)
}
const lowerable = Object.entries(baseline).filter(([key, n]) => (counts[key] ?? 0) < n)
if (lowerable.length && !update) log(`${lowerable.length} file(s) skip fewer tests than the baseline: run with --update to lower it.`)
const sum = (map) => Object.values(map).reduce((a, b) => a + b, 0)
log(`skips: ${sum(counts)} skipped or todo test(s), baseline allows ${sum(baseline)}; ${Object.keys(only).length} file(s) with .only`)
return grown.length || Object.keys(only).length ? 1 : 0
}