Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
120 lines
6.7 KiB
JavaScript
120 lines
6.7 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 MSD_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:dequeue` 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 MSD_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 five event types run-tests.cjs needs are
|
|
// handled — `test:enqueue`/`test:dequeue` (emitted by the RUNNER as it queues
|
|
// and begins each spawned test-file child, independent of whether anything
|
|
// inside that file ever completes) plus `test:start`/`test:pass`/`test:fail`
|
|
// (emitted per-subtest, once the child reports it). `test:dequeue` is the
|
|
// event that actually means "in flight": a subtest inside a file that hangs
|
|
// forever never reaches `test:start`/`test:pass`/`test:fail` at all, because
|
|
// node:test only surfaces those to the parent once the child COMPLETES that
|
|
// test — a hang, by definition, never completes. Recording `test:dequeue`
|
|
// closes that gap: it fires the moment the runner begins the file, so a
|
|
// killed hang still leaves a durable "this file was running" record.
|
|
// 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.MSD_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:enqueue' ||
|
|
event.type === 'test:dequeue' ||
|
|
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).
|
|
};
|