mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Merge pull request #917 from MBombeck/test/settle-background-tasks
test: settle detached background work before a test file tears down
This commit is contained in:
8 files changed
+359
-29
No files matched your search
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Background work started inside a test must not outlive it.
|
||||
*
|
||||
* Several paths in this codebase start a promise, return without awaiting it,
|
||||
* and log on the detached handle so the failure is not swallowed. Vitest
|
||||
* replaces `globalThis.console` with a sink that forwards every write to the
|
||||
* main thread as an `onUserConsoleLog` RPC call, awaits the calls in flight
|
||||
* when it tears a test file down, and then REJECTS whatever was started after
|
||||
* that point. A detached write that lands in that window fails the whole run
|
||||
* with `EnvironmentTeardownError: Closing rpc while "onUserConsoleLog" was
|
||||
* pending` — blamed on the file the worker was tearing down, which is not the
|
||||
* test that started the work and need not contain a console call of its own.
|
||||
*
|
||||
* Both suite setups therefore await `settleBackgroundTasks()` after every
|
||||
* test, and that only works while the detached paths keep registering what
|
||||
* they start.
|
||||
*
|
||||
* These are tripwires, not proofs. The sweep below matches a detached
|
||||
* `.catch()` / `.then()` whose handler writes to the console — the shape that
|
||||
* actually produced the failure. A background path that logs some other way,
|
||||
* or that logs from a callee rather than from the handler, is invisible to it.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { walkSourceFiles } from "./helpers/source-files";
|
||||
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
import {
|
||||
pendingBackgroundTaskCount,
|
||||
settleBackgroundTasks,
|
||||
trackBackgroundTask,
|
||||
} from "@/lib/logging/background-tasks";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const SRC = join(ROOT, "src");
|
||||
|
||||
/**
|
||||
* Detached console writers that deliberately stay unregistered.
|
||||
*
|
||||
* `cli/mcp-stdio.ts` is a process entrypoint: its `main().catch(...)` is the
|
||||
* top-level failure handler and there is no test that runs it, so there is
|
||||
* nothing for a registry to hold. `jobs/boss-instance.ts` awaits its handle —
|
||||
* the matcher sees the `console` call in the following lines, not a detached
|
||||
* promise.
|
||||
*/
|
||||
const UNREGISTERED_BY_DESIGN = new Set([
|
||||
"cli/mcp-stdio.ts",
|
||||
"lib/jobs/boss-instance.ts",
|
||||
]);
|
||||
|
||||
/** Every non-test source file under `src/`, minus the generated Prisma client. */
|
||||
function sourceFiles(): string[] {
|
||||
return walkSourceFiles(SRC, { floor: 3000 })
|
||||
.filter((rel) => !rel.startsWith("generated/"))
|
||||
.filter((rel) => !rel.includes("__tests__"))
|
||||
.filter((rel) => !rel.endsWith(".test.ts") && !rel.endsWith(".test.tsx"));
|
||||
}
|
||||
|
||||
/** Lines starting a `.catch()` / `.then()` whose handler writes to the console. */
|
||||
function consoleWritingHandlers(): { file: string; line: number }[] {
|
||||
const found: { file: string; line: number }[] = [];
|
||||
for (const rel of sourceFiles()) {
|
||||
const lines = readFileSync(join(SRC, rel), "utf8").split("\n");
|
||||
lines.forEach((line, index) => {
|
||||
if (!/\.(catch|then)\(/.test(line)) return;
|
||||
const handler = lines.slice(index, index + 8).join("\n");
|
||||
if (!/console\.(log|warn|error|info|debug|trace)\(/.test(handler)) return;
|
||||
found.push({ file: rel, line: index + 1 });
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
describe("background-task settle contract", () => {
|
||||
it("registers an in-flight task and drains it on settle", async () => {
|
||||
let release: () => void = () => {};
|
||||
const task = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
trackBackgroundTask(task);
|
||||
expect(pendingBackgroundTaskCount()).toBe(1);
|
||||
|
||||
let settled = false;
|
||||
const settling = settleBackgroundTasks().then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
// Settle must not resolve ahead of the task it is waiting on — that is the
|
||||
// whole reason the registry exists.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
expect(settled).toBe(false);
|
||||
|
||||
release();
|
||||
await settling;
|
||||
expect(settled).toBe(true);
|
||||
// And nothing is retained once the task is done.
|
||||
expect(pendingBackgroundTaskCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("registers what fireAndForget starts", async () => {
|
||||
let release: () => void = () => {};
|
||||
fireAndForget(
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
{ action: "guard.background.registered" },
|
||||
);
|
||||
expect(pendingBackgroundTaskCount()).toBe(1);
|
||||
|
||||
release();
|
||||
await settleBackgroundTasks();
|
||||
expect(pendingBackgroundTaskCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("also awaits work a settling task starts itself", async () => {
|
||||
let innerDone = false;
|
||||
|
||||
fireAndForget(
|
||||
Promise.resolve().then(() => {
|
||||
fireAndForget(
|
||||
new Promise<void>((resolve) =>
|
||||
setTimeout(() => {
|
||||
innerDone = true;
|
||||
resolve();
|
||||
}, 10),
|
||||
),
|
||||
{ action: "guard.background.cascade.inner" },
|
||||
);
|
||||
}),
|
||||
{ action: "guard.background.cascade.outer" },
|
||||
);
|
||||
|
||||
await settleBackgroundTasks();
|
||||
expect(innerDone).toBe(true);
|
||||
expect(pendingBackgroundTaskCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps every detached console writer registered", () => {
|
||||
const sites = consoleWritingHandlers();
|
||||
|
||||
// An empty sweep must not read as a pass: if the matcher stops finding the
|
||||
// shape it is the matcher that broke, not the tree that got clean. Pinned
|
||||
// below the five sites that exist today, so removing one legitimately does
|
||||
// not trip a guard that is about the sweep being alive, not about freezing
|
||||
// a count.
|
||||
expect(sites.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
const unregistered = sites.filter(({ file, line }) => {
|
||||
if (UNREGISTERED_BY_DESIGN.has(file)) return false;
|
||||
const lines = readFileSync(join(SRC, file), "utf8").split("\n");
|
||||
// The registration wraps the handle, so it opens on one of the few lines
|
||||
// above the `.catch(` / `.then(` the sweep matched.
|
||||
const preamble = lines.slice(Math.max(0, line - 5), line).join("\n");
|
||||
return !/trackBackgroundTask\(/.test(preamble);
|
||||
});
|
||||
|
||||
expect(
|
||||
unregistered.map(({ file, line }) => `src/${file}:${line}`),
|
||||
"a detached promise logs to the console without registering — wrap the " +
|
||||
"handled handle in trackBackgroundTask() so the suite can await it",
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the settle hook wired into both suite setups", () => {
|
||||
for (const setup of [
|
||||
"vitest.setup.ts",
|
||||
join("tests", "integration", "environment-setup.ts"),
|
||||
]) {
|
||||
const source = readFileSync(join(ROOT, setup), "utf8");
|
||||
expect(
|
||||
source.includes("settleBackgroundTasks"),
|
||||
`${setup} no longer awaits background tasks after each test`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user