Files
civitai__civitai/scripts/test-unit-run.mjs
T
Zachary Lowden 1891ce76aa fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon (#4181)
* fix(dev-server): the daemon never read DEV_DAEMON_PORT, so setting it broke the CLI instead of moving the daemon

The variable was read where the client decides what to CONNECT to and ignored
where the daemon decides what to LISTEN on. `DEV_DAEMON_PORT=9555 cli.mjs status`
therefore pointed the CLI at :9555, spawned a daemon that bound :9444, and could
not reach it — the daemon has always accepted `--port`, the spawn simply passed
no arguments and the daemon read no environment.

Four files each decided the port for themselves and two of them were wrong:
cli.mjs and scripts/test-unit-run.mjs read the variable, console.mjs hardcoded
9444, and daemon.mjs saw only argv. That is not a bug any one of them contains —
it is a bug in the set, so the number now lives in exactly one module and all
four resolve it there. A daemon a client spawns inherits that client's
environment, so both ends read the same variable through the same function; an
explicit `--port` still wins for a daemon started by hand.

resolveDaemonPort also refuses a value that is not a port rather than handing
back parseInt's NaN, which used to reach a URL as `http://127.0.0.1:NaN` and
fail a long way from its cause.

Verified by reproducing the reported path, not by reading the code. On
pre-change code with DEV_DAEMON_PORT=19461 the daemon logged `Daemon port: 9444`,
emitted no ready line, and nothing ever listened on 19461. After the change it
reports and binds 19461, and the pid it reports is the child that was spawned —
a port that answers proves a listener, not THIS listener.

Six isolated mutants, each killed by its own named assertion:
  daemon ignores the environment  -> both behavioural cases
  daemon ignores --port           -> the --port precedence case
  resolver stops validating       -> the rejects-a-non-port case
  console.mjs re-hardcodes 9444   -> both halves of the ledger
  test-unit-run.mjs re-hardcodes  -> both halves of the ledger
  the default drifts to 9445      -> the default case
console.mjs is a TUI with no end-to-end case here, which is why the ledger
exists: it fails when the set of files deciding the port grows or shrinks.

Closes ClickUp 868kuaa4e.

* fix(dev-server): the audit round — the test deleted the pid file it was written to protect, and the ledger could not see the set grow

🔴 The blocking one. `POST /shutdown` replies 200 and only THEN schedules
`unlinkSync(pidFile); process.exit(0)` on a 100 ms timer, so the helper that
saved and restored the developer's `daemon.pid` restored it ~100 ms before the
daemon deleted it. `scripts/**/*.test.ts` is in the `unit` project, so every
`pnpm test:unit:run` removed `.claude/skills/dev-server/daemon.pid` — and the
file is only written at daemon START, so it did not come back. That breaks the
recipe SKILL.md itself gives for checking the daemon's interpreter, and makes
`cli.mjs shutdown`'s cleanup a no-op. `shutdown()` now waits for the port to
stop answering before returning.

The ledger scanned a FIXED list of four readers, so it could not detect the set
GROWING — which the PR description claimed it did. A fifth hardcoded 9444
already existed while the test was green: `.claude/hooks/check-writable.mjs`
baked it into the dev-port regex, so `DEV_DAEMON_PORT=9555` silently switched
that nudge off for the daemon, the one long-lived server it most exists for. The
ledger now walks the tree, so a file that does not exist yet is covered, and the
hook reads the port from the module. Verified with a negative control: with
DEV_DAEMON_PORT=9555, `curl :9555` nudges and `curl :7777` stays silent.

The ledger's assertions were also spelled rather than structural.
`includes('daemon-port.mjs')` is satisfied by a COMMENT — and this file's own
prose names the module in several — and `includes('resolveDaemonPort(')` is
satisfied by any call whatever is done with the result, so
`resolveDaemonPort() + 1` passed all of it while console.mjs has no behavioural
test. Both clients now take a whole URL from `resolveDaemonUrl()` and do no
arithmetic on a port; the assertions pin an import line and that call.

Three more the audit found:

- `scripts/test-unit-run.mjs` lost its "never leave a caller unable to run
  tests" guarantee. Resolution moved inside `runQueued` but OUTSIDE its try, and
  `runQueued` is un-awaited, so a malformed DEV_DAEMON_PORT became an unhandled
  rejection and no tests ran. Measured against base: base printed "running
  directly", HEAD died. Restored, with the outer `.catch()` as the general net.
- `--port` still open-coded `parseInt`, so the HIGHER-precedence input was the
  unvalidated one — `--port abc` gave ERR_SOCKET_BAD_PORT, which via cli.mjs is
  invisible (detached, stdio ignored) and reads only as "Failed to start
  daemon". Both inputs now go through `parsePort`, which names the input it is
  complaining about.
- Resolving at module load made a bad DEV_DAEMON_PORT throw on merely IMPORTING
  daemon.mjs, which would have broken collection of the port-reservation suite.
  Resolution moved into `parseArgs`.

Nits: SKILL.md said `cli.mjs:69`, the line is 70 — my own earlier check of that
number had gone stale under a later edit. Out-of-range is now a different
message from unparseable ('0' is a number, just not a port). The 9444 scan uses
a word boundary so 19444 is not a false positive.

Seven mutants, each applied alone, tree restored and cmp-verified between:
  shutdown() stops waiting            -> the pid-file case
  a NEW file hardcodes 9444           -> the ledger (the growth case)
  the hook re-hardcodes 9444          -> the ledger
  console.mjs does resolvePort() + 1  -> the client-address case
  the targeted fallback is removed    -> the degrade-to-direct case
  --port back to parseInt             -> the argv validation case
  port resolved at module load        -> the import-safety case

🔴 One of them SURVIVED the first run and the fix is the point: the fallback
case asserted the generic phrase "running directly", which BOTH guards on that
path print, so deleting the targeted guard left the suite green — the mutant
died to the other guard. The assertion now names the specific message.

scripts/__tests__ 324 passed. typecheck 0 errors. Hook selftest green.

* fix(dev-server): round 3 — the last round's hook fix un-guarded the default port, and its catch would have double-run the suite

Both regressions were introduced by the previous commit, not pre-existing, and a
delta re-audit of 7dd65a9d62..85cb922567 found them.

🔴-in-effect: `DEV_DAEMON_PORT` REPLACED the guarded port instead of adding to
it, so setting it un-guarded the shared daemon. Reproduced through the hook's
real stdin contract: with the override set, `curl http://localhost:9444/...`
went from ask to allowed, and the hook's own selftest went from `all green` to
`1 FAILURES`. SKILL.md documents the override as standing a daemon BESIDE the
shared one — both are live, so both need guarding. `daemonPortsGuarded()` now
returns the union, and the selftest is green both plain and under the override.

The other one is subtler and worse in effect. The outer `.catch()` I added
wrapped the whole `runQueued` lifecycle, not just queue acquisition — so a
socket dropped mid-poll abandoned a run the daemon had ALREADY ACCEPTED and
started a second, unqueued full suite beside it. That defeats the serialisation
this script exists for, and the file had already decided the opposite for the
status-code form of the same condition (`Lost contact with the test queue` ->
exit 2). It now tracks whether the run was accepted: before acceptance it
degrades to a direct run, after acceptance it exits 2. `err?.message`, because a
non-Error throw printed `(undefined)` and a null throw raised inside the handler
— back to the unhandled rejection the catch exists to prevent.

Also from the re-audit:

- The port scrape in the hook is a dependency on one line's formatting in
  another file and nothing tested it. A vitest case now drives the real
  `unboundedDevRequest`, with a negative control on a port the skill does not
  use. Breaking the scrape regex now fails two tests; it used to fail none.
- `console.mjs` had no behavioural coverage, and every structural assertion was
  walkable by resolving the URL correctly and then drifting it. It now runs for
  real against a stub daemon on an ephemeral port — via `--tail`, since the
  dashboard refuses to start without a TTY and exits before contacting anything.
- `--base-dev-port` was still `parseInt`, which made "argv gets the same
  validation the environment gets" false about half of argv.
- The port range's upper bound was untested: fixtures were 0 and 70000, so
  65535 could drift to 65536 unnoticed. Now pinned ON the boundary.
- SKILL.md's `console.mjs:88` — the previous commit fixed the cli.mjs citation
  and moved console.mjs's line to 89 in the same change. Now 89.
- The module header claimed the scan covers "the whole skill ... anywhere". It
  covers three roots and four extensions; prose and test files are deliberately
  out of scope. Said so instead.

🔴 The ledger caught ME during this round: a comment I wrote explaining the
un-guarding bug spelled the port, and `spells the port in exactly one source
file` went red. That is the guard working on its author — reworded, not
exempted.

Round-3 battery, each mutant alone, tree cmp-verified between:
  outer .catch() deleted            -> the accepted-run case
  accepted-check removed            -> the accepted-run case
  hook override replaces default    -> the union case
  hook scrape regex broken          -> both hook cases
  upper bound 65535 -> 65536        -> the boundary case
  console.mjs drifts the URL        -> the behavioural case + the ledger
  --base-dev-port back to parseInt  -> the base-dev-port case
All killed. One deliberate survivor: a copy OUTSIDE the three scanned roots,
which is the documented scope and is now stated in the header rather than
overclaimed.

330 passed. typecheck 0 errors. Hook selftest green plain and under override.

* style: prettier the added test file — the CI gate checks ADDED files and I never ran it

`ESLint + Prettier (changed files)` went red on the round-3 push for one reason:
`scripts/__tests__/dev-server-daemon-port.test.ts` was not prettier-formatted.
Four lines, all wrapping.

Reproduced the gate's exact scope locally rather than guessing at it — it runs
`prettier --list-different` over ADDED files only, which is why the four `.mjs`
files this branch MODIFIES were never checked. Those four are unformatted, and
the control says that is not mine: at the merge base `bc74ba06ea`, all four are
already unformatted. Reformatting them would rewrite files this change does not
own, which is the breadth rule in CLAUDE.md, so they are left alone.

Local prettier is 2.8.8, the same version CI installs, so the local format is
the one the gate will read.

21 tests still pass.

* fix(dev-server): round-4 tidy — the test that proves the console bug was itself destroying the pid file on its red path

Follow-ups from the round-3 delta re-audit, which found no 🔴 and confirmed
round 3 did not reintroduce a regression. These are the 🟡/🟢 it did find.

The console test was not wrapped in `withPidFilePreserved`, and the exposure is
specifically on the FAILING path: on green the stub answers `/`, so the console
never starts a daemon. Under the mutant the test exists to catch, it cannot
reach the stub, falls through to its own `startDaemon`, and overwrites
`daemon.pid` with a dead pid — the test that proves the bug also damaged the
thing the rest of the file is careful about. Controlled both ways: with the
drift mutant applied the test now goes red AND the pid file's md5 is unchanged.

The port scrape in the hook is anchored on `export const` again. Dropping the
anchor was justified as surviving a reformat, and that reasoning was wrong — a
reformat does not rewrite `export const NAME =`. What it actually bought was
letting the FIRST match anywhere in the file win, comments included: a
`// historical: DEFAULT_DAEMON_PORT = 9999` line above the declaration made the
hook guard 9999 and stop guarding the real port. Controlled: that same line now
yields 9444.

Three comments that no longer described their code. `test-unit-run.mjs` had the
previous round's two lines left verbatim above the block that replaced them, so
the same sentence appeared twice. The hook still said the port "is overridable
via DEV_DAEMON_PORT" — the exact semantics round 3 removed, since the set is now
additive — and its "resolved lazily" note sat above `daemonPortsGuarded`, which
is neither lazy nor cached; the laziness is in `devPorts`.

Numeric line references (`:123`, `:128`) replaced with named ones. Both had
already rotted by three lines. This is the same class as the SKILL.md citation
fixed last round, and a line number in a comment will rot again — a quoted
message will not.

Named the residual hole rather than implying it away: the daemon enqueues INSIDE
the response write, so the slot is taken before the client can observe it. Lose
the response between those points and `accepted` is still false while the run is
queued. That window needs an idempotency key on the enqueue, not a flag here.

Not changed, deliberately. `.claude/**/*.mjs` fails `prettier --check` at the
merge base as well as here, so it is pre-existing and reformatting it would
rewrite files this change does not own. And `scripts/__tests__/*.ts` is
typechecked by nothing — `tsconfig.json`'s `include` omits it — so this PR's
"typecheck 0 errors" says nothing about the new test file's annotations. Both
are stated rather than quietly folded in.

330 passed. Hook selftest green plain and under the override. Test file
prettier-clean.
2026-08-20 01:23:47 -05:00

222 lines
9.8 KiB
JavaScript

#!/usr/bin/env node
/**
* `pnpm run test:unit:run` — runs the unit suite, optionally through the dev-server queue.
*
* Off by default: with no flag set this spawns exactly the vitest command the script used to be,
* so CI and anyone who does not run the daemon see no change at all.
*
* With CIVITAI_TEST_QUEUE set, a full-suite run is routed through the daemon's queue instead, which
* serialises it against every other agent on the machine. Routing rather than refusing is the whole
* point: there is no second command to learn, nothing to wrap around, and an agent that never read
* the guidance still gets queued.
*/
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { dirname, resolve } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const CLI = resolve(repoRoot, '.claude/skills/dev-server/cli.mjs');
// The queue module owns the one rule that decides pass from fail. It is imported dynamically
// rather than at the top of the file because it lives under `.claude/`, which the direct path
// must keep working without: a static import would fail the whole script when the skill is absent.
const QUEUE = resolve(repoRoot, '.claude/skills/dev-server/scripts/test-queue.mjs');
// Same override the CLI and the daemon honour, and from the same module, so no two of the three
// can disagree about where the daemon is. Without it this file could only ever talk to the shared
// daemon, which is why the verdict below had no test: there was no way to stand a fake one up
// beside it. Imported on the queue path only, for the reason given above QUEUE.
const PORT_MODULE = resolve(repoRoot, '.claude/skills/dev-server/scripts/daemon-port.mjs');
let DAEMON = null;
// Whether the queue has taken ownership of this run. Once it has, a later failure must NOT be
// answered by starting a second, unqueued suite — see the note where this is set.
let accepted = false;
const POLL_MS = 2000;
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
export function queueDecision(args, env) {
if (env.CI) return { queue: false, why: 'CI runs the suite directly' };
if (!env.CIVITAI_TEST_QUEUE || /^(0|false|off|no)$/i.test(env.CIVITAI_TEST_QUEUE)) {
return { queue: false, why: 'CIVITAI_TEST_QUEUE is not set' };
}
// A narrow run is cheap and is the fast iteration loop. Queueing it behind a full suite would
// turn a two-second check into a nine-minute wait, and push callers toward batching more work
// into each run — the opposite of what this is for.
if (args.some((a) => TEST_FILE.test(a))) return { queue: false, why: 'run names specific files' };
return { queue: true };
}
function runDirect(args) {
// Resolved from node_modules rather than PATH, so this behaves the same when run directly as it
// does under `pnpm run`, which is the only context that puts .bin on PATH.
const local = resolve(
repoRoot,
'node_modules/.bin',
process.platform === 'win32' ? 'vitest.cmd' : 'vitest'
);
const bin = existsSync(local) ? local : 'vitest';
const child = spawn(bin, ['run', '--project', 'unit*', ...args], {
cwd: repoRoot,
stdio: 'inherit',
shell: process.platform === 'win32',
});
child.on('exit', (code, signal) => process.exit(signal ? 1 : code ?? 1));
child.on('error', (err) => {
console.error(`Failed to start vitest: ${err.message}`);
process.exit(1);
});
}
async function post(path, body) {
const res = await fetch(`${DAEMON}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`daemon returned ${res.status}`);
return res.json();
}
function ensureDaemon() {
return new Promise((done) => {
const child = spawn(process.execPath, [CLI, 'status'], { cwd: repoRoot, stdio: 'ignore' });
child.on('exit', () => done());
child.on('error', () => done());
});
}
/**
* The queue keeps a bounded window of a run's output. Dropping the oldest lines is fine; dropping
* them SILENTLY is not, because a truncated log is indistinguishable from a complete one — that is
* how a clipped log gets quoted as a full-suite pass. Say the number out loud instead.
*/
function warnIfLogsDropped(state) {
if (!state.logsDropped) return;
console.error(
`WARNING: this log is INCOMPLETE — the queue dropped the oldest ${state.logsDropped} of ` +
`${state.logIndex} output lines. Do not read the text above as the whole run.`
);
}
async function runQueued(args) {
// Resolved once, up front: this is the module that decides pass from fail, and a waiter that
// discovers it cannot load that rule at the moment it must apply it has no verdict to give.
const { exitCodeFor } = await import(pathToFileURL(QUEUE).href);
// Resolving the daemon's address can THROW — a malformed DEV_DAEMON_PORT is rejected rather
// than silently becoming NaN. That must not cost the caller their test run: the
// "Test queue unreachable" guarantee below is that an unusable queue degrades to a direct run,
// and an unusable ADDRESS is the queue being unusable. Before this catch existed the throw
// escaped an un-awaited `runQueued` as an unhandled rejection and no tests ran at all.
try {
const { resolveDaemonUrl } = await import(pathToFileURL(PORT_MODULE).href);
DAEMON = resolveDaemonUrl();
} catch (err) {
console.error(`Test queue address unusable (${err.message}); running directly.`);
return runDirect(args);
}
let run;
try {
run = await post('/test-runs', { worktree: repoRoot, args });
} catch {
await ensureDaemon();
try {
run = await post('/test-runs', { worktree: repoRoot, args });
} catch (err) {
// Never leave a caller unable to run tests because the queue is unavailable.
console.error(`Test queue unreachable (${err.message}); running directly.`);
return runDirect(args);
}
}
// From here the daemon has ACCEPTED the run, and that changes what a failure may do. Falling
// back to a direct run now would start a second, unqueued full suite beside one the queue is
// already holding a slot for — which is precisely the serialisation this script exists to
// provide. The "Lost contact with the test queue" branch below already decided this for the
// status-code form of the same condition; the network form gets the same answer.
//
// Not airtight, and the gap is worth naming rather than implying it away: the daemon enqueues
// INSIDE the response write, so the slot is taken before the client can observe it. Lose the
// response between those two points and this flag is still false while the run is queued —
// the one window where a duplicate can still happen. Closing it needs an idempotency key on
// the enqueue, not a flag here.
accepted = true;
if (run.status === 'queued') {
console.error(
run.paused
? `Queued at position ${run.position}. The queue is PAUSED (concurrency 0) — nothing starts until it is raised.`
: `Queued at position ${run.position} of ${run.queueLength} (${run.running}/${run.concurrency} running).`
);
}
let lastLog = -1;
for (;;) {
const res = await fetch(`${DAEMON}/test-runs/${run.id}`);
if (res.status === 404) {
console.error(
`The daemon forgot run ${run.id} — it was most likely restarted. Re-run this command.`
);
process.exit(2);
}
if (!res.ok) {
console.error(`Lost contact with the test queue (${res.status}).`);
process.exit(2);
}
const state = await res.json();
const logs = await fetch(`${DAEMON}/test-runs/${run.id}/logs?since=${lastLog}`).then((r) =>
r.json()
);
for (const entry of logs.logs ?? []) {
console.log(entry.message);
lastLog = entry.index;
}
if (state.status !== 'queued' && state.status !== 'running') {
if (state.status !== 'completed')
console.error(`Run ${state.status}${state.error ? `: ${state.error}` : ''}`);
warnIfLogsDropped(state);
// The verdict comes from the queue's own `exitCodeFor`, never from a second copy of the rule
// here. The copy that used to live on this line read `state.exitCode || 1`, which passes a
// signal-killed run's recorded -1 straight through: `process.exit(-1)` gives the shell 255,
// the exact number `exitCodeFor` exists to avoid, and `[ $? -eq 1 ]` misreads it.
process.exit(exitCodeFor(state));
}
await new Promise((r) => setTimeout(r, POLL_MS));
}
}
if (
import.meta.url === `file://${process.argv[1]}` ||
process.argv[1] === fileURLToPath(import.meta.url)
) {
const args = process.argv.slice(2);
const decision = queueDecision(args, process.env);
if (decision.queue && existsSync(CLI) && existsSync(QUEUE) && existsSync(PORT_MODULE)) {
// Un-awaited at top level, so anything runQueued throws would otherwise be an unhandled
// rejection that kills the process with no tests run.
//
// What it does about it depends on whether the queue took the run. Before acceptance,
// degrading to a direct run keeps the "Test queue unreachable" guarantee. AFTER acceptance
// it would start a
// second, unqueued suite beside the one the queue is holding a slot for, so it exits 2 — the
// same verdict :159 already gives when the poll comes back with a bad status.
//
// `err?.message` rather than `err.message`: a non-Error throw would otherwise print
// `(undefined)`, and a null throw would raise inside the handler and land back at the
// unhandled rejection this exists to prevent.
runQueued(args).catch((err) => {
const detail = err?.message ?? String(err);
if (accepted) {
console.error(`Lost contact with the test queue (${detail}). The run may still be queued.`);
process.exit(2);
}
console.error(`Test queue failed (${detail}); running directly.`);
runDirect(args);
});
} else runDirect(args);
}