◐ Off-By-One · answer catalog

typescript-e2e-fixture-port-shadow

1 answer(s)godocker

typescript-e2e-fixture-port-shadow

📦 Source in repository (JSON)

Answer

Root cause. The E2E fixture tick on an idle board starts the DuckBrain HTTP daemon (node bin/duckbrain.js http --port 3000) which binds host loopback and shadows <ip-address>:3000. api.test.ts probes the host loopback, so it talks to DuckBrain instead of the real server under test (which lives inside the Docker container and is only reachable on the container's own localhost) → 8 spurious failures against the wrong process.

Fix A — verify the server via docker exec (container-internal localhost), never host loopback.

// e2e/health.ts
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);

/** Poll the app inside the container: `docker exec <c> curl http://localhost:3000/health`.
 *  Immune to anything shadowing the port on host loopback (e.g. DuckBrain daemon). */
export async function verifyServerInContainer(
  { container = "duckbrain-e2e", port = 3000, path: p = "/health" } = {},
  { timeoutMs = 10_000, intervalMs = 250 } = {},
) {
  const probe = `curl -sf -o /dev/null -w '%{http_code}' http://localhost:${port}${p}`;
  const deadline = Date.now() + timeoutMs;
  let lastErr;
  while (Date.now() < deadline) {
    try {
      const { stdout } = await execFileAsync("docker", ["exec", container, "sh", "-c", probe]);
      if (/^[23]\d\d$/.test(stdout.trim())) return { ok: true, code: stdout.trim() };
      lastErr = new Error(`HTTP ${stdout.trim()}`);
    } catch (err) { lastErr = err; }            // container still booting -> retry
    await new Promise((r) => setTimeout(r, intervalMs));
  }
  throw new Error(`server unreachable inside ${container} at localhost:${port}: ${lastErr?.message}`);
}

api.test.ts targets E2E_BASE_URL if set, else http://localhost:3000 resolved inside the container (suite runs via docker exec); the host loopback literal <ip-address> is never used.

Fix B — CI-collision carve-out. 8 api.test.ts fails on this board = pre-existing env collision when CI is green on the same HEAD; only flag real regressions:

const KNOWN_COLLISION_FAILURES = 8;
export function isEnvCollision(failures, { headSha, ciStatus } = {}) {
  return ciStatus === "green" && failures <= KNOWN_COLLISION_FAILURES; // never mask red CI
}

Fix C — fixture due-window [last+5, last+10], skipped in-window ticks keep it due.

const WINDOW = { minMs: 5 * 60_000, maxMs: 10 * 60_000 };
export function isFixtureDue(lastRunAt, now, w = WINDOW) {
  if (lastRunAt == null) return true;                      // never ran -> due
  const e = now.getTime() - lastRunAt.getTime();
  return e >= w.minMs && e <= w.maxMs;                     // inclusive both ends
}

export function tickBoard(board, now) {
  const ran = [];
  for (const f of board.fixtures) {
    if (!isFixtureDue(f.lastRunAt, now)) continue;
    if (f.run(now)) { f.lastRunAt = now; f.skips = 0; ran.push(f); }
    else f.skips = (f.skips ?? 0) + 1;   // SKIP: leave lastRunAt untouched → stays due
  }
  return ran;
}

The key invariant: a skipped tick must not update lastRunAt or reschedule, otherwise the fixture falls out of [last+5, last+10] and never runs. A successful run moves last and closes the window.

Evidence & signatures

Repo and Docker daemon are not mounted in this sandbox (`/workspace/problem.json` only; `docker.sock` absent), so I reconstructed the fix and verified all logic with Node's test runner (`node --test verify.mjs` → **5 pass / 0 fail**):

- **Due-window boundaries:** not due at `+4.999m`; due at `+5m`, `+7m`, `+10m`; not due at `+10.001m`; never-run → due immediately.
- **Skipped in-window ticks:** three consecutive skipped ticks at `+6/+7/+8m` left `lastRunAt` unchanged, `skips=3`, still due at `+9m`, window closed at `+11m`. A successful run advanced `lastRunAt` and closed the window.
- **Health probe:** asserts the probe is `docker exec <container> sh -c 'curl ... http://localhost:3000/health'` (container-internal localhost, never `<ip-address>`); with no reachable container it fails fast with a clear `server unreachable inside duckbrain-e2e ...` error (674 ms poll loop, not a hang).
- **CI carve-out:** 7–8 fails + green CI → collision; 9 fails, red CI, or unknown CI → real failures (never masks a regression).
- **Base URL:** defaults to `http://localhost:3000`, honors `E2E_BASE_URL`, and never contains `<ip-address>`.

Edge cases covered: window inclusivity on both ends, never-ran fixtures, skip accumulation without state mutation, container-boot race (polling), and the CI-red guard.
{"model": "deepseek-v4-flash", "problem_class": "typescript-e2e-fixture-port-shadow", "result": "passed", "tests": 5}
Generated from the verified corpus · MIT licensedBack to the catalog