Skip to content

Commit 6262b58

Browse files
committed
1.10.1: make the check script fail loudly on wedged test runs and count tests across all vitest invocations instead of only the first
1 parent 0057a62 commit 6262b58

3 files changed

Lines changed: 147 additions & 18 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@lenne.tech/nuxt-extensions",
3-
"version": "1.10.0",
3+
"version": "1.10.1",
44
"description": "Reusable Nuxt 4 composables, components, and Better-Auth integration for lenne.tech projects",
55
"repository": {
66
"type": "git",

scripts/check.mjs

Lines changed: 145 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
* Exit code: 0 when every step passed, 1 otherwise (preserves the contract the
2626
* lt-dev `running-check-script` skill relies on: non-zero === failed).
2727
*/
28-
import { spawn } from "node:child_process";
28+
import { execSync, spawn } from "node:child_process";
2929
import { readdirSync, readFileSync } from "node:fs";
3030
import { dirname, join } from "node:path";
3131
import { fileURLToPath } from "node:url";
@@ -107,16 +107,29 @@ function toFixCommand(kind, cmd) {
107107
}
108108

109109
// ── metric parsers ─────────────────────────────────────────────────────────
110+
// Sum capture group 1 across every match of `re` (which must carry the `g` flag).
111+
// Returns null when nothing matched, so callers can tell "absent" from "zero".
112+
function sumMatches(clean, re) {
113+
let total = null;
114+
for (const m of clean.matchAll(re)) {
115+
const n = Number(m[1]);
116+
if (Number.isFinite(n)) total = (total ?? 0) + n;
117+
}
118+
return total;
119+
}
120+
// A single test step may invoke vitest more than once (e.g. `vitest:unit && vitest`),
121+
// emitting one summary block per run. Sum them all — reading only the first
122+
// silently under-reports every later run.
110123
function parseVitest(out) {
111124
const clean = stripAnsi(out);
112-
const tests = clean.match(/Tests\s+(?:(\d+)\s+failed[^\n]*?)?(\d+)\s+passed/i);
113-
const files = clean.match(/Test Files\s+(?:(\d+)\s+failed[^\n]*?)?(\d+)\s+passed/i);
114-
const failed = clean.match(/Tests\s+(\d+)\s+failed/i);
115-
if (!tests && !files) return null;
125+
const passed = sumMatches(clean, /Tests\s+(?:\d+\s+failed[^\n]*?)?(\d+)\s+passed/gi);
126+
const files = sumMatches(clean, /Test Files\s+(?:\d+\s+failed[^\n]*?)?(\d+)\s+passed/gi);
127+
const failed = sumMatches(clean, /Tests\s+(\d+)\s+failed/gi);
128+
if (passed == null && files == null) return null;
116129
return {
117-
failed: failed ? Number(failed[1]) : 0,
118-
files: files ? Number(files[2]) : null,
119-
passed: tests ? Number(tests[2]) : null,
130+
failed: failed ?? 0,
131+
files,
132+
passed,
120133
};
121134
}
122135
function parseLint(out) {
@@ -150,21 +163,100 @@ async function runAudit(auditCmd) {
150163
return { auditCmd, blocking: code !== 0, counts, reason: counts ? null : out, total };
151164
}
152165

166+
// Watchdog: kill a TEST step whose child produces NO output for this long. A
167+
// wedged test run (workers idle at 0% CPU — e.g. one spec file grinding through
168+
// retries after its app/socket state broke under load) otherwise spins the live
169+
// view forever: the spinner only proves the child process exists, not that it
170+
// progresses. Only test steps are watched: build / typecheck / audit
171+
// legitimately buffer all their output to the end (and go silent under a
172+
// non-TTY pipe), so watching them would false-kill a slow-but-progressing run.
173+
// Override with --idle-timeout=<seconds> or CHECK_IDLE_TIMEOUT (seconds); 0
174+
// disables it.
175+
const IDLE_TIMEOUT_MS = (() => {
176+
const flag = process.argv.find((a) => a.startsWith("--idle-timeout="));
177+
const raw = flag ? flag.slice("--idle-timeout=".length) : process.env.CHECK_IDLE_TIMEOUT;
178+
const DEFAULT_MS = 300 * 1000;
179+
if (raw === undefined || raw === "") return DEFAULT_MS;
180+
const seconds = Number(raw);
181+
if (seconds === 0) return 0; // explicit opt-out
182+
if (!Number.isFinite(seconds) || seconds < 0) {
183+
process.stderr.write(`[check] ignoring invalid idle-timeout "${raw}", using ${DEFAULT_MS / 1000}s\n`);
184+
return DEFAULT_MS;
185+
}
186+
return seconds * 1000;
187+
})();
188+
153189
// ── command runner ─────────────────────────────────────────────────────────
154190
const RUNNING = new Set();
155-
function capture(cmd, cwd) {
191+
192+
// Best-effort kill of a child's whole process tree (sh → pnpm → vitest →
193+
// fork workers). Killing only the direct child orphans the tree — exactly the
194+
// zombie workers a deadlock leaves behind. Children are collected via pgrep
195+
// and killed leaves-first.
196+
function killTree(child, signal = "SIGTERM") {
197+
const pids = [];
198+
const collect = (pid) => {
199+
pids.push(pid);
200+
let out = "";
201+
try {
202+
out = execSync(`pgrep -P ${pid}`, { stdio: ["ignore", "pipe", "ignore"] })
203+
.toString()
204+
.trim();
205+
} catch {
206+
/* no children */
207+
}
208+
if (out) for (const p of out.split("\n")) collect(Number(p));
209+
};
210+
collect(child.pid);
211+
for (const pid of pids.reverse()) {
212+
try {
213+
process.kill(pid, signal);
214+
} catch {
215+
/* already gone */
216+
}
217+
}
218+
}
219+
220+
// idleTimeoutMs > 0 arms the no-output watchdog for this child; 0 (the default)
221+
// runs it unwatched. Only callers that KNOW the child streams progress (test
222+
// steps) should pass a timeout — see runGroup.
223+
function capture(cmd, cwd, idleTimeoutMs = 0) {
156224
return new Promise((resolve) => {
157225
const child = spawn(cmd, { cwd, shell: true });
158226
RUNNING.add(child);
159227
let out = "";
228+
let idleTimer = null;
229+
let killTimer = null;
230+
let watchdogHit = false;
231+
const armWatchdog = () => {
232+
if (!idleTimeoutMs) return;
233+
clearTimeout(idleTimer);
234+
idleTimer = setTimeout(() => {
235+
watchdogHit = true;
236+
killTree(child);
237+
killTimer = setTimeout(() => killTree(child, "SIGKILL"), 5000);
238+
killTimer.unref();
239+
}, idleTimeoutMs);
240+
};
160241
const onData = (d) => {
161242
out += d;
243+
armWatchdog();
162244
if (VERBOSE) process.stdout.write(d);
163245
};
246+
armWatchdog();
164247
child.stdout.on("data", onData);
165248
child.stderr.on("data", onData);
166249
const done = (code, extra) => {
250+
clearTimeout(idleTimer);
251+
clearTimeout(killTimer);
167252
RUNNING.delete(child);
253+
if (watchdogHit) {
254+
const note =
255+
`[watchdog] step produced no output for ${Math.round(idleTimeoutMs / 1000)}s — ` +
256+
"process tree killed as deadlocked. This is a hang (workers idle at 0% CPU), " +
257+
`not a slow run. Re-run the step directly to debug: \`${cmd}\``;
258+
return resolve({ code: 1, out: `${out}\n${note}` });
259+
}
168260
resolve({ code, out: extra ? `${out}\n${extra}` : out });
169261
};
170262
child.on("close", (code) => done(code ?? 1));
@@ -174,13 +266,36 @@ function capture(cmd, cwd) {
174266
function killAll() {
175267
for (const child of RUNNING) {
176268
try {
177-
child.kill("SIGTERM");
269+
killTree(child);
178270
} catch {
179271
/* already gone */
180272
}
181273
}
182274
}
183275

276+
// A child killed by a signal surfaces through the package manager as a
277+
// "Command failed with exit code 143/137" line (SIGTERM/SIGKILL), NOT as a test
278+
// assertion failure — and the outer shell then reports its own generic exit 1,
279+
// so `code` alone never reveals it. Surface the signal so the reason isn't
280+
// mistaken for a real failure: the usual cause is resource pressure (parallel
281+
// checks/builds swapping the machine) or an external kill.
282+
function signalExitHint(out) {
283+
const clean = stripAnsi(out);
284+
// The watchdog also kills via SIGTERM, so pnpm's "exit code 143" ends up in
285+
// the output — but that path already carries its own [watchdog] note with the
286+
// correct (deadlock) diagnosis. Don't stack a contradictory "external kill"
287+
// hint on top of it.
288+
if (/\[watchdog\]/.test(clean)) return null;
289+
const m = clean.match(/Command failed with exit code (137|143)\b/);
290+
if (!m) return null;
291+
const sig = m[1] === "143" ? "SIGTERM" : "SIGKILL";
292+
return (
293+
`[check] step ended via ${sig} (exit ${m[1]}) — the process was killed, not an assertion failure. ` +
294+
"Usual cause: resource pressure (parallel checks/builds swapping) or an external kill. " +
295+
"Re-run this project's check alone to confirm."
296+
);
297+
}
298+
184299
// ── live multi-line status (one line per running project) ────────────────────
185300
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
186301
let liveCount = 0;
@@ -328,7 +443,10 @@ async function runGroup(group, states, results, abort) {
328443
st.current = step.label;
329444
st.stepStart = Date.now();
330445
if (!TTY) process.stdout.write(` ${C.dim("→")} ${shortRel(rel)} · ${step.label}\n`);
331-
const { code, out } = await capture(step.cmd, step.cwd);
446+
// Watchdog only on test steps (see IDLE_TIMEOUT_MS): a test runner streams
447+
// output continuously, so prolonged silence == deadlocked workers. Other
448+
// steps buffer their output and must run unwatched.
449+
const { code, out } = await capture(step.cmd, step.cwd, step.kind === "test" ? IDLE_TIMEOUT_MS : 0);
332450
const dur = Date.now() - st.stepStart;
333451
const r = { dur, kind: step.kind, label: step.label, project: rel };
334452
if (step.kind === "test") r.tests = parseVitest(out);
@@ -338,7 +456,12 @@ async function runGroup(group, states, results, abort) {
338456
st.failed = step.label;
339457
if (!abort.hit) {
340458
abort.hit = true;
341-
abort.failure = { out, project: rel, step: `${shortRel(rel)} · ${step.label}` };
459+
const hint = signalExitHint(out);
460+
abort.failure = {
461+
out: hint ? `${out}\n${hint}` : out,
462+
project: rel,
463+
step: `${shortRel(rel)} · ${step.label}`,
464+
};
342465
killAll();
343466
}
344467
return;
@@ -383,9 +506,9 @@ async function main() {
383506
if (!TTY) process.stdout.write(` ${C.dim("→")} audit\n`);
384507
else drawLive([`${C.cyan(FRAMES[0])} audit`]);
385508
const audit = await runAudit(auditCmd);
386-
liveCount = 0;
387509
const dur = Date.now() - t;
388510
if (audit.blocking) {
511+
liveCount = 0; // the failure line must survive — nothing may overwrite it
389512
const summary = audit.counts
390513
? `${audit.total} vuln (${renderVulnLine(audit.counts)})`
391514
: "failed";
@@ -396,10 +519,16 @@ async function main() {
396519
started,
397520
);
398521
}
399-
console.log(
400-
`${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}`,
401-
);
522+
if (!TTY) {
523+
process.stdout.write(
524+
` ${C.green("✓")} audit ${audit.counts ? renderVulnLine(audit.counts) : C.dim("0")} ${C.dim(`(${fmtDuration(dur)})`)}\n`,
525+
);
526+
}
527+
// TTY success: NO permanent line — the live status view overwrites the audit
528+
// row (like every other step); the result lands in the report twice: the
529+
// Steps list (entry below) and the Vulnerabilities section.
402530
results.push({ audit, kind: "audit" });
531+
results.push({ dur, kind: "step", label: "audit", project: "." });
403532
}
404533

405534
// Per-project steps — parallel by default, serial with --sequential.

src/module.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import type { LtExtensionsModuleOptions } from './runtime/types';
1111

1212
// Module meta
1313
export const name = '@lenne.tech/nuxt-extensions';
14-
export const version = '1.10.0';
14+
export const version = '1.10.1';
1515
export const configKey = 'ltExtensions';
1616

1717
// Default cookie names — re-exported from auth-state so consumers can read

0 commit comments

Comments
 (0)