Files
bitvid/scripts/run-unit-tests.mjs
T
thePR0M3TH3ANandClaude Opus 4.8 d6ac9429c1 tests(11b): un-quarantine user-blocks + nwc-client + nostr-publish-rejection; quarantine dm-block-filter
Closes the #11b un-quarantine work: the three target files now pass and run in
CI. A full-suite run with a per-file timeout then surfaced a FOURTH, pre-existing
hang that had never been in the map (dm-block-filter) — quarantined with a triage
note rather than left to hang CI.

user-blocks.test.mjs — post-completion HANG. The decrypt-timeout section installs
a never-resolving nip44Decrypt whose background decrypt-retry timer outlives the
run (manager.reset() races it). Added the repo's established bare-assert
`setTimeout(() => process.exit(0), 50)` exit after the final assertion.

nostr-publish-rejection.test.mjs — post-completion HANG (was NOT in the map, so it
had been silently hanging local `npm run test:unit`, which has no per-file timeout
unless UNIT_TEST_TIMEOUT_MS is set). All assertions pass, then the imported app
stack (app.js + subscriptions + userBlocks) keeps the loop alive. Same
`process.exit(0)`-after-cleanup fix. (The dev-mode + NIP-71 spec fixes were
already in the file.)

nwc-client.test.mjs — (1) mock shadow: readToolkitFromScope prefers the bootstrap's
frozen canonical toolkit over window.NostrTools, so per-section mocks never took
effect and real @noble rejected the fake keys. Remove the scope canonical so the
resolver falls through to each mock. (2) spec correction: one section expected a
rejection when the toolkit "lacks nip44", but mergeWithCanonical ALWAYS backfills
the canonical nip44 (even explicit `nip44:null`) — nip44_v2 is never absent. The
wallet now advertises a genuinely-unsupported scheme (nip44_v3) to exercise the
same UNSUPPORTED_ENCRYPTION path, and asserts error.code too. See TEST_INTEGRITY.md.

dm-block-filter.test.mjs — QUARANTINED (pre-existing, node:test). All subtests pass,
then it hangs: manager.loadBlocks() opens a live block-list subscription through
nostrClient's real SimplePool pointed at the test's fake relay URLs, leaking TCP
sockets + reconnect timers that node:test can't drain. Needs a node:test-safe fix
(inject a mock pool so no real socket opens) — a force process.exit in after() is
UNSAFE (verified: process.exitCode isn't set yet, so it masks failures).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-05 10:45:55 -04:00

251 lines
7.9 KiB
JavaScript
Executable File

import { spawn } from "node:child_process";
import { readdir } from "node:fs/promises";
import fs from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
// Ensure NODE_ENV is set to "test" so that nostrToolsBootstrap.js uses the mock toolkit
process.env.NODE_ENV = "test";
// Quarantine: KNOWN-BROKEN unit test files that exist but currently fail or hang.
// They are NOT silently dropped — they are loudly reported on every run so they
// can't rot invisibly (the historical bug: the runner only collected files that
// imported `node:test`, silently excluding ~20 bare-assert files; several were
// failing on main, unnoticed). Each entry MUST cite why and link the triage item.
// Remove an entry as soon as its file is fixed. See todo/TODO_2026-06-20_pre-launch.md todo-11b.
const QUARANTINE = new Map([
["tests/dm-block-filter.test.mjs", "HANG (post-completion) — all subtests PASS (TAP 'ok'), then the process hangs on a lingering handle from the imported app stack (nostrService/userBlocks/nostrClient/relayManager) so node:test never exits. Surfaced 2026-07-05 by running the full suite with UNIT_TEST_TIMEOUT_MS (pre-existing; it was never in this map). Needs a node:test-SAFE fix: close the leaked resource in an after() hook so node exits naturally — a force `process.exit` in after() is UNSAFE here (process.exitCode isn't set yet, verified, so it masks real failures). triage todo-11b"],
]);
const rootDir = path.dirname(fileURLToPath(new URL("../package.json", import.meta.url)));
const testsDir = path.join(rootDir, "tests");
const testFiles = [];
const setupImport = new URL("../tests/test-helpers/setup-localstorage.mjs", import.meta.url);
const args = process.argv.slice(2);
const shardArg = args.find((arg) => arg.startsWith("--shard="));
const shardValue = shardArg ? shardArg.split("=")[1] : process.env.UNIT_TEST_SHARD;
const filters = args.filter((arg) => !arg.startsWith("--"));
const timeoutMs = process.env.UNIT_TEST_TIMEOUT_MS
? Number(process.env.UNIT_TEST_TIMEOUT_MS)
: null;
if (timeoutMs !== null && (!Number.isFinite(timeoutMs) || timeoutMs <= 0)) {
throw new Error(
"UNIT_TEST_TIMEOUT_MS must be a positive number representing milliseconds.",
);
}
async function collectTests(dir) {
const entries = await readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name.startsWith(".")) {
continue;
}
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "visual") {
continue;
}
await collectTests(fullPath);
continue;
}
const isTestFile =
entry.isFile() &&
(entry.name.endsWith(".test.mjs") || entry.name.endsWith(".test.js"));
if (isTestFile) {
// Collect EVERY test file. Both styles run fine through the spawn below:
// node:test files self-execute and exit non-zero on failure; bare top-level
// `assert` files throw on failure (also non-zero). Do not re-introduce a
// content-based include filter — that is what silently dropped files before.
testFiles.push(fullPath);
}
}
}
function parseShard(value) {
if (!value) {
return null;
}
const match = /^(\d+)\/(\d+)$/.exec(value.trim());
if (!match) {
throw new Error("Shard format must be <index>/<total>, e.g. 1/3.");
}
const index = Number(match[1]);
const total = Number(match[2]);
if (!Number.isInteger(index) || !Number.isInteger(total) || total < 1) {
throw new Error("Shard values must be positive integers.");
}
if (index < 1 || index > total) {
throw new Error("Shard index must be between 1 and total (inclusive).");
}
return { index, total };
}
async function run() {
await collectTests(testsDir);
testFiles.sort((a, b) => a.localeCompare(b));
if (testFiles.length === 0) {
console.log("No unit tests found.");
return;
}
// Partition off the quarantined (known-broken) files and report them loudly so
// they are never silently skipped. A stale entry (file deleted/renamed) is also
// surfaced so the list stays honest.
const quarantinedPresent = [];
let selectedTests = testFiles.filter((file) => {
const rel = path.relative(rootDir, file).split(path.sep).join("/");
if (QUARANTINE.has(rel)) {
quarantinedPresent.push(rel);
return false;
}
return true;
});
if (quarantinedPresent.length > 0) {
console.warn(
`\n⚠ ${quarantinedPresent.length} quarantined (known-broken) test file(s) SKIPPED — fix and remove from QUARANTINE (todo todo-11b):`,
);
for (const rel of quarantinedPresent.sort()) {
console.warn(` • ${rel} — ${QUARANTINE.get(rel)}`);
}
}
const staleQuarantine = [...QUARANTINE.keys()].filter(
(rel) => !quarantinedPresent.includes(rel),
);
if (staleQuarantine.length > 0) {
console.warn(
`\n⚠ QUARANTINE lists ${staleQuarantine.length} file(s) that were not found — remove the stale entr${
staleQuarantine.length === 1 ? "y" : "ies"
}:`,
);
for (const rel of staleQuarantine.sort()) {
console.warn(` • ${rel}`);
}
}
if (filters.length > 0) {
selectedTests = selectedTests.filter((file) =>
filters.some((f) => file.includes(f)),
);
if (selectedTests.length === 0) {
console.log(`No tests matched filters: ${filters.join(", ")}`);
return;
}
console.log(`Filtered to ${selectedTests.length} test(s).`);
}
const shard = parseShard(shardValue);
if (shard) {
selectedTests = selectedTests.filter(
(_, index) => index % shard.total === shard.index - 1,
);
console.log(
`Using shard ${shard.index}/${shard.total} (${selectedTests.length} of ${testFiles.length} files).`,
);
}
if (selectedTests.length === 0) {
console.log("No unit tests found for this shard/filter.");
return;
}
const logFile = path.join(rootDir, "artifacts", "test_unit.log");
if (!fs.existsSync(path.dirname(logFile))) {
fs.mkdirSync(path.dirname(logFile), { recursive: true });
}
const logStream = fs.createWriteStream(logFile);
for (const testFile of selectedTests) {
const relativePath = path.relative(rootDir, testFile);
console.log(`\n→ Running ${relativePath}`);
logStream.write(`\n→ Running ${relativePath}\n`);
await new Promise((resolve, reject) => {
const child = spawn(process.execPath, ["--import", setupImport.href, testFile], {
stdio: ["inherit", "pipe", "pipe"],
});
child.stdout.pipe(process.stdout, { end: false });
child.stdout.pipe(logStream, { end: false });
child.stderr.pipe(process.stderr, { end: false });
child.stderr.pipe(logStream, { end: false });
let finished = false;
let timeoutId = null;
const finalize = (error) => {
if (finished) {
return;
}
finished = true;
if (timeoutId) {
clearTimeout(timeoutId);
}
if (error) {
reject(error);
return;
}
resolve();
};
if (timeoutMs !== null) {
timeoutId = setTimeout(() => {
child.kill("SIGKILL");
finalize(
new Error(`${relativePath} timed out after ${timeoutMs}ms and was aborted.`),
);
}, timeoutMs);
}
child.on("close", (code, signal) => {
if (signal) {
finalize(new Error(`${relativePath} exited with signal ${signal}`));
return;
}
if (code !== 0) {
finalize(new Error(`${relativePath} failed with exit code ${code}`));
return;
}
finalize();
});
child.on("error", (error) => {
finalize(error);
});
});
}
console.log("\n✔ All unit tests passed");
// Force exit to ensure we don't hang on lingering handles/timers
process.exit(0);
}
try {
await run();
} catch (error) {
console.error(`\n✖ ${error.message}`);
process.exit(1);
}