From 8aa68234d95278a99f1c5b507609f8ba9caf0b3e Mon Sep 17 00:00:00 2001 From: Diana Barsan Date: Thu, 18 Jun 2026 11:58:34 +0300 Subject: [PATCH 01/10] ci: introduce CI failure analysis script --- scripts/ci/find-test-failures.js | 503 +++++++++++++++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 scripts/ci/find-test-failures.js diff --git a/scripts/ci/find-test-failures.js b/scripts/ci/find-test-failures.js new file mode 100644 index 00000000000..742780e23f0 --- /dev/null +++ b/scripts/ci/find-test-failures.js @@ -0,0 +1,503 @@ +#!/usr/bin/env node + +/** + * find-test-failures.js + * + * Search CI history (the "Build and test" workflow, build.yml) for runs where a + * specific test failed, and report how often it failed. + * + * GitHub does not store per-test results, so the only spec-level signal lives in + * the job *logs*. This script pulls per-job logs via the GitHub API (`gh`) and + * greps them for failures of the test you name. It understands both test styles + * used by this repo: + * + * - WebdriverIO e2e specs (ci-webdriver-*, wdio-performance, test-cht-form, + * upgrade-*): each spec attempt prints a per-worker block headed by either + * `Spec: ` or `» `, with failures marked `[FAIL] ` or + * `✖ <title>`. Specs are retried up to 3x, so the same spec can appear in + * several blocks; the last block is the final result. + * + * - Integration mocha tests (ci-integration-*): the mocha "spec" reporter + * prints a trailing "N failing" summary. With fullTrace enabled the stack + * lines contain the `tests/integration/.../*.spec.js` path, so we can match + * by test title OR by spec file. + * + * Requirements: the `gh` CLI must be installed and authenticated + * (`gh auth login`), or a GH_TOKEN / GITHUB_TOKEN env var must be set. + * + * Usage: + * node scripts/ci/find-test-failures.js <search-term> [options] + * + * <search-term> Substring (case-insensitive) or /regex/ matched against the + * failing test's title, its spec-file path, and (wdio) the + * `Spec:` header. Pass a spec file basename + * (e.g. "reports-list.wdio-spec.js") or a test-title fragment. + * + * Options: + * --branch <name> Only scan runs on this branch (default: all branches). + * --limit <n> Max number of runs to scan (default: 100). + * --since <date> Only runs created on/after this date (YYYY-MM-DD). + * --repo <owner/repo> Repository to query (default: medic/cht-core). + * --scan-all Also scan green runs and passing jobs. Catches specs + * that failed an attempt but were retried-and-passed + * (flaky-recovered). Slower. Default scans only failed + * runs / failed jobs (i.e. final, job-failing failures). + * --concurrency <n> Parallel log downloads (default: 6). + * --json Emit machine-readable JSON instead of a table. + * --debug Print per-job parsing diagnostics to stderr. + * -h, --help Show this help. + * + * Examples: + * node scripts/ci/find-test-failures.js reports-list.wdio-spec.js + * node scripts/ci/find-test-failures.js "should display the correct number" --since 2026-01-01 + * node scripts/ci/find-test-failures.js "/replication.*offline/" --scan-all --json + */ + +'use strict'; + +const { spawnSync } = require('node:child_process'); + +// --------------------------------------------------------------------------- +// CLI parsing +// --------------------------------------------------------------------------- + +const parseArgs = (argv) => { + const opts = { + term: null, + branch: null, + limit: 100, + since: null, + repo: 'medic/cht-core', + scanAll: false, + concurrency: 6, + json: false, + debug: false, + }; + const rest = []; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + switch (arg) { + case '-h': + case '--help': + opts.help = true; + break; + case '--branch': opts.branch = argv[++i]; break; + case '--limit': opts.limit = Number(argv[++i]); break; + case '--since': opts.since = argv[++i]; break; + case '--repo': opts.repo = argv[++i]; break; + case '--concurrency': opts.concurrency = Number(argv[++i]); break; + case '--scan-all': opts.scanAll = true; break; + case '--json': opts.json = true; break; + case '--debug': opts.debug = true; break; + default: + if (arg.startsWith('--')) { + throw new Error(`Unknown option: ${arg}`); + } + rest.push(arg); + } + } + opts.term = rest[0] || null; + return opts; +}; + +const HELP = `Search CI history for runs where a specific test failed. + +Usage: + node scripts/ci/find-test-failures.js <search-term> [options] + + <search-term> Substring (case-insensitive) or /regex/ matched against the + failing test's title and spec-file path. Pass a spec file + basename (e.g. "reports-list.wdio-spec.js") or a title fragment. + +Options: + --branch <name> Only scan runs on this branch (default: all branches). + --limit <n> Max number of runs to scan (default: 100). + --since <date> Only runs created on/after this date (YYYY-MM-DD). + --repo <owner/repo> Repository to query (default: medic/cht-core). + --scan-all Also scan green runs / passing jobs (catches flaky-recovered). + --concurrency <n> Parallel log downloads (default: 6). + --json Emit JSON instead of a table. + --debug Print parsing diagnostics to stderr. + -h, --help Show this help. +`; + +// --------------------------------------------------------------------------- +// gh helpers +// --------------------------------------------------------------------------- + +const runGh = (args, { allowFail = false, maxBuffer = 256 * 1024 * 1024 } = {}) => { + const res = spawnSync('gh', args, { encoding: 'utf8', maxBuffer }); + if (res.error) { + if (res.error.code === 'ENOENT') { + throw new Error('`gh` CLI not found. Install it: https://cli.github.com/'); + } + throw res.error; + } + if (res.status !== 0 && !allowFail) { + throw new Error(`gh ${args.join(' ')} failed (exit ${res.status}):\n${res.stderr}`); + } + return { stdout: res.stdout, stderr: res.stderr, status: res.status }; +}; + +const ghJson = (endpoint) => { + const { stdout } = runGh(['api', endpoint]); + return JSON.parse(stdout); +}; + +const assertAuth = () => { + const res = runGh(['auth', 'status'], { allowFail: true }); + if (res.status !== 0 && !process.env.GH_TOKEN && !process.env.GITHUB_TOKEN) { + throw new Error( + 'gh is not authenticated. Run `gh auth login`, or set GH_TOKEN.\n' + (res.stderr || '') + ); + } +}; + +// --------------------------------------------------------------------------- +// Text cleaning +// --------------------------------------------------------------------------- + +// eslint-disable-next-line no-control-regex +const ANSI = /\x1B\[[0-9;]*[A-Za-z]/g; +const GH_TIMESTAMP = /^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s/; +// wdio spec-reporter prefixes lines with a runner tag like "[chrome 119 linux #0-0] " +const WDIO_RUNNER_PREFIX = /^\[[^\]]*#\d+-\d+\]\s?/; + +const cleanLine = (line) => line.replace(ANSI, '').replace(GH_TIMESTAMP, ''); + +// --------------------------------------------------------------------------- +// Matching +// --------------------------------------------------------------------------- + +const buildMatcher = (term) => { + const regexMatch = term.match(/^\/(.*)\/([a-z]*)$/); + if (regexMatch) { + const re = new RegExp(regexMatch[1], regexMatch[2].includes('i') ? regexMatch[2] : regexMatch[2] + 'i'); + return (text) => re.test(text); + } + const needle = term.toLowerCase(); + return (text) => text.toLowerCase().includes(needle); +}; + +// --------------------------------------------------------------------------- +// Log parsing: produce a list of failure records { title, file, source } +// --------------------------------------------------------------------------- + +const SPEC_FILE_RE = /(?:[\w./-]*\/)?[\w.-]+\.(?:wdio-spec|spec)\.js/; + +// Parse wdio "spec" reporter output. Returns failures grouped per spec file, +// keeping only the *final* attempt unless includeAllAttempts is set. +const parseWdioLog = (lines, includeAllAttempts) => { + // collect ordered blocks: { file, fails: [titles] } + const blocks = []; + let current = null; + for (const raw of lines) { + const line = cleanLine(raw).replace(WDIO_RUNNER_PREFIX, ''); + // Spec-block header. Two reporter styles show up in this repo's logs: + // - classic spec-reporter: "Spec: <path>" + // - per-worker grouped block: "» <path>" (current wdio output) + const specMatch = line.match(/^\s*(?:Spec:|»)\s*(\S+\.js)\s*$/); + if (specMatch) { + current = { file: specMatch[1], fails: [] }; + blocks.push(current); + continue; + } + // Streaming concise lines ("✖ <title> » [ <path> ]") repeat every + // attempt and aren't tied to an open block; skip them. The grouped block + // re-reports the same failures and is authoritative for retry handling. + if (/✖.*»\s*\[/.test(line)) { + continue; + } + // Failure markers within a block: classic "[FAIL] <title>" or the + // "✖ <title>" tree line emitted by the grouped block. + const failMatch = line.match(/(?:\[FAIL\]|✖)\s*(.+?)\s*$/); + if (failMatch && current) { + current.fails.push(failMatch[1].trim()); + } + } + + // group blocks by file, preserving order so the last block is the final attempt + const byFile = new Map(); + for (const block of blocks) { + if (!byFile.has(block.file)) { + byFile.set(block.file, []); + } + byFile.get(block.file).push(block); + } + + const failures = []; + for (const [file, fileBlocks] of byFile) { + const relevant = includeAllAttempts ? fileBlocks : [fileBlocks[fileBlocks.length - 1]]; + for (const block of relevant) { + for (const title of block.fails) { + failures.push({ title, file, source: 'wdio' }); + } + } + } + return failures; +}; + +// Parse mocha "spec" reporter trailing "N failing" summary. Each entry looks like: +// 1) Suite title +// sub-suite +// test name: +// AssertionError: ... +// at Context.<anonymous> (tests/integration/.../foo.spec.js:12:34) +const parseMochaLog = (lines) => { + const clean = lines.map(cleanLine); + const failures = []; + let i = 0; + while (i < clean.length) { + const header = clean[i].match(/^\s{1,4}(\d+)\)\s+(.*\S)\s*$/); + if (!header) { + i++; + continue; + } + // gather the multi-line title: indented lines until we hit the error/stack + const titleParts = [header[2]]; + let j = i + 1; + for (; j < clean.length; j++) { + const l = clean[j]; + if (!l.trim()) { + break; + } + // start of next numbered failure + if (/^\s{1,4}\d+\)\s/.test(l)) { + break; + } + // error message / stack lines: typically " SomeError: ..." or "at ..." + if (/^\s*(at\s|[A-Z]\w*(Error|Exception):|AssertionError|\+ expected|- actual|Error:)/.test(l)) { + break; + } + titleParts.push(l.trim()); + } + // scan forward (within this entry) for a spec-file path in the stack + let file = null; + for (let k = i; k < clean.length; k++) { + if (k > i && /^\s{1,4}\d+\)\s/.test(clean[k])) { + break; + } + const m = clean[k].match(SPEC_FILE_RE); + if (m && /tests\//.test(m[0])) { + file = m[0]; + break; + } + } + const title = titleParts.join(' ').replace(/:$/, '').replace(/\s+/g, ' ').trim(); + failures.push({ title, file, source: 'mocha' }); + i = j; + } + return failures; +}; + +const isIntegrationJob = (jobName) => /ci-integration/i.test(jobName); + +const TEST_JOB_RE = /(ci-webdriver|wdio-performance|ci-integration|test-cht-form|integration-cht-form|upgrade-)/i; + +// --------------------------------------------------------------------------- +// Concurrency helper +// --------------------------------------------------------------------------- + +const mapWithConcurrency = async (items, limit, worker) => { + const results = new Array(items.length); + let next = 0; + const runWorker = async () => { + while (true) { + const idx = next++; + if (idx >= items.length) { + return; + } + results[idx] = await worker(items[idx], idx); + } + }; + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, runWorker)); + return results; +}; + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const listRuns = (opts) => { + const runs = []; + const perPage = 100; + let page = 1; + while (runs.length < opts.limit) { + const params = [`per_page=${perPage}`, `page=${page}`]; + if (opts.branch) { + params.push(`branch=${encodeURIComponent(opts.branch)}`); + } + if (!opts.scanAll) { + params.push('status=failure'); // only runs that ended in failure + } + if (opts.since) { + params.push(`created=${encodeURIComponent('>=' + opts.since)}`); + } + const endpoint = `/repos/${opts.repo}/actions/workflows/build.yml/runs?${params.join('&')}`; + const data = ghJson(endpoint); + const batch = data.workflow_runs || []; + if (!batch.length) { + break; + } + runs.push(...batch); + page++; + if (batch.length < perPage) { + break; + } + } + return runs.slice(0, opts.limit); +}; + +const fetchJobLog = (repo, jobId) => { + const res = runGh(['api', '-H', 'Accept: application/vnd.github.raw', + `/repos/${repo}/actions/jobs/${jobId}/logs`], { allowFail: true }); + if (res.status !== 0) { + return null; // logs expired / unavailable + } + return res.stdout; +}; + +const scanRun = async (run, opts, matcher) => { + let jobsData; + try { + jobsData = ghJson(`/repos/${opts.repo}/actions/runs/${run.id}/jobs?per_page=100`); + } catch (err) { + if (opts.debug) { + process.stderr.write(`run ${run.id}: failed to list jobs: ${err.message}\n`); + } + return []; + } + const jobs = (jobsData.jobs || []).filter((job) => { + if (!TEST_JOB_RE.test(job.name)) { + return false; + } + if (!opts.scanAll && job.conclusion !== 'failure') { + return false; + } + return true; + }); + + const perJob = await mapWithConcurrency(jobs, opts.concurrency, async (job) => { + const log = fetchJobLog(opts.repo, job.id); + if (log === null) { + if (opts.debug) { + process.stderr.write(`run ${run.id} job ${job.name}: log unavailable\n`); + } + return []; + } + const lines = log.split('\n'); + const failures = isIntegrationJob(job.name) + ? parseMochaLog(lines) + : parseWdioLog(lines, opts.scanAll); + + const matched = failures.filter((f) => matcher(f.title || '') || (f.file && matcher(f.file))); + + if (opts.debug) { + process.stderr.write(`run ${run.id} job ${job.name}: ` + + `${failures.length} failures, ${matched.length} matched\n`); + } + return matched.map((f) => ({ ...f, jobName: job.name, jobUrl: job.html_url })); + }); + + return perJob.flat(); +}; + +const formatRow = (run, failures) => { + const titles = [...new Set(failures.map((f) => f.title).filter(Boolean))]; + const jobs = [...new Set(failures.map((f) => f.jobName))]; + return { + date: (run.created_at || '').slice(0, 10), + branch: run.head_branch, + event: run.event, + runNumber: run.run_number, + conclusion: run.conclusion, + jobs, + titles, + url: run.html_url, + }; +}; + +const printTable = (matchedRuns, scannedCount, opts) => { + if (!matchedRuns.length) { + console.log(`\nNo failures of "${opts.term}" found in ${scannedCount} scanned run(s).`); + if (!opts.scanAll) { + console.log('(Only failed runs/jobs were scanned. Use --scan-all to also catch ' + + 'specs that failed but were retried-and-passed.)'); + } + return; + } + console.log(`\n"${opts.term}" failed in ${matchedRuns.length} of ${scannedCount} scanned run(s):\n`); + for (const row of matchedRuns) { + const prNote = row.event === 'pull_request' ? ' (PR)' : ''; + console.log(`● ${row.date} ${row.branch}${prNote} [run #${row.runNumber}, ${row.conclusion}]`); + console.log(` jobs: ${row.jobs.join(', ')}`); + for (const title of row.titles) { + console.log(` fail: ${title}`); + } + console.log(` ${row.url}`); + console.log(''); + } + + // simple branch breakdown + const byBranch = {}; + for (const row of matchedRuns) { + byBranch[row.branch] = (byBranch[row.branch] || 0) + 1; + } + const branches = Object.entries(byBranch).sort((a, b) => b[1] - a[1]); + console.log('By branch:'); + for (const [branch, count] of branches) { + console.log(` ${count}x ${branch}`); + } +}; + +const main = async () => { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help || !opts.term) { + console.log(HELP); + process.exit(opts.term ? 0 : 1); + } + if (!Number.isFinite(opts.limit) || opts.limit <= 0) { + throw new Error('--limit must be a positive number'); + } + + assertAuth(); + const matcher = buildMatcher(opts.term); + + process.stderr.write(`Listing build.yml runs (${opts.scanAll ? 'all' : 'failed-only'}` + + `${opts.branch ? `, branch=${opts.branch}` : ', all branches'}` + + `${opts.since ? `, since=${opts.since}` : ''}, limit=${opts.limit})...\n`); + const runs = listRuns(opts); + process.stderr.write(`Scanning ${runs.length} run(s) for "${opts.term}"...\n`); + + const matchedRuns = []; + // scan runs sequentially; log downloads within each run are parallelised + for (let i = 0; i < runs.length; i++) { + const run = runs[i]; + process.stderr.write(` [${i + 1}/${runs.length}] run #${run.run_number} (${run.head_branch})\r`); + const failures = await scanRun(run, opts, matcher); + if (failures.length) { + matchedRuns.push(formatRow(run, failures)); + } + } + process.stderr.write('\n'); + + if (opts.json) { + console.log(JSON.stringify({ + term: opts.term, + scanned: runs.length, + failedRuns: matchedRuns.length, + runs: matchedRuns, + }, null, 2)); + } else { + printTable(matchedRuns, runs.length, opts); + } +}; + +if (require.main === module) { + main().catch((err) => { + process.stderr.write(`\nError: ${err.message}\n`); + process.exit(1); + }); +} + +module.exports = { parseWdioLog, parseMochaLog, cleanLine, buildMatcher }; From b40412baf7ca7b3c586e9bf27b3c7b554913efd8 Mon Sep 17 00:00:00 2001 From: Diana Barsan <barsan@medic.org> Date: Thu, 18 Jun 2026 18:45:57 +0300 Subject: [PATCH 02/10] ci: add job filtering to test failure analysis script Introduce a `--job` flag, allowing users to scan specific CI jobs by name or pattern, accelerating test failure analysis by bypassing logs from unrelated suites. --- scripts/ci/find-test-failures.js | 479 +++++++++++++++++++------------ 1 file changed, 294 insertions(+), 185 deletions(-) diff --git a/scripts/ci/find-test-failures.js b/scripts/ci/find-test-failures.js index 742780e23f0..a8456010cc1 100644 --- a/scripts/ci/find-test-failures.js +++ b/scripts/ci/find-test-failures.js @@ -38,6 +38,10 @@ * --limit <n> Max number of runs to scan (default: 100). * --since <date> Only runs created on/after this date (YYYY-MM-DD). * --repo <owner/repo> Repository to query (default: medic/cht-core). + * --job <name> Only scan jobs whose name matches (substring or /regex/). + * A spec runs in one CI suite, so naming it (e.g. the + * "ci-webdriver-default-lowLevel" partition) skips the + * other jobs' log downloads and is much faster. * --scan-all Also scan green runs and passing jobs. Catches specs * that failed an attempt but were retried-and-passed * (flaky-recovered). Slower. Default scans only failed @@ -50,6 +54,7 @@ * Examples: * node scripts/ci/find-test-failures.js reports-list.wdio-spec.js * node scripts/ci/find-test-failures.js "should display the correct number" --since 2026-01-01 + * node scripts/ci/find-test-failures.js replace_user --job lowLevel * node scripts/ci/find-test-failures.js "/replication.*offline/" --scan-all --json */ @@ -61,12 +66,54 @@ const { spawnSync } = require('node:child_process'); // CLI parsing // --------------------------------------------------------------------------- +const asString = (value) => value; + +// Flag table: boolean flags carry `flag: true`; value flags carry a `parse` +// that converts the following argv token. +const ARG_FLAGS = { + '-h': { key: 'help', flag: true }, + '--help': { key: 'help', flag: true }, + '--scan-all': { key: 'scanAll', flag: true }, + '--json': { key: 'json', flag: true }, + '--debug': { key: 'debug', flag: true }, + '--branch': { key: 'branch', parse: asString }, + '--since': { key: 'since', parse: asString }, + '--repo': { key: 'repo', parse: asString }, + '--job': { key: 'job', parse: asString }, + '--limit': { key: 'limit', parse: Number }, + '--concurrency': { key: 'concurrency', parse: Number }, +}; + +const applyPositional = (rest, arg) => { + if (arg.startsWith('--')) { + throw new Error(`Unknown option: ${arg}`); + } + rest.push(arg); +}; + +// Apply argv[i] to opts. Returns the index of the last token consumed (i for a +// boolean/positional, i+1 for a value flag). +const applyArg = (opts, rest, argv, i) => { + const spec = ARG_FLAGS[argv[i]]; + if (!spec) { + applyPositional(rest, argv[i]); + return i; + } + if (spec.flag) { + opts[spec.key] = true; + return i; + } + opts[spec.key] = spec.parse(argv[i + 1]); + return i + 1; +}; + const parseArgs = (argv) => { const opts = { term: null, branch: null, limit: 100, since: null, + job: null, repo: 'medic/cht-core', scanAll: false, concurrency: 6, @@ -75,26 +122,7 @@ const parseArgs = (argv) => { }; const rest = []; for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - switch (arg) { - case '-h': - case '--help': - opts.help = true; - break; - case '--branch': opts.branch = argv[++i]; break; - case '--limit': opts.limit = Number(argv[++i]); break; - case '--since': opts.since = argv[++i]; break; - case '--repo': opts.repo = argv[++i]; break; - case '--concurrency': opts.concurrency = Number(argv[++i]); break; - case '--scan-all': opts.scanAll = true; break; - case '--json': opts.json = true; break; - case '--debug': opts.debug = true; break; - default: - if (arg.startsWith('--')) { - throw new Error(`Unknown option: ${arg}`); - } - rest.push(arg); - } + i = applyArg(opts, rest, argv, i); } opts.term = rest[0] || null; return opts; @@ -114,6 +142,8 @@ Options: --limit <n> Max number of runs to scan (default: 100). --since <date> Only runs created on/after this date (YYYY-MM-DD). --repo <owner/repo> Repository to query (default: medic/cht-core). + --job <name> Only scan jobs whose name matches (substring or /regex/); + faster, since it skips other suites' log downloads. --scan-all Also scan green runs / passing jobs (catches flaky-recovered). --concurrency <n> Parallel log downloads (default: 6). --json Emit JSON instead of a table. @@ -144,7 +174,7 @@ const ghJson = (endpoint) => { return JSON.parse(stdout); }; -const assertAuth = () => { +const assertAuth = () => { const res = runGh(['auth', 'status'], { allowFail: true }); if (res.status !== 0 && !process.env.GH_TOKEN && !process.env.GITHUB_TOKEN) { throw new Error( @@ -180,112 +210,157 @@ const buildMatcher = (term) => { }; // --------------------------------------------------------------------------- -// Log parsing: produce a list of failure records { title, file, source } +// Shared helpers // --------------------------------------------------------------------------- const SPEC_FILE_RE = /(?:[\w./-]*\/)?[\w.-]+\.(?:wdio-spec|spec)\.js/; -// Parse wdio "spec" reporter output. Returns failures grouped per spec file, -// keeping only the *final* attempt unless includeAllAttempts is set. -const parseWdioLog = (lines, includeAllAttempts) => { - // collect ordered blocks: { file, fails: [titles] } +const groupBy = (items, keyFn) => { + const map = new Map(); + for (const item of items) { + const key = keyFn(item); + if (!map.has(key)) { + map.set(key, []); + } + map.get(key).push(item); + } + return map; +}; + +// --------------------------------------------------------------------------- +// Log parsing: produce a list of failure records { title, file, source } +// --------------------------------------------------------------------------- + +const WDIO_SPEC_HEADER_RE = /^\s*(?:Spec:|»)\s*(\S+\.js)\s*$/; +const WDIO_STREAMING_RE = /✖.*»\s*\[/; +const WDIO_FAIL_RE = /(?:\[FAIL\]|✖)\s*(.+?)\s*$/; + +// Fold one (prefix-stripped) wdio log line into the running block list, and +// return the block that subsequent failure lines belong to. +const foldWdioLine = (line, blocks, current) => { + const specMatch = line.match(WDIO_SPEC_HEADER_RE); + if (specMatch) { + const block = { file: specMatch[1], fails: [] }; + blocks.push(block); + return block; + } + // Streaming concise lines ("✖ <title> » [ <path> ]") repeat every attempt and + // aren't tied to an open block; skip them. The grouped block re-reports the + // same failures and is authoritative for retry handling. + if (WDIO_STREAMING_RE.test(line)) { + return current; + } + const failMatch = line.match(WDIO_FAIL_RE); + if (failMatch && current) { + current.fails.push(failMatch[1].trim()); + } + return current; +}; + +const collectWdioBlocks = (lines) => { const blocks = []; let current = null; for (const raw of lines) { - const line = cleanLine(raw).replace(WDIO_RUNNER_PREFIX, ''); - // Spec-block header. Two reporter styles show up in this repo's logs: - // - classic spec-reporter: "Spec: <path>" - // - per-worker grouped block: "» <path>" (current wdio output) - const specMatch = line.match(/^\s*(?:Spec:|»)\s*(\S+\.js)\s*$/); - if (specMatch) { - current = { file: specMatch[1], fails: [] }; - blocks.push(current); - continue; - } - // Streaming concise lines ("✖ <title> » [ <path> ]") repeat every - // attempt and aren't tied to an open block; skip them. The grouped block - // re-reports the same failures and is authoritative for retry handling. - if (/✖.*»\s*\[/.test(line)) { - continue; - } - // Failure markers within a block: classic "[FAIL] <title>" or the - // "✖ <title>" tree line emitted by the grouped block. - const failMatch = line.match(/(?:\[FAIL\]|✖)\s*(.+?)\s*$/); - if (failMatch && current) { - current.fails.push(failMatch[1].trim()); + current = foldWdioLine(cleanLine(raw).replace(WDIO_RUNNER_PREFIX, ''), blocks, current); + } + return blocks; +}; + +const blocksToFailures = (file, blocks) => blocks.flatMap( + (block) => block.fails.map((title) => ({ title, file, source: 'wdio' })) +); + +// Parse wdio "spec" reporter output. Returns failures grouped per spec file, +// keeping only the *final* attempt unless includeAllAttempts is set. +const parseWdioLog = (lines, includeAllAttempts) => { + const byFile = groupBy(collectWdioBlocks(lines), (block) => block.file); + const failures = []; + for (const [file, fileBlocks] of byFile) { + const relevant = includeAllAttempts ? fileBlocks : [fileBlocks.at(-1)]; + failures.push(...blocksToFailures(file, relevant)); + } + return failures; +}; + +// Mocha "N failing" entry: " 1) Suite title", indented sub-titles, then the +// error message / stack. These delimit the title and the stack respectively. +const MOCHA_HEADER_RE = /^\s{1,4}(\d+)\)\s+(.*\S)\s*$/; +const MOCHA_NEXT_RE = /^\s{1,4}\d+\)\s/; +const MOCHA_STACK_RE = /^\s*(at\s|[A-Z]\w*(Error|Exception):|AssertionError|\+ expected|- actual|Error:)/; + +// Index where the multi-line title ends: first blank, next-entry, or stack line. +const findTitleEnd = (clean, start) => { + let j = start + 1; + for (; j < clean.length; j++) { + const l = clean[j]; + if (!l.trim() || MOCHA_NEXT_RE.test(l) || MOCHA_STACK_RE.test(l)) { + break; } } + return j; +}; - // group blocks by file, preserving order so the last block is the final attempt - const byFile = new Map(); - for (const block of blocks) { - if (!byFile.has(block.file)) { - byFile.set(block.file, []); +const gatherTitle = (clean, start, end, firstPart) => { + const parts = [firstPart]; + for (let k = start + 1; k < end; k++) { + parts.push(clean[k].trim()); + } + return parts.join(' ').replace(/:$/, '').replace(/\s+/g, ' ').trim(); +}; + +// Index of the next numbered entry (or end of log), bounding this entry's stack. +const nextEntryIndex = (clean, start) => { + for (let k = start + 1; k < clean.length; k++) { + if (MOCHA_NEXT_RE.test(clean[k])) { + return k; } - byFile.get(block.file).push(block); } + return clean.length; +}; - const failures = []; - for (const [file, fileBlocks] of byFile) { - const relevant = includeAllAttempts ? fileBlocks : [fileBlocks[fileBlocks.length - 1]]; - for (const block of relevant) { - for (const title of block.fails) { - failures.push({ title, file, source: 'wdio' }); - } +const matchSpecFile = (line) => { + const m = line.match(SPEC_FILE_RE); + return m && /tests\//.test(m[0]) ? m[0] : null; +}; + +const findSpecFile = (entryLines) => { + for (const line of entryLines) { + const file = matchSpecFile(line); + if (file) { + return file; } } - return failures; + return null; +}; + +// Build the failure for the entry starting at `start`, plus the index to resume +// scanning from (after the title — stack lines are skipped by the main loop). +const parseMochaEntry = (clean, start, firstPart) => { + const titleEnd = findTitleEnd(clean, start); + const entryEnd = nextEntryIndex(clean, start); + return { + failure: { + title: gatherTitle(clean, start, titleEnd, firstPart), + file: findSpecFile(clean.slice(start, entryEnd)), + source: 'mocha', + }, + next: titleEnd, + }; }; -// Parse mocha "spec" reporter trailing "N failing" summary. Each entry looks like: -// 1) Suite title -// sub-suite -// test name: -// AssertionError: ... -// at Context.<anonymous> (tests/integration/.../foo.spec.js:12:34) const parseMochaLog = (lines) => { const clean = lines.map(cleanLine); const failures = []; let i = 0; while (i < clean.length) { - const header = clean[i].match(/^\s{1,4}(\d+)\)\s+(.*\S)\s*$/); + const header = clean[i].match(MOCHA_HEADER_RE); if (!header) { i++; continue; } - // gather the multi-line title: indented lines until we hit the error/stack - const titleParts = [header[2]]; - let j = i + 1; - for (; j < clean.length; j++) { - const l = clean[j]; - if (!l.trim()) { - break; - } - // start of next numbered failure - if (/^\s{1,4}\d+\)\s/.test(l)) { - break; - } - // error message / stack lines: typically " SomeError: ..." or "at ..." - if (/^\s*(at\s|[A-Z]\w*(Error|Exception):|AssertionError|\+ expected|- actual|Error:)/.test(l)) { - break; - } - titleParts.push(l.trim()); - } - // scan forward (within this entry) for a spec-file path in the stack - let file = null; - for (let k = i; k < clean.length; k++) { - if (k > i && /^\s{1,4}\d+\)\s/.test(clean[k])) { - break; - } - const m = clean[k].match(SPEC_FILE_RE); - if (m && /tests\//.test(m[0])) { - file = m[0]; - break; - } - } - const title = titleParts.join(' ').replace(/:$/, '').replace(/\s+/g, ' ').trim(); - failures.push({ title, file, source: 'mocha' }); - i = j; + const { failure, next } = parseMochaEntry(clean, i, header[2]); + failures.push(failure); + i = next; } return failures; }; @@ -318,31 +393,30 @@ const mapWithConcurrency = async (items, limit, worker) => { // Main // --------------------------------------------------------------------------- +const runsEndpoint = (opts, perPage, page) => { + const params = [`per_page=${perPage}`, `page=${page}`]; + if (opts.branch) { + params.push(`branch=${encodeURIComponent(opts.branch)}`); + } + if (!opts.scanAll) { + params.push('status=failure'); // only runs that ended in failure + } + if (opts.since) { + params.push(`created=${encodeURIComponent('>=' + opts.since)}`); + } + return `/repos/${opts.repo}/actions/workflows/build.yml/runs?${params.join('&')}`; +}; + const listRuns = (opts) => { const runs = []; const perPage = 100; let page = 1; while (runs.length < opts.limit) { - const params = [`per_page=${perPage}`, `page=${page}`]; - if (opts.branch) { - params.push(`branch=${encodeURIComponent(opts.branch)}`); - } - if (!opts.scanAll) { - params.push('status=failure'); // only runs that ended in failure - } - if (opts.since) { - params.push(`created=${encodeURIComponent('>=' + opts.since)}`); - } - const endpoint = `/repos/${opts.repo}/actions/workflows/build.yml/runs?${params.join('&')}`; - const data = ghJson(endpoint); - const batch = data.workflow_runs || []; - if (!batch.length) { - break; - } + const batch = ghJson(runsEndpoint(opts, perPage, page)).workflow_runs || []; runs.push(...batch); page++; if (batch.length < perPage) { - break; + break; // last (or empty) page reached } } return runs.slice(0, opts.limit); @@ -357,48 +431,52 @@ const fetchJobLog = (repo, jobId) => { return res.stdout; }; -const scanRun = async (run, opts, matcher) => { - let jobsData; - try { - jobsData = ghJson(`/repos/${opts.repo}/actions/runs/${run.id}/jobs?per_page=100`); - } catch (err) { +const isScannableJob = (job, opts) => { + if (!TEST_JOB_RE.test(job.name)) { + return false; + } + if (opts.jobMatcher && !opts.jobMatcher(job.name)) { + return false; + } + return opts.scanAll || job.conclusion === 'failure'; +}; + +const scanJob = async (job, run, opts, matcher) => { + const log = fetchJobLog(opts.repo, job.id); + if (log === null) { if (opts.debug) { - process.stderr.write(`run ${run.id}: failed to list jobs: ${err.message}\n`); + process.stderr.write(`run ${run.id} job ${job.name}: log unavailable\n`); } return []; } - const jobs = (jobsData.jobs || []).filter((job) => { - if (!TEST_JOB_RE.test(job.name)) { - return false; - } - if (!opts.scanAll && job.conclusion !== 'failure') { - return false; - } - return true; - }); - - const perJob = await mapWithConcurrency(jobs, opts.concurrency, async (job) => { - const log = fetchJobLog(opts.repo, job.id); - if (log === null) { - if (opts.debug) { - process.stderr.write(`run ${run.id} job ${job.name}: log unavailable\n`); - } - return []; - } - const lines = log.split('\n'); - const failures = isIntegrationJob(job.name) - ? parseMochaLog(lines) - : parseWdioLog(lines, opts.scanAll); - - const matched = failures.filter((f) => matcher(f.title || '') || (f.file && matcher(f.file))); + const lines = log.split('\n'); + const failures = isIntegrationJob(job.name) + ? parseMochaLog(lines) + : parseWdioLog(lines, opts.scanAll); + + const matched = failures.filter((f) => matcher(f.title || '') || (f.file && matcher(f.file))); + if (opts.debug) { + process.stderr.write(`run ${run.id} job ${job.name}: ` + + `${failures.length} failures, ${matched.length} matched\n`); + } + return matched.map((f) => ({ ...f, jobName: job.name, jobUrl: job.html_url })); +}; +const listRunJobs = (run, opts) => { + try { + const jobsData = ghJson(`/repos/${opts.repo}/actions/runs/${run.id}/jobs?per_page=100`); + return jobsData.jobs || []; + } catch (err) { if (opts.debug) { - process.stderr.write(`run ${run.id} job ${job.name}: ` + - `${failures.length} failures, ${matched.length} matched\n`); + process.stderr.write(`run ${run.id}: failed to list jobs: ${err.message}\n`); } - return matched.map((f) => ({ ...f, jobName: job.name, jobUrl: job.html_url })); - }); + return []; + } +}; +const scanRun = async (run, opts, matcher) => { + const jobs = listRunJobs(run, opts).filter((job) => isScannableJob(job, opts)); + const perJob = await mapWithConcurrency(jobs, opts.concurrency, (job) => scanJob(job, run, opts, matcher)); return perJob.flat(); }; @@ -417,28 +495,26 @@ const formatRow = (run, failures) => { }; }; -const printTable = (matchedRuns, scannedCount, opts) => { - if (!matchedRuns.length) { - console.log(`\nNo failures of "${opts.term}" found in ${scannedCount} scanned run(s).`); - if (!opts.scanAll) { - console.log('(Only failed runs/jobs were scanned. Use --scan-all to also catch ' + - 'specs that failed but were retried-and-passed.)'); - } - return; +const printNoMatches = (scannedCount, opts) => { + console.log(`\nNo failures of "${opts.term}" found in ${scannedCount} scanned run(s).`); + if (!opts.scanAll) { + console.log('(Only failed runs/jobs were scanned. Use --scan-all to also catch ' + + 'specs that failed but were retried-and-passed.)'); } - console.log(`\n"${opts.term}" failed in ${matchedRuns.length} of ${scannedCount} scanned run(s):\n`); - for (const row of matchedRuns) { - const prNote = row.event === 'pull_request' ? ' (PR)' : ''; - console.log(`● ${row.date} ${row.branch}${prNote} [run #${row.runNumber}, ${row.conclusion}]`); - console.log(` jobs: ${row.jobs.join(', ')}`); - for (const title of row.titles) { - console.log(` fail: ${title}`); - } - console.log(` ${row.url}`); - console.log(''); +}; + +const printRun = (row) => { + const prNote = row.event === 'pull_request' ? ' (PR)' : ''; + console.log(`● ${row.date} ${row.branch}${prNote} [run #${row.runNumber}, ${row.conclusion}]`); + console.log(` jobs: ${row.jobs.join(', ')}`); + for (const title of row.titles) { + console.log(` fail: ${title}`); } + console.log(` ${row.url}`); + console.log(''); +}; - // simple branch breakdown +const printBranchBreakdown = (matchedRuns) => { const byBranch = {}; for (const row of matchedRuns) { byBranch[row.branch] = (byBranch[row.branch] || 0) + 1; @@ -450,27 +526,39 @@ const printTable = (matchedRuns, scannedCount, opts) => { } }; -const main = async () => { - const opts = parseArgs(process.argv.slice(2)); +const printTable = (matchedRuns, scannedCount, opts) => { + if (!matchedRuns.length) { + printNoMatches(scannedCount, opts); + return; + } + console.log(`\n"${opts.term}" failed in ${matchedRuns.length} of ${scannedCount} scanned run(s):\n`); + matchedRuns.forEach(printRun); + printBranchBreakdown(matchedRuns); +}; + +const handleHelp = (opts) => { if (opts.help || !opts.term) { console.log(HELP); process.exit(opts.term ? 0 : 1); } +}; + +const validateLimit = (opts) => { if (!Number.isFinite(opts.limit) || opts.limit <= 0) { throw new Error('--limit must be a positive number'); } +}; - assertAuth(); - const matcher = buildMatcher(opts.term); - - process.stderr.write(`Listing build.yml runs (${opts.scanAll ? 'all' : 'failed-only'}` + - `${opts.branch ? `, branch=${opts.branch}` : ', all branches'}` + - `${opts.since ? `, since=${opts.since}` : ''}, limit=${opts.limit})...\n`); - const runs = listRuns(opts); - process.stderr.write(`Scanning ${runs.length} run(s) for "${opts.term}"...\n`); +const describeScan = (opts) => { + const scope = opts.scanAll ? 'all' : 'failed-only'; + const branch = opts.branch ? `, branch=${opts.branch}` : ', all branches'; + const since = opts.since ? `, since=${opts.since}` : ''; + return `Listing build.yml runs (${scope}${branch}${since}, limit=${opts.limit})...`; +}; +// scan runs sequentially; log downloads within each run are parallelised +const scanAllRuns = async (runs, opts, matcher) => { const matchedRuns = []; - // scan runs sequentially; log downloads within each run are parallelised for (let i = 0; i < runs.length; i++) { const run = runs[i]; process.stderr.write(` [${i + 1}/${runs.length}] run #${run.run_number} (${run.head_branch})\r`); @@ -479,8 +567,10 @@ const main = async () => { matchedRuns.push(formatRow(run, failures)); } } - process.stderr.write('\n'); + return matchedRuns; +}; +const emitResults = (matchedRuns, runs, opts) => { if (opts.json) { console.log(JSON.stringify({ term: opts.term, @@ -488,9 +578,28 @@ const main = async () => { failedRuns: matchedRuns.length, runs: matchedRuns, }, null, 2)); - } else { - printTable(matchedRuns, runs.length, opts); + return; } + printTable(matchedRuns, runs.length, opts); +}; + +const main = async () => { + const opts = parseArgs(process.argv.slice(2)); + handleHelp(opts); + validateLimit(opts); + + assertAuth(); + const matcher = buildMatcher(opts.term); + opts.jobMatcher = opts.job ? buildMatcher(opts.job) : null; + + process.stderr.write(`${describeScan(opts)}\n`); + const runs = listRuns(opts); + process.stderr.write(`Scanning ${runs.length} run(s) for "${opts.term}"...\n`); + + const matchedRuns = await scanAllRuns(runs, opts, matcher); + process.stderr.write('\n'); + + emitResults(matchedRuns, runs, opts); }; if (require.main === module) { From cfe780c451e7d534f96578536091693640783845 Mon Sep 17 00:00:00 2001 From: Diana Barsan <barsan@medic.org> Date: Sat, 20 Jun 2026 07:13:07 +0300 Subject: [PATCH 03/10] ci: add workflow to find and analyze test failures --- .github/workflows/find-test-failures.yml | 66 ++++++++++++++++++++++++ scripts/ci/find-test-failures.js | 10 ++-- 2 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/find-test-failures.yml diff --git a/.github/workflows/find-test-failures.yml b/.github/workflows/find-test-failures.yml new file mode 100644 index 00000000000..37c7019706e --- /dev/null +++ b/.github/workflows/find-test-failures.yml @@ -0,0 +1,66 @@ +name: Find test failures + +on: + workflow_dispatch: + inputs: + search_term: + description: 'Substring or /regex/ matched against the failing test title and spec-file path' + type: string + required: true + job: + description: 'Only scan jobs whose name matches (substring or /regex/), e.g. lowLevel or sentinel or core' + type: string + default: '' + branch: + description: 'Only scan runs on this branch (empty = all branches)' + type: string + default: '' + since: + description: 'Only runs created on/after this date (YYYY-MM-DD)' + type: string + default: '' + limit: + description: 'Max number of runs to scan' + type: string + default: '100' + scan_all: + description: 'Also scan green runs / passing jobs (catches flaky-recovered failures)' + type: boolean + default: false + +permissions: + contents: read + actions: read + +jobs: + find_test_failures: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Use Node 24 + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 # zizmor: ignore[cache-poisoning] + with: + node-version: 24 + - name: Run failure scanner + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SEARCH_TERM: ${{ github.event.inputs.search_term }} + JOB: ${{ github.event.inputs.job }} + BRANCH: ${{ github.event.inputs.branch }} + SINCE: ${{ github.event.inputs.since }} + LIMIT: ${{ github.event.inputs.limit }} + SCAN_ALL: ${{ github.event.inputs.scan_all }} + run: | + args=("$SEARCH_TERM" --repo "$GITHUB_REPOSITORY" --limit "$LIMIT") + [ -n "$JOB" ] && args+=(--job "$JOB") + [ -n "$BRANCH" ] && args+=(--branch "$BRANCH") + [ -n "$SINCE" ] && args+=(--since "$SINCE") + [ "$SCAN_ALL" = "true" ] && args+=(--scan-all) + { + echo '```' + node scripts/ci/find-test-failures.js "${args[@]}" + echo '```' + } | tee -a "$GITHUB_STEP_SUMMARY" \ No newline at end of file diff --git a/scripts/ci/find-test-failures.js b/scripts/ci/find-test-failures.js index a8456010cc1..7242705a3ca 100644 --- a/scripts/ci/find-test-failures.js +++ b/scripts/ci/find-test-failures.js @@ -46,7 +46,6 @@ * that failed an attempt but were retried-and-passed * (flaky-recovered). Slower. Default scans only failed * runs / failed jobs (i.e. final, job-failing failures). - * --concurrency <n> Parallel log downloads (default: 6). * --json Emit machine-readable JSON instead of a table. * --debug Print per-job parsing diagnostics to stderr. * -h, --help Show this help. @@ -81,7 +80,6 @@ const ARG_FLAGS = { '--repo': { key: 'repo', parse: asString }, '--job': { key: 'job', parse: asString }, '--limit': { key: 'limit', parse: Number }, - '--concurrency': { key: 'concurrency', parse: Number }, }; const applyPositional = (rest, arg) => { @@ -116,7 +114,6 @@ const parseArgs = (argv) => { job: null, repo: 'medic/cht-core', scanAll: false, - concurrency: 6, json: false, debug: false, }; @@ -145,7 +142,6 @@ Options: --job <name> Only scan jobs whose name matches (substring or /regex/); faster, since it skips other suites' log downloads. --scan-all Also scan green runs / passing jobs (catches flaky-recovered). - --concurrency <n> Parallel log downloads (default: 6). --json Emit JSON instead of a table. --debug Print parsing diagnostics to stderr. -h, --help Show this help. @@ -373,6 +369,10 @@ const TEST_JOB_RE = /(ci-webdriver|wdio-performance|ci-integration|test-cht-form // Concurrency helper // --------------------------------------------------------------------------- +// Parallel log downloads per run. Modest enough to stay well under GitHub's +// secondary rate limits while keeping each run's scan fast. +const LOG_DOWNLOAD_CONCURRENCY = 6; + const mapWithConcurrency = async (items, limit, worker) => { const results = new Array(items.length); let next = 0; @@ -476,7 +476,7 @@ const listRunJobs = (run, opts) => { const scanRun = async (run, opts, matcher) => { const jobs = listRunJobs(run, opts).filter((job) => isScannableJob(job, opts)); - const perJob = await mapWithConcurrency(jobs, opts.concurrency, (job) => scanJob(job, run, opts, matcher)); + const perJob = await mapWithConcurrency(jobs, LOG_DOWNLOAD_CONCURRENCY, (job) => scanJob(job, run, opts, matcher)); return perJob.flat(); }; From 23b7355db1e32b78551b05f7f9b203057700cef3 Mon Sep 17 00:00:00 2001 From: Diana Barsan <barsan@medic.org> Date: Sun, 21 Jun 2026 07:43:55 +0300 Subject: [PATCH 04/10] ci: fix index increment logic in test failure analysis script --- scripts/ci/find-test-failures.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/ci/find-test-failures.js b/scripts/ci/find-test-failures.js index 7242705a3ca..4c9b10aedc9 100644 --- a/scripts/ci/find-test-failures.js +++ b/scripts/ci/find-test-failures.js @@ -118,8 +118,9 @@ const parseArgs = (argv) => { debug: false, }; const rest = []; - for (let i = 0; i < argv.length; i++) { - i = applyArg(opts, rest, argv, i); + let i = 0; + while (i < argv.length) { + i = applyArg(opts, rest, argv, i) + 1; } opts.term = rest[0] || null; return opts; From f0d1d13807d864fdc8df75c2474004c77f774180 Mon Sep 17 00:00:00 2001 From: Diana Barsan <barsan@medic.org> Date: Thu, 23 Jul 2026 09:23:47 +0300 Subject: [PATCH 05/10] ci: address review feedback on test failure scanner - replace hand-rolled CLI parsing with node:util parseArgs - hardcode maxBuffer in runGh instead of a never-used option - move build.yml-coupled job-name constants to the top of the file - drop unused module exports and the require.main guard Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- scripts/ci/find-test-failures.js | 121 ++++++++++++------------------- 1 file changed, 47 insertions(+), 74 deletions(-) diff --git a/scripts/ci/find-test-failures.js b/scripts/ci/find-test-failures.js index 4c9b10aedc9..02da86a2386 100644 --- a/scripts/ci/find-test-failures.js +++ b/scripts/ci/find-test-failures.js @@ -60,70 +60,50 @@ 'use strict'; const { spawnSync } = require('node:child_process'); +const { parseArgs: nodeParseArgs } = require('node:util'); // --------------------------------------------------------------------------- -// CLI parsing +// CI workflow coupling — keep in sync with .github/workflows/build.yml // --------------------------------------------------------------------------- -const asString = (value) => value; - -// Flag table: boolean flags carry `flag: true`; value flags carry a `parse` -// that converts the following argv token. -const ARG_FLAGS = { - '-h': { key: 'help', flag: true }, - '--help': { key: 'help', flag: true }, - '--scan-all': { key: 'scanAll', flag: true }, - '--json': { key: 'json', flag: true }, - '--debug': { key: 'debug', flag: true }, - '--branch': { key: 'branch', parse: asString }, - '--since': { key: 'since', parse: asString }, - '--repo': { key: 'repo', parse: asString }, - '--job': { key: 'job', parse: asString }, - '--limit': { key: 'limit', parse: Number }, -}; - -const applyPositional = (rest, arg) => { - if (arg.startsWith('--')) { - throw new Error(`Unknown option: ${arg}`); - } - rest.push(arg); -}; +const CI_WORKFLOW_FILE = 'build.yml'; +// Jobs that run tests and whose logs are worth scanning. +const TEST_JOB_RE = /(ci-webdriver|wdio-performance|ci-integration|test-cht-form|integration-cht-form|upgrade-)/i; +// Integration jobs use mocha; every other job in TEST_JOB_RE is wdio. +const INTEGRATION_JOB_RE = /ci-integration/i; -// Apply argv[i] to opts. Returns the index of the last token consumed (i for a -// boolean/positional, i+1 for a value flag). -const applyArg = (opts, rest, argv, i) => { - const spec = ARG_FLAGS[argv[i]]; - if (!spec) { - applyPositional(rest, argv[i]); - return i; - } - if (spec.flag) { - opts[spec.key] = true; - return i; - } - opts[spec.key] = spec.parse(argv[i + 1]); - return i + 1; -}; +// --------------------------------------------------------------------------- +// CLI parsing +// --------------------------------------------------------------------------- const parseArgs = (argv) => { - const opts = { - term: null, - branch: null, - limit: 100, - since: null, - job: null, - repo: 'medic/cht-core', - scanAll: false, - json: false, - debug: false, + const { values, positionals } = nodeParseArgs({ + args: argv, + allowPositionals: true, + options: { + help: { type: 'boolean', short: 'h', default: false }, + 'scan-all': { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, + debug: { type: 'boolean', default: false }, + branch: { type: 'string' }, + since: { type: 'string' }, + repo: { type: 'string', default: 'medic/cht-core' }, + job: { type: 'string' }, + limit: { type: 'string', default: '100' }, + }, + }); + return { + term: positionals[0] || null, + branch: values.branch || null, + since: values.since || null, + job: values.job || null, + repo: values.repo, + limit: Number(values.limit), + scanAll: values['scan-all'], + json: values.json, + debug: values.debug, + help: values.help, }; - const rest = []; - let i = 0; - while (i < argv.length) { - i = applyArg(opts, rest, argv, i) + 1; - } - opts.term = rest[0] || null; - return opts; }; const HELP = `Search CI history for runs where a specific test failed. @@ -152,8 +132,9 @@ Options: // gh helpers // --------------------------------------------------------------------------- -const runGh = (args, { allowFail = false, maxBuffer = 256 * 1024 * 1024 } = {}) => { - const res = spawnSync('gh', args, { encoding: 'utf8', maxBuffer }); +const runGh = (args, { allowFail = false } = {}) => { + // 256MB: job logs can be tens of MB, well above spawnSync's 1MB default + const res = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); if (res.error) { if (res.error.code === 'ENOENT') { throw new Error('`gh` CLI not found. Install it: https://cli.github.com/'); @@ -171,7 +152,7 @@ const ghJson = (endpoint) => { return JSON.parse(stdout); }; -const assertAuth = () => { +const assertAuth = () => { const res = runGh(['auth', 'status'], { allowFail: true }); if (res.status !== 0 && !process.env.GH_TOKEN && !process.env.GITHUB_TOKEN) { throw new Error( @@ -362,10 +343,6 @@ const parseMochaLog = (lines) => { return failures; }; -const isIntegrationJob = (jobName) => /ci-integration/i.test(jobName); - -const TEST_JOB_RE = /(ci-webdriver|wdio-performance|ci-integration|test-cht-form|integration-cht-form|upgrade-)/i; - // --------------------------------------------------------------------------- // Concurrency helper // --------------------------------------------------------------------------- @@ -405,7 +382,7 @@ const runsEndpoint = (opts, perPage, page) => { if (opts.since) { params.push(`created=${encodeURIComponent('>=' + opts.since)}`); } - return `/repos/${opts.repo}/actions/workflows/build.yml/runs?${params.join('&')}`; + return `/repos/${opts.repo}/actions/workflows/${CI_WORKFLOW_FILE}/runs?${params.join('&')}`; }; const listRuns = (opts) => { @@ -451,7 +428,7 @@ const scanJob = async (job, run, opts, matcher) => { return []; } const lines = log.split('\n'); - const failures = isIntegrationJob(job.name) + const failures = INTEGRATION_JOB_RE.test(job.name) ? parseMochaLog(lines) : parseWdioLog(lines, opts.scanAll); @@ -554,7 +531,7 @@ const describeScan = (opts) => { const scope = opts.scanAll ? 'all' : 'failed-only'; const branch = opts.branch ? `, branch=${opts.branch}` : ', all branches'; const since = opts.since ? `, since=${opts.since}` : ''; - return `Listing build.yml runs (${scope}${branch}${since}, limit=${opts.limit})...`; + return `Listing ${CI_WORKFLOW_FILE} runs (${scope}${branch}${since}, limit=${opts.limit})...`; }; // scan runs sequentially; log downloads within each run are parallelised @@ -603,11 +580,7 @@ const main = async () => { emitResults(matchedRuns, runs, opts); }; -if (require.main === module) { - main().catch((err) => { - process.stderr.write(`\nError: ${err.message}\n`); - process.exit(1); - }); -} - -module.exports = { parseWdioLog, parseMochaLog, cleanLine, buildMatcher }; +main().catch((err) => { + process.stderr.write(`\nError: ${err.message}\n`); + process.exit(1); +}); From 20c9a8ed3b551e6ab797af997fa514a857903439 Mon Sep 17 00:00:00 2001 From: Diana Barsan <barsan@medic.org> Date: Thu, 23 Jul 2026 16:56:42 +0300 Subject: [PATCH 06/10] ci: TEMP push trigger to test find-test-failures workflow Remove this commit before merging to master. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --- .github/workflows/find-test-failures.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/find-test-failures.yml b/.github/workflows/find-test-failures.yml index 37c7019706e..4fa41dd513a 100644 --- a/.github/workflows/find-test-failures.yml +++ b/.github/workflows/find-test-failures.yml @@ -1,6 +1,8 @@ name: Find test failures on: + push: # TEMP: remove before merge — only for testing the workflow on this branch + branches: [11194-add-test-failure-scanner] workflow_dispatch: inputs: search_term: @@ -47,11 +49,12 @@ jobs: - name: Run failure scanner env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SEARCH_TERM: ${{ github.event.inputs.search_term }} + # TEMP fallbacks after || are for push-trigger testing only — remove with the push trigger before merge + SEARCH_TERM: ${{ github.event.inputs.search_term || 'timed out' }} JOB: ${{ github.event.inputs.job }} BRANCH: ${{ github.event.inputs.branch }} SINCE: ${{ github.event.inputs.since }} - LIMIT: ${{ github.event.inputs.limit }} + LIMIT: ${{ github.event.inputs.limit || '10' }} SCAN_ALL: ${{ github.event.inputs.scan_all }} run: | args=("$SEARCH_TERM" --repo "$GITHUB_REPOSITORY" --limit "$LIMIT") From 59b2e3a6361cf083f24730bce08adb483a1a5913 Mon Sep 17 00:00:00 2001 From: Diana Barsan <barsan@medic.org> Date: Thu, 23 Jul 2026 18:39:14 +0300 Subject: [PATCH 07/10] ci: refine search term in test failure scanner for improved targeting --- .github/workflows/find-test-failures.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/find-test-failures.yml b/.github/workflows/find-test-failures.yml index 4fa41dd513a..8f0dc3d9f40 100644 --- a/.github/workflows/find-test-failures.yml +++ b/.github/workflows/find-test-failures.yml @@ -50,7 +50,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} # TEMP fallbacks after || are for push-trigger testing only — remove with the push trigger before merge - SEARCH_TERM: ${{ github.event.inputs.search_term || 'timed out' }} + SEARCH_TERM: ${{ github.event.inputs.search_term || 'africas-talking.wdio-spec.js' }} JOB: ${{ github.event.inputs.job }} BRANCH: ${{ github.event.inputs.branch }} SINCE: ${{ github.event.inputs.since }} From 16ad5360b4e1ad57b0888efea075c24637075743 Mon Sep 17 00:00:00 2001 From: Diana Barsan <barsan@medic.org> Date: Thu, 23 Jul 2026 19:27:03 +0300 Subject: [PATCH 08/10] ci: strengthen log parsing and input validation in failure scanner --- scripts/ci/find-test-failures.js | 53 +++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/scripts/ci/find-test-failures.js b/scripts/ci/find-test-failures.js index 02da86a2386..4636688eeaf 100644 --- a/scripts/ci/find-test-failures.js +++ b/scripts/ci/find-test-failures.js @@ -191,7 +191,8 @@ const buildMatcher = (term) => { // Shared helpers // --------------------------------------------------------------------------- -const SPEC_FILE_RE = /(?:[\w./-]*\/)?[\w.-]+\.(?:wdio-spec|spec)\.js/; +const SPEC_FILE_SUFFIX_RE = /\.(?:wdio-spec|spec)\.js/; +const SPEC_FILE_CHAR_RE = /[\w./-]/; const groupBy = (items, keyFn) => { const map = new Map(); @@ -210,8 +211,14 @@ const groupBy = (items, keyFn) => { // --------------------------------------------------------------------------- const WDIO_SPEC_HEADER_RE = /^\s*(?:Spec:|»)\s*(\S+\.js)\s*$/; -const WDIO_STREAMING_RE = /✖.*»\s*\[/; -const WDIO_FAIL_RE = /(?:\[FAIL\]|✖)\s*(.+?)\s*$/; +const WDIO_STREAMING_TAIL_RE = /»\s*\[/; +const WDIO_FAIL_RE = /(?:\[FAIL\]|✖)(.*)$/; + +// "✖ <title> » [ <path> ]" — split on the marker so the regex stays linear. +const isStreamingLine = (line) => { + const marker = line.indexOf('✖'); + return marker !== -1 && WDIO_STREAMING_TAIL_RE.test(line.slice(marker + 1)); +}; // Fold one (prefix-stripped) wdio log line into the running block list, and // return the block that subsequent failure lines belong to. @@ -225,12 +232,15 @@ const foldWdioLine = (line, blocks, current) => { // Streaming concise lines ("✖ <title> » [ <path> ]") repeat every attempt and // aren't tied to an open block; skip them. The grouped block re-reports the // same failures and is authoritative for retry handling. - if (WDIO_STREAMING_RE.test(line)) { + if (isStreamingLine(line)) { return current; } const failMatch = line.match(WDIO_FAIL_RE); if (failMatch && current) { - current.fails.push(failMatch[1].trim()); + const title = failMatch[1].trim(); + if (title) { + current.fails.push(title); + } } return current; }; @@ -262,7 +272,7 @@ const parseWdioLog = (lines, includeAllAttempts) => { // Mocha "N failing" entry: " 1) Suite title", indented sub-titles, then the // error message / stack. These delimit the title and the stack respectively. -const MOCHA_HEADER_RE = /^\s{1,4}(\d+)\)\s+(.*\S)\s*$/; +const MOCHA_HEADER_RE = /^\s{1,4}(\d+)\)\s+(\S.*)$/; const MOCHA_NEXT_RE = /^\s{1,4}\d+\)\s/; const MOCHA_STACK_RE = /^\s*(at\s|[A-Z]\w*(Error|Exception):|AssertionError|\+ expected|- actual|Error:)/; @@ -283,7 +293,7 @@ const gatherTitle = (clean, start, end, firstPart) => { for (let k = start + 1; k < end; k++) { parts.push(clean[k].trim()); } - return parts.join(' ').replace(/:$/, '').replace(/\s+/g, ' ').trim(); + return parts.join(' ').replace(/\s+/g, ' ').trim().replace(/:$/, ''); }; // Index of the next numbered entry (or end of log), bounding this entry's stack. @@ -296,9 +306,19 @@ const nextEntryIndex = (clean, start) => { return clean.length; }; +// Locate the spec suffix, then extend backwards over path characters. A single +// path regex here backtracks super-linearly on non-matching lines (sonar S8786). const matchSpecFile = (line) => { - const m = line.match(SPEC_FILE_RE); - return m && /tests\//.test(m[0]) ? m[0] : null; + const suffix = line.match(SPEC_FILE_SUFFIX_RE); + if (!suffix) { + return null; + } + let start = suffix.index; + while (start > 0 && SPEC_FILE_CHAR_RE.test(line[start - 1])) { + start--; + } + const file = line.slice(start, suffix.index + suffix[0].length); + return file.includes('tests/') ? file : null; }; const findSpecFile = (entryLines) => { @@ -521,10 +541,21 @@ const handleHelp = (opts) => { } }; -const validateLimit = (opts) => { +// repo and since are interpolated into `gh api` endpoint paths — allowlist +// their shape so arbitrary CLI input can't reshape the request (sonar S8705). +const REPO_RE = /^[\w.-]+\/[\w.-]+$/; +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +const validateOpts = (opts) => { if (!Number.isFinite(opts.limit) || opts.limit <= 0) { throw new Error('--limit must be a positive number'); } + if (!REPO_RE.test(opts.repo)) { + throw new Error('--repo must be in owner/repo format'); + } + if (opts.since && !DATE_RE.test(opts.since)) { + throw new Error('--since must be a YYYY-MM-DD date'); + } }; const describeScan = (opts) => { @@ -564,7 +595,7 @@ const emitResults = (matchedRuns, runs, opts) => { const main = async () => { const opts = parseArgs(process.argv.slice(2)); handleHelp(opts); - validateLimit(opts); + validateOpts(opts); assertAuth(); const matcher = buildMatcher(opts.term); From d4b2e2996520bb59951efaa7c0bbc2c93bbfc7fa Mon Sep 17 00:00:00 2001 From: Diana Barsan <barsan@medic.org> Date: Thu, 23 Jul 2026 19:34:19 +0300 Subject: [PATCH 09/10] ci: streamline failure parsing with regex refactor and helper abstraction --- scripts/ci/find-test-failures.js | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/scripts/ci/find-test-failures.js b/scripts/ci/find-test-failures.js index 4636688eeaf..f4305d17ccd 100644 --- a/scripts/ci/find-test-failures.js +++ b/scripts/ci/find-test-failures.js @@ -212,7 +212,7 @@ const groupBy = (items, keyFn) => { const WDIO_SPEC_HEADER_RE = /^\s*(?:Spec:|»)\s*(\S+\.js)\s*$/; const WDIO_STREAMING_TAIL_RE = /»\s*\[/; -const WDIO_FAIL_RE = /(?:\[FAIL\]|✖)(.*)$/; +const WDIO_FAIL_MARKER_RE = /\[FAIL\]|✖/; // "✖ <title> » [ <path> ]" — split on the marker so the regex stays linear. const isStreamingLine = (line) => { @@ -220,6 +220,19 @@ const isStreamingLine = (line) => { return marker !== -1 && WDIO_STREAMING_TAIL_RE.test(line.slice(marker + 1)); }; +// The title is everything after the fail marker; slicing it off in code keeps +// the regex quantifier-free (sonar S8786). +const recordFailLine = (line, block) => { + const marker = line.match(WDIO_FAIL_MARKER_RE); + if (!marker || !block) { + return; + } + const title = line.slice(marker.index + marker[0].length).trim(); + if (title) { + block.fails.push(title); + } +}; + // Fold one (prefix-stripped) wdio log line into the running block list, and // return the block that subsequent failure lines belong to. const foldWdioLine = (line, blocks, current) => { @@ -235,13 +248,7 @@ const foldWdioLine = (line, blocks, current) => { if (isStreamingLine(line)) { return current; } - const failMatch = line.match(WDIO_FAIL_RE); - if (failMatch && current) { - const title = failMatch[1].trim(); - if (title) { - current.fails.push(title); - } - } + recordFailLine(line, current); return current; }; From 6d3601ec147ea9e57f4616dca3ecbdcf15b06f85 Mon Sep 17 00:00:00 2001 From: Diana Barsan <barsan@medic.org> Date: Thu, 23 Jul 2026 19:43:42 +0300 Subject: [PATCH 10/10] ci: suppress sonar lint warnings in `runGh` invocation comments --- scripts/ci/find-test-failures.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/ci/find-test-failures.js b/scripts/ci/find-test-failures.js index f4305d17ccd..217ee9dd655 100644 --- a/scripts/ci/find-test-failures.js +++ b/scripts/ci/find-test-failures.js @@ -133,8 +133,10 @@ Options: // --------------------------------------------------------------------------- const runGh = (args, { allowFail = false } = {}) => { - // 256MB: job logs can be tens of MB, well above spawnSync's 1MB default - const res = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); + // 256MB: job logs can be tens of MB, well above spawnSync's 1MB default. + // NOSONAR suppresses S8705 (argv-array spawn — no shell to escape) and + // S4036 (resolving gh from PATH is inherent to wrapping a CLI). + const res = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 }); // NOSONAR if (res.error) { if (res.error.code === 'ENOENT') { throw new Error('`gh` CLI not found. Install it: https://cli.github.com/');