mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Stop the strict acceptance from waiting for itself
A scenario whose body ran past about 19 s idled until 45 s after its start, and twice one never exited and hit the runner's 300 s timeout after it had passed: something of Chrome outlived Browser.close and kept its stdout and stderr open. - scripts/qa/browser.mjs closes Chrome within 5 s, kills its process group and closes its pipes; the harness then finishes and exits with the scenario's code. QA_EXIT_DIAGNOSTICS=1 keeps the old ending and prints the open handles and Chrome's process tree 5 s after finish. - Every strict page starts with its clock at the 18:30 UTC nearest to the real time, so Navara's sun is up over the test cities whenever the acceptance runs; a scenario can ask for its own instant or the real clock (Wildlife does, for its freshness fixtures). - The runner records a scenario that passed but did not exit as hung, kills the whole process group on a timeout, runs a failed scenario once more and reports one that passes then as flaky, and appends each run to --history=FILE; scripts/report-acceptance-flake.mjs reads it.
This commit is contained in:
1 parent
a913e5466e
commit
ed6ae54ef6
19 files changed
+1039
-104
No files matched your search
@@ -0,0 +1,84 @@
|
||||
export const STATUSES = ['pass', 'flaky', 'hung', 'timeout', 'fail'];
|
||||
|
||||
export function classifyAttempt(run, manifest, { external = false } = {}) {
|
||||
if (run.error?.code === 'ETIMEDOUT')
|
||||
return manifest?.status === 'pass' ? 'hung' : 'timeout';
|
||||
if (run.error || run.signal || run.status !== 0) return 'fail';
|
||||
return manifest?.status === 'pass' || (external && !manifest)
|
||||
? 'pass'
|
||||
: 'fail';
|
||||
}
|
||||
|
||||
export function retryStatus(first, second) {
|
||||
return first !== 'pass' && second === 'pass' ? 'flaky' : (second ?? first);
|
||||
}
|
||||
|
||||
export function statusCounts(scenarios) {
|
||||
return Object.fromEntries(
|
||||
STATUSES.map((status) => [
|
||||
status,
|
||||
scenarios.filter((row) => row.status === status).length,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function historyLine(report) {
|
||||
return {
|
||||
commit: report.commit,
|
||||
dirty: report.dirty,
|
||||
startedAt: report.startedAt,
|
||||
finishedAt: report.finishedAt,
|
||||
seconds:
|
||||
(Date.parse(report.finishedAt) - Date.parse(report.startedAt)) / 1000,
|
||||
counts: statusCounts(report.scenarios),
|
||||
// Keep the denominator correct for --only runs too.
|
||||
scenarios: report.scenarios.map((row) => row.label),
|
||||
nonPass: report.scenarios
|
||||
.filter((row) => row.status !== 'pass')
|
||||
.map(({ label, status }) => ({ label, status })),
|
||||
};
|
||||
}
|
||||
|
||||
export function flakeReport(history) {
|
||||
const rows = new Map();
|
||||
for (const run of history) {
|
||||
const nonPass = new Set(run.nonPass.map((row) => row.label));
|
||||
for (const label of run.scenarios) {
|
||||
const row = rows.get(label) ?? { label, runs: 0, passes: 0 };
|
||||
row.runs++;
|
||||
if (!nonPass.has(label)) row.passes++;
|
||||
rows.set(label, row);
|
||||
}
|
||||
}
|
||||
const share = (row) => ({
|
||||
...row,
|
||||
nonPassShare: row.runs ? (row.runs - row.passes) / row.runs : 0,
|
||||
});
|
||||
const scenarios = [...rows.values()]
|
||||
.map(share)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.nonPassShare - a.nonPassShare || a.label.localeCompare(b.label),
|
||||
);
|
||||
const overall = share(
|
||||
scenarios.reduce(
|
||||
(total, row) => ({
|
||||
runs: total.runs + row.runs,
|
||||
passes: total.passes + row.passes,
|
||||
}),
|
||||
{ runs: 0, passes: 0 },
|
||||
),
|
||||
);
|
||||
return { scenarios, overall };
|
||||
}
|
||||
|
||||
export function formatFlakeReport(history) {
|
||||
const { scenarios, overall } = flakeReport(history);
|
||||
return [
|
||||
'scenario\truns\tpasses\tnon-pass',
|
||||
...[...scenarios, { label: 'overall', ...overall }].map(
|
||||
(row) =>
|
||||
`${row.label}\t${row.runs}\t${row.passes}\t${(row.nonPassShare * 100).toFixed(1)}%`,
|
||||
),
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { processTree, readProcesses } from './process.mjs';
|
||||
|
||||
/** Bound shutdown even when Chrome leaves descendants holding its pipes. */
|
||||
export async function closeBrowser(browser, { deadlineMs = 5000 } = {}) {
|
||||
const start = performance.now();
|
||||
let child;
|
||||
let closed = false;
|
||||
let killed = false;
|
||||
let timer;
|
||||
try {
|
||||
child = browser.process();
|
||||
} catch {}
|
||||
try {
|
||||
closed = await Promise.race([
|
||||
Promise.resolve()
|
||||
.then(() => browser.close())
|
||||
.then(
|
||||
() => true,
|
||||
() => false,
|
||||
),
|
||||
new Promise((resolve) => {
|
||||
timer = setTimeout(() => resolve(false), deadlineMs);
|
||||
}),
|
||||
]);
|
||||
} catch {}
|
||||
clearTimeout(timer);
|
||||
if (process.env.QA_EXIT_DIAGNOSTICS !== '1') {
|
||||
try {
|
||||
if (Number.isInteger(child?.pid) && child.pid > 0) {
|
||||
// The group can outlive its leader, so probe the group itself.
|
||||
process.kill(-child.pid, 0);
|
||||
process.kill(-child.pid, 'SIGKILL');
|
||||
killed = true;
|
||||
}
|
||||
} catch {}
|
||||
for (const name of ['stdout', 'stderr']) {
|
||||
try {
|
||||
child?.[name]?.destroy();
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
return { closed, killed, ms: performance.now() - start };
|
||||
}
|
||||
|
||||
export async function exitAfterFlush(code = process.exitCode ?? 0) {
|
||||
process.exitCode = code;
|
||||
if (process.env.QA_EXIT_DIAGNOSTICS === '1') return;
|
||||
await Promise.all(
|
||||
[process.stdout, process.stderr].map(
|
||||
(stream) =>
|
||||
new Promise((resolve) => {
|
||||
if (stream.destroyed) return resolve();
|
||||
stream.write('', resolve);
|
||||
}),
|
||||
),
|
||||
);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
export function diagnoseExit(pids) {
|
||||
if (process.env.QA_EXIT_DIAGNOSTICS !== '1') return;
|
||||
setTimeout(() => {
|
||||
console.error('QA EXIT resources', process.getActiveResourcesInfo());
|
||||
const processes = readProcesses();
|
||||
console.error('QA EXIT Chrome: pid ppid pgid elapsed command');
|
||||
for (const pid of pids) {
|
||||
const rows = processTree(pid, processes);
|
||||
console.error(`Chrome ${pid}:`);
|
||||
for (const row of rows) console.error(row.line);
|
||||
if (!rows.length) console.error('(no remaining processes)');
|
||||
}
|
||||
}, 5000).unref();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { assertKeylessPreview } from '../qa-strict-harness.mjs';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import sharp from 'sharp';
|
||||
import { DAYTIME, shiftTime } from './time.mjs';
|
||||
|
||||
/** A still view over Austin, no layers, OSM imagery. */
|
||||
export const STILL_VIEW =
|
||||
@@ -18,21 +19,8 @@ export const STILL_VIEW =
|
||||
* the styles' signatures read) does not follow the time of day the
|
||||
* scenario happens to run at. Default: Austin early afternoon.
|
||||
*/
|
||||
export function pinDaytime(page, utc = '2026-11-03T18:30:00Z') {
|
||||
return page.evaluateOnNewDocument((utc) => {
|
||||
const offset = Date.parse(utc) - Date.now();
|
||||
const RealDate = Date;
|
||||
class ShiftedDate extends RealDate {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) super(RealDate.now() + offset);
|
||||
else super(...args);
|
||||
}
|
||||
static now() {
|
||||
return RealDate.now() + offset;
|
||||
}
|
||||
}
|
||||
globalThis.Date = ShiftedDate;
|
||||
}, utc);
|
||||
export function pinDaytime(page, utc = DAYTIME) {
|
||||
return page.evaluateOnNewDocument(shiftTime, utc);
|
||||
}
|
||||
|
||||
/** The keyless page on `engine` with the still view (and `extra` hash). */
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { execFileSync, spawn } from 'node:child_process';
|
||||
|
||||
export function readProcesses() {
|
||||
try {
|
||||
return execFileSync('ps', ['-axo', 'pid=,ppid=,pgid=,etime=,command='], {
|
||||
encoding: 'utf8',
|
||||
timeout: 2000,
|
||||
maxBuffer: 1 << 24,
|
||||
})
|
||||
.trim()
|
||||
.split('\n')
|
||||
.flatMap((line) => {
|
||||
const match = line
|
||||
.trim()
|
||||
.match(/^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.*)$/);
|
||||
return match
|
||||
? [
|
||||
{
|
||||
pid: Number(match[1]),
|
||||
ppid: Number(match[2]),
|
||||
pgid: Number(match[3]),
|
||||
elapsed: match[4],
|
||||
command: match[5],
|
||||
line: line.trim(),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Include the group even when its leader has already exited. */
|
||||
export function processTree(pid, processes) {
|
||||
const ids = new Set([pid]);
|
||||
let changed;
|
||||
do {
|
||||
changed = false;
|
||||
for (const row of processes) {
|
||||
if (!ids.has(row.pid) && (ids.has(row.ppid) || row.pgid === pid)) {
|
||||
ids.add(row.pid);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
} while (changed);
|
||||
return processes.filter((row) => ids.has(row.pid));
|
||||
}
|
||||
|
||||
/** Sequential callers get captured output and a deadline for the whole tree. */
|
||||
export function runScenario(command, args, { timeout, env }) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
env,
|
||||
detached: true,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
const groups = new Set(child.pid ? [child.pid] : []);
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
let bytes = 0;
|
||||
let error = null;
|
||||
let done = false;
|
||||
const rememberGroups = () => {
|
||||
const processes = readProcesses();
|
||||
for (const pid of [...groups]) {
|
||||
const rows = processTree(pid, processes);
|
||||
if (processes.length && !rows.length && pid !== child.pid)
|
||||
groups.delete(pid);
|
||||
for (const row of rows) groups.add(row.pgid);
|
||||
}
|
||||
};
|
||||
const finish = (status, signal) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(deadline);
|
||||
clearInterval(watch);
|
||||
resolve({
|
||||
status,
|
||||
signal,
|
||||
error,
|
||||
stdout: Buffer.concat(stdout).toString(),
|
||||
stderr: Buffer.concat(stderr).toString(),
|
||||
});
|
||||
};
|
||||
const stop = (code, message) => {
|
||||
if (done) return;
|
||||
error = Object.assign(new Error(message), { code });
|
||||
rememberGroups();
|
||||
// Chrome detaches into another group; retain it before Node can exit.
|
||||
for (const pid of [...groups].reverse()) {
|
||||
try {
|
||||
if (pid > 0) process.kill(-pid, 'SIGKILL');
|
||||
} catch {}
|
||||
}
|
||||
child.stdout.destroy();
|
||||
child.stderr.destroy();
|
||||
child.unref();
|
||||
finish(null, 'SIGKILL');
|
||||
};
|
||||
const capture = (chunks) => (chunk) => {
|
||||
bytes += chunk.length;
|
||||
if (bytes > 1 << 26) stop('ENOBUFS', 'Scenario output exceeded 64 MiB');
|
||||
else chunks.push(chunk);
|
||||
};
|
||||
child.stdout.on('data', capture(stdout));
|
||||
child.stderr.on('data', capture(stderr));
|
||||
child.on('error', (failure) => {
|
||||
error = failure;
|
||||
});
|
||||
child.on('close', finish);
|
||||
// Keep detached descendants identifiable if Chrome's leader exits first.
|
||||
const watch = setInterval(rememberGroups, 1000);
|
||||
const deadline = setTimeout(
|
||||
() => stop('ETIMEDOUT', `Scenario timed out after ${timeout} ms`),
|
||||
timeout,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
/**
|
||||
* Austin's early afternoon (18:30 UTC) nearest to `now`. The page clock then
|
||||
* moves by at most 12 hours: the sun is up over the test cities whenever a
|
||||
* scenario runs, and data stamped with the real time stays current.
|
||||
*/
|
||||
export function daytimeNear(now = Date.now()) {
|
||||
const day = new Date(now);
|
||||
const at = Date.UTC(
|
||||
day.getUTCFullYear(),
|
||||
day.getUTCMonth(),
|
||||
day.getUTCDate(),
|
||||
18,
|
||||
30,
|
||||
);
|
||||
const nearest = [at - DAY_MS, at, at + DAY_MS].reduce((best, next) =>
|
||||
Math.abs(next - now) < Math.abs(best - now) ? next : best,
|
||||
);
|
||||
return new Date(nearest).toISOString();
|
||||
}
|
||||
|
||||
export const DAYTIME = daytimeNear();
|
||||
|
||||
/** Keep elapsed time moving while giving the sun a repeatable position. */
|
||||
export function shiftTime(utc) {
|
||||
if (utc === 'real') return;
|
||||
const offset = Date.parse(utc) - Date.now();
|
||||
const RealDate = Date;
|
||||
class ShiftedDate extends RealDate {
|
||||
constructor(...args) {
|
||||
if (args.length === 0) super(RealDate.now() + offset);
|
||||
else super(...args);
|
||||
}
|
||||
static now() {
|
||||
return RealDate.now() + offset;
|
||||
}
|
||||
}
|
||||
globalThis.Date = ShiftedDate;
|
||||
}
|
||||
Reference in new issue
Block a user