* feat(#4422): block merging into next/main while the base branch's Tests run is red Adds a next-health job to test.yml that checks the base branch's own last push-triggered Tests run via the GitHub API and fails the existing "Required tests" required check when it's red, with a maintainer-applied "fix-next" label as the explicit escape hatch for the fix-forward PR itself. No branch-protection config change needed — it rides the already-required check. The job is deliberately not gated behind preflight, same reasoning as the changes job: a compute-free API read has nothing to save by waiting. Documents the fix-next label in CONTRIBUTING.md and adds a property test locking the CLEAN/RED/INDETERMINATE classification's iff-relationship. This closes the second half of the 2026-09-06 RCA: three unrelated PRs merged on top of an already-broken next before anyone noticed it was red. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: close two zero-margin CI timing gaps found while verifying #4422 Discovered while watching this branch's own CI, root-caused via /diagnose rather than dismissed as Windows flakiness: 1. tests/gsd-check-update-worker-atomic-cache.test.cjs's outer timeout (15000ms) exactly matched the inner npm-view timeout the worker wraps (NPM_VIEW_TIMEOUT_MS, gsd-core/bin/check-latest-version.cjs). A slow registry response raced two SIGKILLs at the same instant, killing the worker before it could catch its own timeout and degrade gracefully. Windows's shell-wrapped npm subprocess made the race lose more often there, but the zero margin was platform-agnostic. Fixed by giving the test real headroom (+10s) beyond the named constant it wraps, plus an invariant test so the two values can't silently collide again. 2. scripts/run-tests.cjs's per-chunk weight budget (MAX_FILES_PER_CHUNK) let a Windows full-matrix chunk that was well under budget by the Linux/macOS-calibrated weight table (~32/60 units) still exceed the 600s wall-clock backstop — codex-config.test.cjs's genuinely-measured weight (17.87) doesn't transfer 1:1 to Windows's slower install/ subprocess overhead. Windows now gets its own lower cap (40 vs 60). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
553 lines
22 KiB
JavaScript
553 lines
22 KiB
JavaScript
'use strict';
|
|
|
|
// #4422 — base-branch health gate.
|
|
//
|
|
// Risk asymmetry drives this suite (see scripts/ci-next-health.cjs's header):
|
|
// false positive (a healthy `next` called RED) => blocks every PR merge
|
|
// until a human notices and applies the `fix-next` bypass label.
|
|
// false negative (a broken `next` called healthy) => reproduces the #4422
|
|
// incident exactly (three unrelated PRs merged on top of an already-broken
|
|
// `next`).
|
|
// Unlike the mergeability preflight, this gate does NOT fail open on a
|
|
// definite RED signal — only on an unavailable/inapplicable one.
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const http = require('node:http');
|
|
const fc = require('fast-check');
|
|
|
|
const { createTempDir, cleanup } = require('./helpers.cjs');
|
|
const { runNode } = require('./helpers/process-seam.cjs');
|
|
const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const SCRIPT = path.join(ROOT, 'scripts', 'ci-next-health.cjs');
|
|
|
|
const {
|
|
VERDICT,
|
|
BYPASS_LABEL,
|
|
classifyRunConclusion,
|
|
resolveNextHealth,
|
|
main,
|
|
} = require('../scripts/ci-next-health.cjs');
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fakes. The seams are parameters, not module patches — no global state, so
|
|
// every case is order-independent.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** A fetchLatestRun fake that replays a scripted response; an Error is thrown. */
|
|
function scriptedFetch(response) {
|
|
const calls = [];
|
|
const fn = async (branch) => {
|
|
calls.push(branch);
|
|
if (response instanceof Error) throw response;
|
|
return response;
|
|
};
|
|
fn.calls = calls;
|
|
return fn;
|
|
}
|
|
|
|
function runPayload(conclusion, htmlUrl = 'https://github.com/open-gsd/gsd-core/actions/runs/1') {
|
|
return { workflow_runs: [{ conclusion, html_url: htmlUrl }] };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A. classifyRunConclusion — pure
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ci-next-health: classifyRunConclusion', () => {
|
|
test('classifies an undefined payload as INDETERMINATE', () => {
|
|
assert.equal(classifyRunConclusion(undefined), VERDICT.INDETERMINATE);
|
|
});
|
|
|
|
test('classifies a null payload as INDETERMINATE', () => {
|
|
assert.equal(classifyRunConclusion(null), VERDICT.INDETERMINATE);
|
|
});
|
|
|
|
test('classifies a non-object payload as INDETERMINATE', () => {
|
|
for (const value of [0, 'str', true, 42]) {
|
|
assert.equal(classifyRunConclusion(value), VERDICT.INDETERMINATE);
|
|
}
|
|
});
|
|
|
|
test('classifies a payload whose workflow_runs is not an array as INDETERMINATE', () => {
|
|
for (const value of [undefined, null, 'x', {}, 1]) {
|
|
assert.equal(classifyRunConclusion({ workflow_runs: value }), VERDICT.INDETERMINATE);
|
|
}
|
|
});
|
|
|
|
test('classifies an empty workflow_runs array as CLEAN', () => {
|
|
// A brand-new release/**/hotfix/** branch with no Tests history yet must
|
|
// not be permanently unmergeable.
|
|
assert.equal(classifyRunConclusion({ workflow_runs: [] }), VERDICT.CLEAN);
|
|
});
|
|
|
|
test('classifies conclusion: success as CLEAN', () => {
|
|
assert.equal(classifyRunConclusion(runPayload('success')), VERDICT.CLEAN);
|
|
});
|
|
|
|
test('classifies conclusion: failure as RED', () => {
|
|
assert.equal(classifyRunConclusion(runPayload('failure')), VERDICT.RED);
|
|
});
|
|
|
|
test('classifies conclusion: cancelled as RED', () => {
|
|
// A cancelled/timed-out run on the base branch is not evidence of health.
|
|
assert.equal(classifyRunConclusion(runPayload('cancelled')), VERDICT.RED);
|
|
});
|
|
|
|
test('classifies every non-success conclusion as RED', () => {
|
|
for (const conclusion of ['timed_out', 'action_required', 'neutral', 'skipped', 'stale']) {
|
|
assert.equal(
|
|
classifyRunConclusion(runPayload(conclusion)),
|
|
VERDICT.RED,
|
|
`conclusion=${conclusion} must be RED`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('only reads the FIRST run in workflow_runs', () => {
|
|
const payload = { workflow_runs: [{ conclusion: 'success' }, { conclusion: 'failure' }] };
|
|
assert.equal(classifyRunConclusion(payload), VERDICT.CLEAN);
|
|
});
|
|
|
|
test('VERDICT is frozen and its atom set is locked', () => {
|
|
assert.ok(Object.isFrozen(VERDICT));
|
|
assert.deepEqual(
|
|
Object.keys(VERDICT).sort(),
|
|
['BYPASSED', 'CLEAN', 'INDETERMINATE', 'RED', 'SKIPPED_NOT_APPLICABLE'],
|
|
);
|
|
});
|
|
|
|
// A well-formed payload has an ARRAY workflow_runs of length 0 or 1, whose
|
|
// sole element (if present) carries an arbitrary `conclusion` string. A
|
|
// malformed payload is anything else: a non-object payload, or an object
|
|
// whose `workflow_runs` is not an array.
|
|
const wellFormedPayloadArb = fc.record({
|
|
workflow_runs: fc.oneof(
|
|
fc.constant([]),
|
|
fc.tuple(fc.record({ conclusion: fc.string() })),
|
|
),
|
|
});
|
|
|
|
const malformedNonArrayRunsArb = fc.oneof(
|
|
fc.constant(undefined),
|
|
fc.constant(null),
|
|
fc.string(),
|
|
fc.integer(),
|
|
fc.boolean(),
|
|
fc.dictionary(fc.string(), fc.string()),
|
|
);
|
|
|
|
const malformedPayloadArb = fc.oneof(
|
|
fc.constant(null),
|
|
fc.constant(undefined),
|
|
fc.string(),
|
|
fc.integer(),
|
|
fc.boolean(),
|
|
fc.record({ workflow_runs: malformedNonArrayRunsArb }),
|
|
);
|
|
|
|
const payloadArb = fc.oneof(wellFormedPayloadArb, malformedPayloadArb);
|
|
|
|
test('CLEAN iff (workflow_runs is empty) or (first run succeeded); everything else RED or INDETERMINATE (property)', () => {
|
|
fc.assert(
|
|
fc.property(payloadArb, (payload) => {
|
|
const verdict = classifyRunConclusion(payload);
|
|
|
|
const isObject = payload !== null && typeof payload === 'object';
|
|
const hasArrayRuns = isObject && Array.isArray(payload.workflow_runs);
|
|
|
|
if (!hasArrayRuns) {
|
|
return verdict === VERDICT.INDETERMINATE;
|
|
}
|
|
|
|
const isClean = payload.workflow_runs.length === 0
|
|
|| payload.workflow_runs[0].conclusion === 'success';
|
|
|
|
if (isClean) return verdict === VERDICT.CLEAN;
|
|
return verdict === VERDICT.RED;
|
|
}),
|
|
{ seed: 4422, numRuns: 500, verbose: true },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// B. resolveNextHealth — dependency-injected orchestrator
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ci-next-health: resolveNextHealth', () => {
|
|
test('a push event is SKIPPED_NOT_APPLICABLE without calling fetchLatestRun', async () => {
|
|
const fetchLatestRun = scriptedFetch(runPayload('failure'));
|
|
const result = await resolveNextHealth({ fetchLatestRun, eventName: 'push', baseRef: 'next', labels: [] });
|
|
assert.equal(result.verdict, VERDICT.SKIPPED_NOT_APPLICABLE);
|
|
assert.equal(fetchLatestRun.calls.length, 0);
|
|
});
|
|
|
|
test('workflow_dispatch is SKIPPED_NOT_APPLICABLE without calling fetchLatestRun', async () => {
|
|
const fetchLatestRun = scriptedFetch(runPayload('failure'));
|
|
const result = await resolveNextHealth({
|
|
fetchLatestRun, eventName: 'workflow_dispatch', baseRef: 'next', labels: [],
|
|
});
|
|
assert.equal(result.verdict, VERDICT.SKIPPED_NOT_APPLICABLE);
|
|
assert.equal(fetchLatestRun.calls.length, 0);
|
|
});
|
|
|
|
test('an unknown/absent event name is SKIPPED_NOT_APPLICABLE without calling', async () => {
|
|
for (const eventName of ['', undefined, 'schedule', 'release', 'issues']) {
|
|
const fetchLatestRun = scriptedFetch(runPayload('failure'));
|
|
const result = await resolveNextHealth({ fetchLatestRun, eventName, baseRef: 'next', labels: [] });
|
|
assert.equal(result.verdict, VERDICT.SKIPPED_NOT_APPLICABLE);
|
|
assert.equal(fetchLatestRun.calls.length, 0);
|
|
}
|
|
});
|
|
|
|
test('a pull_request event with no resolvable base ref is INDETERMINATE', async () => {
|
|
for (const baseRef of [undefined, null, '', ' ']) {
|
|
const fetchLatestRun = scriptedFetch(runPayload('failure'));
|
|
const result = await resolveNextHealth({ fetchLatestRun, eventName: 'pull_request', baseRef, labels: [] });
|
|
assert.equal(result.verdict, VERDICT.INDETERMINATE);
|
|
assert.equal(fetchLatestRun.calls.length, 0);
|
|
}
|
|
});
|
|
|
|
test('a merge_group event with no resolvable base ref is INDETERMINATE', async () => {
|
|
const fetchLatestRun = scriptedFetch(runPayload('failure'));
|
|
const result = await resolveNextHealth({ fetchLatestRun, eventName: 'merge_group', baseRef: '', labels: [] });
|
|
assert.equal(result.verdict, VERDICT.INDETERMINATE);
|
|
assert.equal(fetchLatestRun.calls.length, 0);
|
|
});
|
|
|
|
test('a throwing fetchLatestRun degrades to INDETERMINATE', async () => {
|
|
const fetchLatestRun = scriptedFetch(new Error('ECONNRESET'));
|
|
const result = await resolveNextHealth({ fetchLatestRun, eventName: 'pull_request', baseRef: 'next', labels: [] });
|
|
assert.equal(result.verdict, VERDICT.INDETERMINATE);
|
|
});
|
|
|
|
test('a RED verdict with the fix-next label is BYPASSED', async () => {
|
|
const fetchLatestRun = scriptedFetch(runPayload('failure'));
|
|
const result = await resolveNextHealth({
|
|
fetchLatestRun, eventName: 'pull_request', baseRef: 'next', labels: ['needs-triage', BYPASS_LABEL],
|
|
});
|
|
assert.equal(result.verdict, VERDICT.BYPASSED);
|
|
});
|
|
|
|
test('a RED verdict with no matching label stays RED', async () => {
|
|
const fetchLatestRun = scriptedFetch(runPayload('failure'));
|
|
const result = await resolveNextHealth({
|
|
fetchLatestRun, eventName: 'pull_request', baseRef: 'next', labels: ['needs-triage'],
|
|
});
|
|
assert.equal(result.verdict, VERDICT.RED);
|
|
});
|
|
|
|
test('a RED verdict with an absent labels array stays RED (merge_group has no label surface)', async () => {
|
|
const fetchLatestRun = scriptedFetch(runPayload('failure'));
|
|
const result = await resolveNextHealth({
|
|
fetchLatestRun, eventName: 'merge_group', baseRef: 'next', labels: undefined,
|
|
});
|
|
assert.equal(result.verdict, VERDICT.RED);
|
|
});
|
|
|
|
test('a CLEAN verdict (success conclusion) is not affected by labels', async () => {
|
|
const fetchLatestRun = scriptedFetch(runPayload('success'));
|
|
const result = await resolveNextHealth({
|
|
fetchLatestRun, eventName: 'pull_request', baseRef: 'next', labels: [BYPASS_LABEL],
|
|
});
|
|
assert.equal(result.verdict, VERDICT.CLEAN);
|
|
});
|
|
|
|
test('a CLEAN verdict (empty workflow_runs array)', async () => {
|
|
const fetchLatestRun = scriptedFetch({ workflow_runs: [] });
|
|
const result = await resolveNextHealth({
|
|
fetchLatestRun, eventName: 'pull_request', baseRef: 'release/1.0', labels: [],
|
|
});
|
|
assert.equal(result.verdict, VERDICT.CLEAN);
|
|
});
|
|
|
|
test('merge_group resolves the same way as pull_request given a base ref', async () => {
|
|
const fetchLatestRun = scriptedFetch(runPayload('success'));
|
|
const result = await resolveNextHealth({
|
|
fetchLatestRun, eventName: 'merge_group', baseRef: 'next', labels: [],
|
|
});
|
|
assert.equal(result.verdict, VERDICT.CLEAN);
|
|
assert.deepEqual(fetchLatestRun.calls, ['next']);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// C. main() — integration through the process seam against a real local API.
|
|
//
|
|
// The CLI's real fetch path is exercised by pointing GITHUB_API_URL at a
|
|
// throwaway localhost server, so no production test-mode branch exists and
|
|
// nothing reaches api.github.com.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const SENTINEL_TOKEN = 'ghs_sentinel_must_never_be_echoed_4422';
|
|
|
|
/** Start a one-shot API stub. `handler(requestCount)` returns { status, body }. */
|
|
async function startApiStub(handler) {
|
|
let requestCount = 0;
|
|
const server = http.createServer((req, res) => {
|
|
const { status, body } = handler(requestCount++);
|
|
res.writeHead(status, { 'content-type': 'application/json' });
|
|
res.end(typeof body === 'string' ? body : JSON.stringify(body));
|
|
});
|
|
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
const { port } = server.address();
|
|
return {
|
|
url: `http://127.0.0.1:${port}`,
|
|
close: () => new Promise((resolve) => server.close(resolve)),
|
|
get requestCount() { return requestCount; },
|
|
};
|
|
}
|
|
|
|
function runCli(env, { outputPath } = {}) {
|
|
return runNode([SCRIPT], {
|
|
cwd: ROOT,
|
|
timeoutMs: PROBE_TIMEOUT_MS,
|
|
env: {
|
|
...process.env,
|
|
GITHUB_REPOSITORY: 'open-gsd/gsd-core',
|
|
GITHUB_TOKEN: SENTINEL_TOKEN,
|
|
GITHUB_BASE_REF: 'next',
|
|
...(outputPath ? { GITHUB_OUTPUT: outputPath } : {}),
|
|
...env,
|
|
},
|
|
});
|
|
}
|
|
|
|
// `runNode` (tests/helpers/process-seam.cjs) is spawnSync: it blocks this
|
|
// process's event loop for the whole child lifetime. `startApiStub` is an
|
|
// http.createServer living in THIS process, so while spawnSync blocks, the
|
|
// stub can never accept the child's connection. Any test that needs the stub
|
|
// must instead call main() in-process, which keeps this process's event loop
|
|
// live so the stub can actually answer. Mirrors tests/ci-pr-mergeability.test.cjs.
|
|
async function callMain(t, env, { outputPath } = {}) {
|
|
const overrides = {
|
|
GITHUB_REPOSITORY: 'open-gsd/gsd-core',
|
|
GITHUB_TOKEN: SENTINEL_TOKEN,
|
|
GITHUB_BASE_REF: 'next',
|
|
...(outputPath ? { GITHUB_OUTPUT: outputPath } : {}),
|
|
...env,
|
|
};
|
|
const saved = new Map();
|
|
for (const key of Object.keys(overrides)) saved.set(key, process.env[key]);
|
|
Object.assign(process.env, overrides);
|
|
t.after(() => {
|
|
for (const [key, value] of saved) {
|
|
if (value === undefined) delete process.env[key];
|
|
else process.env[key] = value;
|
|
}
|
|
});
|
|
|
|
const stdout = [];
|
|
const stderr = [];
|
|
t.mock.method(process.stdout, 'write', (chunk) => { stdout.push(String(chunk)); return true; });
|
|
t.mock.method(process.stderr, 'write', (chunk) => { stderr.push(String(chunk)); return true; });
|
|
|
|
const code = await main([]);
|
|
return { code, stdout: stdout.join(''), stderr: stderr.join('') };
|
|
}
|
|
|
|
function readOutputs(outputPath) {
|
|
const raw = fs.readFileSync(outputPath, 'utf8');
|
|
const outputs = {};
|
|
for (const line of raw.split(/\r?\n/)) {
|
|
const index = line.indexOf('=');
|
|
if (index > 0) outputs[line.slice(0, index)] = line.slice(index + 1);
|
|
}
|
|
return outputs;
|
|
}
|
|
|
|
describe('ci-next-health: CLI', () => {
|
|
test('exits 0 and writes a skip verdict on a push event', (t) => {
|
|
const dir = createTempDir('next-health-skip-');
|
|
t.after(() => cleanup(dir));
|
|
const outputPath = path.join(dir, 'gh-output');
|
|
|
|
const result = runCli({ GITHUB_EVENT_NAME: 'push' }, { outputPath });
|
|
|
|
assert.equal(result.exitCode, 0, result.stderr);
|
|
assert.equal(readOutputs(outputPath).verdict, VERDICT.SKIPPED_NOT_APPLICABLE);
|
|
});
|
|
|
|
test('exits 1 and annotates the failing run on a RED base branch', async (t) => {
|
|
const dir = createTempDir('next-health-red-');
|
|
const api = await startApiStub(() => ({
|
|
status: 200,
|
|
body: { workflow_runs: [{ conclusion: 'failure', html_url: 'https://example.test/runs/99' }] },
|
|
}));
|
|
t.after(async () => { await api.close(); cleanup(dir); });
|
|
const outputPath = path.join(dir, 'gh-output');
|
|
|
|
const result = await callMain(
|
|
t,
|
|
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url, PR_LABELS: '' },
|
|
{ outputPath },
|
|
);
|
|
|
|
assert.equal(result.code, 1, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
|
const combined = `${result.stdout}${result.stderr}`;
|
|
assert.ok(combined.includes('::error::'), 'must emit a workflow error annotation');
|
|
assert.match(combined, /https:\/\/example\.test\/runs\/99/, 'must name the failing run URL');
|
|
assert.equal(readOutputs(outputPath).verdict, VERDICT.RED);
|
|
});
|
|
|
|
test('exits 0 with a warning when the fix-next label bypasses a RED base branch', async (t) => {
|
|
const dir = createTempDir('next-health-bypass-');
|
|
const api = await startApiStub(() => ({
|
|
status: 200,
|
|
body: { workflow_runs: [{ conclusion: 'failure', html_url: 'https://example.test/runs/100' }] },
|
|
}));
|
|
t.after(async () => { await api.close(); cleanup(dir); });
|
|
const outputPath = path.join(dir, 'gh-output');
|
|
|
|
const result = await callMain(
|
|
t,
|
|
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url, PR_LABELS: `needs-triage,${BYPASS_LABEL}` },
|
|
{ outputPath },
|
|
);
|
|
|
|
assert.equal(result.code, 0, `stdout: ${result.stdout}\nstderr: ${result.stderr}`);
|
|
const combined = `${result.stdout}${result.stderr}`;
|
|
assert.ok(combined.includes('::warning::'), 'must warn that a human bypassed the gate');
|
|
assert.match(combined, /https:\/\/example\.test\/runs\/100/, 'the warning must name the failing run URL');
|
|
assert.ok(!combined.includes('::error::'), 'a bypassed gate must not also emit an error annotation');
|
|
assert.equal(readOutputs(outputPath).verdict, VERDICT.BYPASSED);
|
|
});
|
|
|
|
test('exits 0 on a clean base branch', async (t) => {
|
|
const dir = createTempDir('next-health-clean-');
|
|
const api = await startApiStub(() => ({ status: 200, body: { workflow_runs: [{ conclusion: 'success' }] } }));
|
|
t.after(async () => { await api.close(); cleanup(dir); });
|
|
const outputPath = path.join(dir, 'gh-output');
|
|
|
|
const result = await callMain(
|
|
t,
|
|
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url, PR_LABELS: '' },
|
|
{ outputPath },
|
|
);
|
|
|
|
assert.equal(result.code, 0, result.stderr);
|
|
assert.equal(readOutputs(outputPath).verdict, VERDICT.CLEAN);
|
|
});
|
|
|
|
test('resolves the merge_group base ref from MERGE_GROUP_BASE_REF, stripping refs/heads/', async (t) => {
|
|
const dir = createTempDir('next-health-mergequeue-');
|
|
const api = await startApiStub(() => ({ status: 200, body: { workflow_runs: [{ conclusion: 'success' }] } }));
|
|
t.after(async () => { await api.close(); cleanup(dir); });
|
|
const outputPath = path.join(dir, 'gh-output');
|
|
|
|
const result = await callMain(
|
|
t,
|
|
{ GITHUB_EVENT_NAME: 'merge_group', MERGE_GROUP_BASE_REF: 'refs/heads/next', GITHUB_API_URL: api.url },
|
|
{ outputPath },
|
|
);
|
|
|
|
assert.equal(result.code, 0, result.stderr);
|
|
assert.equal(readOutputs(outputPath).verdict, VERDICT.CLEAN);
|
|
});
|
|
|
|
test('fails open when the API rejects the read', async (t) => {
|
|
const dir = createTempDir('next-health-403-');
|
|
const api = await startApiStub(() => ({ status: 403, body: { message: 'Resource not accessible' } }));
|
|
t.after(async () => { await api.close(); cleanup(dir); });
|
|
const outputPath = path.join(dir, 'gh-output');
|
|
|
|
const result = await callMain(
|
|
t,
|
|
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url },
|
|
{ outputPath },
|
|
);
|
|
|
|
assert.equal(result.code, 0, 'an unreadable base branch is not a red base branch');
|
|
assert.equal(readOutputs(outputPath).verdict, VERDICT.INDETERMINATE);
|
|
});
|
|
|
|
test('fails open when the API body is not a JSON object', async (t) => {
|
|
const dir = createTempDir('next-health-badjson-');
|
|
const api = await startApiStub(() => ({ status: 200, body: 'not json at all' }));
|
|
t.after(async () => { await api.close(); cleanup(dir); });
|
|
const outputPath = path.join(dir, 'gh-output');
|
|
|
|
const result = await callMain(
|
|
t,
|
|
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url },
|
|
{ outputPath },
|
|
);
|
|
|
|
assert.equal(result.code, 0);
|
|
assert.equal(readOutputs(outputPath).verdict, VERDICT.INDETERMINATE);
|
|
});
|
|
|
|
test('works when GITHUB_OUTPUT is not set', (t) => {
|
|
const dir = createTempDir('next-health-nooutput-');
|
|
t.after(() => cleanup(dir));
|
|
|
|
const result = runCli({ GITHUB_EVENT_NAME: 'push', GITHUB_OUTPUT: '' });
|
|
|
|
assert.equal(result.exitCode, 0, result.stderr);
|
|
});
|
|
|
|
test('an unwritable GITHUB_OUTPUT does not change the exit code', async (t) => {
|
|
// A failure while REPORTING the verdict must never invert the gate.
|
|
// Injected by pointing at a path whose parent does not exist — no mode-bit
|
|
// tricks, which root Docker/CI silently bypasses.
|
|
const dir = createTempDir('next-health-badout-');
|
|
const api = await startApiStub(() => ({ status: 200, body: { workflow_runs: [{ conclusion: 'failure' }] } }));
|
|
t.after(async () => { await api.close(); cleanup(dir); });
|
|
const outputPath = path.join(dir, 'no-such-dir', 'gh-output');
|
|
|
|
const result = await callMain(
|
|
t,
|
|
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url, PR_LABELS: '' },
|
|
{ outputPath },
|
|
);
|
|
|
|
assert.equal(result.code, 1, 'a RED base branch must still exit 1 when the output write fails');
|
|
});
|
|
|
|
test('never echoes the token', async (t) => {
|
|
const dir = createTempDir('next-health-token-');
|
|
const api = await startApiStub(() => ({ status: 500, body: { message: 'boom' } }));
|
|
t.after(async () => { await api.close(); cleanup(dir); });
|
|
|
|
const result = await callMain(
|
|
t,
|
|
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url },
|
|
{ outputPath: path.join(dir, 'gh-output') },
|
|
);
|
|
|
|
assert.ok(!`${result.stdout}${result.stderr}`.includes(SENTINEL_TOKEN));
|
|
});
|
|
|
|
test('reports a failure without a stack trace', async (t) => {
|
|
const dir = createTempDir('next-health-nostack-');
|
|
const api = await startApiStub(() => ({ status: 200, body: { workflow_runs: [{ conclusion: 'failure' }] } }));
|
|
t.after(async () => { await api.close(); cleanup(dir); });
|
|
|
|
const result = await callMain(
|
|
t,
|
|
{ GITHUB_EVENT_NAME: 'pull_request', GITHUB_API_URL: api.url, PR_LABELS: '' },
|
|
{ outputPath: path.join(dir, 'gh-output') },
|
|
);
|
|
|
|
assert.ok(!`${result.stdout}${result.stderr}`.includes(' at '), 'no raw stack frames');
|
|
});
|
|
|
|
test('prints usage', () => {
|
|
const result = runNode([SCRIPT, '--help'], { cwd: ROOT, timeoutMs: PROBE_TIMEOUT_MS });
|
|
assert.equal(result.exitCode, 0);
|
|
assert.ok(/Usage/i.test(result.stdout));
|
|
});
|
|
|
|
test('rejects an unknown argument', () => {
|
|
const result = runNode([SCRIPT, '--nope'], { cwd: ROOT, timeoutMs: PROBE_TIMEOUT_MS });
|
|
assert.notEqual(result.exitCode, 0);
|
|
assert.ok(`${result.stdout}${result.stderr}`.includes('--nope'));
|
|
});
|
|
});
|