Files
msd-core/scripts/lib/ndjson-reporter.cjs
sim 444137e63a fix(#4012): make the artifact say whether the reporter ever loaded
Down to 3 remote failures, all one chain. The explicit no-events reporting is
working — the diagnostic now states the events file does not exist, instead of
silently printing the generic message. But it then ASSERTED a cause: "the child
was killed before the reporter wrote even one event (process/spawn startup
stall, not a test hang)". That was a guess dressed as a finding, and the fixture
contradicts it: it starts a real test, so test:start should fire in milliseconds
against a 2000ms budget.

Two hypotheses remained and I could not separate them locally, because the local
test runner is hook-blocked here: either the custom reporter never LOADS in the
child, or it loads and no event reaches it before the SIGKILL.

Rather than guess a third time, the artifact now answers it. The reporter
appends a reporter:init line as its first action, before consuming anything, so
the file's contents discriminate: absent means the reporter never loaded;
init-only means it loaded and saw no test events; init plus events means it
works. The diagnostic has a branch for each and, where the cause is genuinely
unresolved, names both possibilities instead of picking one.

Also passes the reporter as a file:// URL via pathToFileURL. Node documents the
--test-reporter value as an import()-style specifier, and a bare absolute path
is not a portable one — notably on Windows. That is a correctness fix whichever
hypothesis holds, and it is a live candidate for the first. FIXED_OVERHEAD is
derived by reducing over the actual argv strings, so the longer URL is accounted
automatically.

Verified by execution, not assumption: composing the reporter against an EMPTY
event stream writes exactly one line, the init marker. That is the whole point
of the marker, so it is pinned by a test rather than left to inspection.

T1 stays red and untouched.

Verification runs on the remote runner.

Refs #4012
2026-08-28 19:41:32 -04:00

108 lines
5.9 KiB
JavaScript

'use strict';
// Machine-readable companion reporter for scripts/run-tests.cjs (#3889).
//
// node:test's built-in reporters (spec/tap) are human-formatted and
// scripts/run-tests.cjs spawns the child with `stdio: 'inherit'` — by design,
// per #3597/#1051, to avoid the maxBuffer and live-output risks of piping —
// so the parent process has no way to see WHICH file was executing when a
// per-chunk timeout kills the child. This reporter runs ALONGSIDE the normal
// human reporter (a second `--test-reporter` on the same invocation, per
// Node's documented multi-reporter pairing) and appends one JSON object per
// line to a path supplied via the GSD_RUN_TESTS_EVENTS_FILE env var. On a
// timeout, run-tests.cjs reads that file back to name the file(s) still
// in flight (a `test:start` with no matching `test:pass`/`test:fail`).
//
// Durability, not `--test-reporter-destination` (#3889 root cause): a
// reporter that YIELDS strings has them piped by Node into a
// `fs.WriteStream` targeting the destination path, and that stream buffers.
// The parent's `execFileSync` timeout SIGKILLs the child on a hang, and
// SIGKILL is uncatchable and gives the process zero chance to flush — so a
// yield-based reporter can lose every event still sitting in the stream's
// buffer, which is exactly the case this feature exists to diagnose (proven
// live: a chunk killed at 2006ms produced a `killed after 2006ms` line from
// the TIMER, which lives in the parent, but zero usable events from the
// reporter, which lives in the child and never flushed). Writing each event
// with `fs.appendFileSync` — synchronous and unbuffered — makes it durable
// the instant it happens, before the process can be killed out from under
// it. The reporter therefore yields NOTHING; it is a pure side-effecting
// sink. Node still requires a `--test-reporter-destination` to pair with
// this `--test-reporter` (see run-tests.cjs's reporterArgsFor), but that
// destination is a throwaway sink that stays empty by design — the durable
// path is GSD_RUN_TESTS_EVENTS_FILE, not the destination Node manages.
//
// Contract targeted: Node's "Custom reporters" contract
// (https://nodejs.org/api/test.html#custom-reporters) — a reporter module's
// default export is a function receiving the test runner's event stream (an
// AsyncIterable of `{ type, data }` objects). Node feeds that function to
// `stream.compose` as the stream's "body". When the body is an async
// FUNCTION (not an `async function*` generator), `stream.compose`'s own
// contract (https://nodejs.org/api/stream.html#streamcomposestreams)
// requires it to return nully (undefined/null) — returning anything else,
// including an array, throws `ERR_INVALID_RETURN_VALUE` ("Expected nully to
// be returned from the 'body' function but got an instance of Array")
// exactly once the promise resolves. Verified directly against
// `stream.compose` in this repo's Node: calling it with a body that
// `return`s `[]` reproduces that same TypeError. A generator form
// (`async function*`) is the one that yields an iterable; the plain
// `async function` form used here is the one that must return nully. This
// repo's `engines.node` requires >=24.0.0 (package.json), where both
// contracts have been stable since Node 20.
//
// Kept intentionally tiny: only the three event types run-tests.cjs needs to
// pair start/completion are handled; everything else (diagnostics, plans,
// coverage) is ignored so a truncated events file (the process is SIGKILLed
// mid-`appendFileSync` on timeout — an individual write is unbuffered but
// not atomic, so the OS can still interleave a partial write with the kill)
// never leaves more than one dangling unparsable trailing line.
module.exports = async function ndjsonEventReporter(source) {
const eventsPath = process.env.GSD_RUN_TESTS_EVENTS_FILE;
// #3889: an init marker, written as this reporter's FIRST action — before
// the `for await` loop even begins consuming the event stream — so the
// events file's mere existence (and its exact contents) can distinguish
// "the reporter module never loaded in the child at all" (file absent)
// from "it loaded fine but no test:start reached it before the kill"
// (file contains only this one line) from "it's working" (file contains
// more than this line). Same appendFileSync durability rationale as every
// other write in this file: synchronous and unbuffered, so it survives an
// uncatchable SIGKILL landing a moment later.
if (eventsPath) {
try {
require('fs').appendFileSync(eventsPath, `${JSON.stringify({ type: 'reporter:init', ts: Date.now() })}\n`);
} catch {
// Best-effort, same as every other write below — must never crash the
// test run this reporter is only observing.
}
}
for await (const event of source) {
if (!eventsPath) continue; // no destination configured — nothing to record
if (
event.type === 'test:start' ||
event.type === 'test:pass' ||
event.type === 'test:fail'
) {
const { file, name, nesting, testNumber } = event.data || {};
const line = `${JSON.stringify({
type: event.type,
file,
name,
nesting,
testNumber,
ts: Date.now(),
})}\n`;
try {
require('fs').appendFileSync(eventsPath, line);
} catch {
// Best-effort: a write failure here (e.g. the events dir vanished)
// must never crash the test run this reporter is only observing.
}
}
}
// Falls through to an implicit `return undefined` (nully): this reporter is
// a pure side-effecting sink (see the durability note above), never a
// source of reporter OUTPUT, and `stream.compose` requires its async
// FUNCTION body to return nully — returning an iterable (e.g. `[]`) here
// raises `ERR_INVALID_RETURN_VALUE` (observed live: this exact `return []`
// crashed every chunk on the real remote run this regresses).
};