@specific.dev/spectest 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/daemon.ts CHANGED
@@ -65,6 +65,8 @@ import type {
65
65
  BrowserSessionStep,
66
66
  } from "./browser.js";
67
67
  import type {
68
+ ComponentContext,
69
+ ComponentExecOpts,
68
70
  EnvironmentConfig,
69
71
  ExecResult,
70
72
  FakeContext,
@@ -607,6 +609,16 @@ function sanitizeSegment(p: string): string {
607
609
  }
608
610
 
609
611
  function resolveHostPath(service: string, vol: VolumeMount): string {
612
+ if (vol.name) {
613
+ // Named shared volume: one backing dir per name, shared by every
614
+ // service that mounts the same name (storage-api ↔ imgproxy). Rooted
615
+ // in the per-env state tree (or the cache tree when cache-flagged),
616
+ // so teardown/fork semantics match ordinary volumes.
617
+ const root = vol.cache
618
+ ? ["/var/cache/spectest/volumes", "_shared"]
619
+ : [WORKSPACE, ".spectest", "volumes", "_shared"];
620
+ return path.join(...root, sanitizeSegment(vol.name));
621
+ }
610
622
  if (vol.source && vol.source.startsWith("/")) return vol.source;
611
623
  // Cache volumes root OUTSIDE /workspace so the delta-restore teardown
612
624
  // (rm -rf /workspace) keeps them — they hold only content-addressed
@@ -640,6 +652,13 @@ async function ensureVolumes(svc: NamedService): Promise<string[]> {
640
652
  const flags: string[] = [];
641
653
  if (!svc.volumes || svc.volumes.length === 0) return flags;
642
654
  for (const vol of svc.volumes) {
655
+ // Boot services are validated in defineEnvironment; re-check here so
656
+ // runtime `startService` specs get the same contract.
657
+ if (vol.name && vol.source) {
658
+ throw new Error(
659
+ `service "${svc.name}" volume for ${JSON.stringify(vol.target)} sets both \`name\` and \`source\``,
660
+ );
661
+ }
643
662
  const host = resolveHostPath(svc.name, vol);
644
663
  await fs.mkdir(host, { recursive: true });
645
664
  if (vol.source?.startsWith("/") && !host.startsWith("/var/cache/spectest/")) {
@@ -1063,12 +1082,21 @@ async function probeTcp(host: string, port: number): Promise<boolean> {
1063
1082
  });
1064
1083
  }
1065
1084
 
1066
- async function probeHttp(host: string, port: number, urlPath: string): Promise<boolean> {
1085
+ async function probeHttp(
1086
+ host: string,
1087
+ port: number,
1088
+ urlPath: string,
1089
+ headers?: Record<string, string>,
1090
+ expectStatus?: number,
1091
+ ): Promise<boolean> {
1067
1092
  const ctrl = new AbortController();
1068
1093
  const to = setTimeout(() => ctrl.abort(), 5000);
1069
1094
  try {
1070
- const res = await fetch(`http://${host}:${port}${urlPath}`, { signal: ctrl.signal });
1071
- return res.ok;
1095
+ const res = await fetch(`http://${host}:${port}${urlPath}`, {
1096
+ signal: ctrl.signal,
1097
+ headers,
1098
+ });
1099
+ return expectStatus !== undefined ? res.status === expectStatus : res.ok;
1072
1100
  } catch {
1073
1101
  return false;
1074
1102
  } finally {
@@ -1098,7 +1126,13 @@ async function waitForReady(svc: NamedService): Promise<void> {
1098
1126
  if (check.type === "tcp") {
1099
1127
  ok = await probeTcp(svc.name, check.port);
1100
1128
  } else if (check.type === "http") {
1101
- ok = await probeHttp(svc.name, check.port, check.path ?? "/");
1129
+ ok = await probeHttp(
1130
+ svc.name,
1131
+ check.port,
1132
+ check.path ?? "/",
1133
+ check.headers,
1134
+ check.expectStatus,
1135
+ );
1102
1136
  } else {
1103
1137
  ok = await probeExec(svc.name, check.command);
1104
1138
  }
@@ -2510,7 +2544,7 @@ async function bootstrap(): Promise<BootstrapTimings> {
2510
2544
  if (svc.setup) {
2511
2545
  progressService(svc.name, { status: "probing", detail: "running setup" });
2512
2546
  const helpers = await ensureHelpers(svc.name, svc);
2513
- await svc.setup({ name: svc.name, helpers });
2547
+ await svc.setup({ name: svc.name, helpers, ...componentContext() });
2514
2548
  }
2515
2549
  progressService(svc.name, { status: "ready", detail: undefined });
2516
2550
  const ti = timings.get(svc.name);
@@ -2618,71 +2652,168 @@ interface RunResult {
2618
2652
  /** asciicast sessions for each `ctx.terminal(...)` call. */
2619
2653
  terminalSessions: TerminalSessionRecord[];
2620
2654
  /**
2621
- * `docker logs` per service, captured only when the test failed (empty
2622
- * on a pass). Lets the failure post-mortem in the web UI show what each
2623
- * container printed without the author having to add `ctx.exec` log
2624
- * grabs by hand.
2655
+ * Per-service log *delta* — the lines each service emitted during THIS
2656
+ * case, beyond what ancestor cases already captured. Captured on every
2657
+ * case (pass or fail). Because a child fork restores the parent's log
2658
+ * markers (module memory travels with the snapshot), these deltas tile
2659
+ * along the ancestor chain into the full cumulative log for any branch —
2660
+ * reconstructed on the dashboard; the CLI failure post-mortem shows the
2661
+ * failing case's own delta. See {captureServiceLogDeltas}.
2625
2662
  */
2626
- serviceLogs: ServiceLogCapture[];
2663
+ serviceLogDeltas: ServiceLogDelta[];
2627
2664
  error?: { message: string; stack?: string };
2628
2665
  }
2629
2666
 
2630
- /** Captured container logs for one service. */
2631
- interface ServiceLogCapture {
2667
+ // ────────────────────────────────────────────────────────────────────────
2668
+ // Cumulative service logs (per-case deltas → S3, reconstructed on the web)
2669
+ // ────────────────────────────────────────────────────────────────────────
2670
+ //
2671
+ // A child test runs in a fork restored from its parent's memory+filesystem
2672
+ // snapshot, so `docker logs <svc>` in the child already contains the
2673
+ // parent's entire history plus the child's own output — logs are
2674
+ // inherently cumulative along each branch. Rather than store the (growing,
2675
+ // redundant) full log at every case, each case stores only its DELTA (the
2676
+ // lines it added), and the dashboard reconstructs a branch's full log by
2677
+ // concatenating deltas along the ancestor chain.
2678
+ //
2679
+ // The load-bearing trick: `LOG_MARKERS` lives in module memory, so it
2680
+ // FORKS with the snapshot (same mechanism as `TEST_DATA` / `RUNTIME_SERVICES`).
2681
+ // `runOne` advances the markers BEFORE `/run` returns, and the control
2682
+ // plane snapshots the fork AFTER `/run` returns — so a child (forked or
2683
+ // handed off) restores the parent's final markers and its delta tiles on
2684
+ // with no gap or cross-branch duplication.
2685
+
2686
+ /**
2687
+ * Per-(service, stream) count of newline-terminated log lines already
2688
+ * captured by an ancestor case. Key is `"<service><stream>"`. The
2689
+ * next case on this branch captures only `lines[marker..]`. Module-scope
2690
+ * so it travels with the snapshot into every fork.
2691
+ */
2692
+ const LOG_MARKERS = new Map<string, { lines: number }>();
2693
+
2694
+ /** Per-(service, stream) delta byte cap. Over this we keep head+tail and
2695
+ * elide the middle — the head preserves the continuation from the parent,
2696
+ * the tail preserves the newest output — while still advancing the marker
2697
+ * to the true line count so the chain stays aligned. */
2698
+ const LOG_DELTA_MAX_BYTES = 2 * 1024 * 1024;
2699
+
2700
+ /** One service's per-case log delta (new output since the parent case). */
2701
+ interface ServiceLogDelta {
2632
2702
  service: string;
2633
- /** Trailing slice of the container's stdout (RFC3339-timestamped). */
2634
2703
  stdout: string;
2635
2704
  stdoutTruncated: boolean;
2636
- /** Trailing slice of the container's stderr. */
2705
+ /** The container's stdout shrank below our marker (it was recreated /
2706
+ * rotated), so this delta is the full current log, not a continuation. */
2707
+ stdoutReset: boolean;
2637
2708
  stderr: string;
2638
2709
  stderrTruncated: boolean;
2710
+ stderrReset: boolean;
2639
2711
  }
2640
2712
 
2641
- /** How many trailing log lines to grab per service on failure. */
2642
- const SERVICE_LOG_TAIL_LINES = 500;
2643
- /** Per-stream byte cap after the line tail (keeps the most recent bytes). */
2644
- const SERVICE_LOG_MAX_BYTES = 256 * 1024;
2645
-
2646
- /** Keep the trailing `max` bytes of `s` — the opposite of
2647
- * `truncateUtf8`'s head-keep, because the most recent output is what
2648
- * explains a failure. */
2649
- function tailBytes(s: string, max: number): { value: string; truncated: boolean } {
2713
+ /** Keep the head and tail of `s`, eliding the middle when it exceeds
2714
+ * `max` (string length, a byte proxy as elsewhere here). Head+tail so an
2715
+ * over-long delta keeps both the parent-continuation and the newest
2716
+ * output. */
2717
+ function capMiddle(s: string, max: number): { value: string; truncated: boolean } {
2650
2718
  if (s.length <= max) return { value: s, truncated: false };
2651
- return { value: s.slice(s.length - max), truncated: true };
2719
+ const half = Math.floor(max / 2);
2720
+ const elided = s.length - 2 * half;
2721
+ return {
2722
+ value: `${s.slice(0, half)}\n… [${elided} bytes elided] …\n${s.slice(s.length - half)}`,
2723
+ truncated: true,
2724
+ };
2725
+ }
2726
+
2727
+ /**
2728
+ * Compute one stream's delta beyond `marker` complete lines.
2729
+ * - Counts only newline-terminated lines; a trailing partial line (no
2730
+ * `\n` yet) is held back from both the delta and the count, so a line
2731
+ * completed by a later capture isn't split across the fork boundary.
2732
+ * - Reset guard: if the stream shrank below `marker` (container recreated
2733
+ * or rotated) the whole current log is re-emitted and `reset` is set.
2734
+ */
2735
+ function streamDelta(
2736
+ full: string,
2737
+ marker: number,
2738
+ ): { delta: string; total: number; reset: boolean; truncated: boolean } {
2739
+ const lastNl = full.lastIndexOf("\n");
2740
+ const complete = lastNl < 0 ? "" : full.slice(0, lastNl + 1);
2741
+ let total = 0;
2742
+ for (let i = 0; i < complete.length; i++) {
2743
+ if (complete.charCodeAt(i) === 10) total++;
2744
+ }
2745
+ let reset = false;
2746
+ let startLine = marker;
2747
+ if (total < marker) {
2748
+ reset = true;
2749
+ startLine = 0;
2750
+ }
2751
+ let delta: string;
2752
+ if (startLine <= 0) {
2753
+ delta = complete;
2754
+ } else if (startLine >= total) {
2755
+ delta = "";
2756
+ } else {
2757
+ // Byte offset just past the `startLine`-th newline.
2758
+ let seen = 0;
2759
+ let off = 0;
2760
+ for (let i = 0; i < complete.length; i++) {
2761
+ if (complete.charCodeAt(i) === 10 && ++seen === startLine) {
2762
+ off = i + 1;
2763
+ break;
2764
+ }
2765
+ }
2766
+ delta = complete.slice(off);
2767
+ }
2768
+ const capped = capMiddle(delta, LOG_DELTA_MAX_BYTES);
2769
+ return { delta: capped.value, total, reset, truncated: capped.truncated };
2652
2770
  }
2653
2771
 
2654
2772
  /**
2655
- * Capture `docker logs` for every service in the loaded environment.
2656
- * Called when a test case fails so the web UI can show each container's
2657
- * recent output. Best-effort and bounded: the last
2658
- * {SERVICE_LOG_TAIL_LINES} lines, trimmed to the trailing
2659
- * {SERVICE_LOG_MAX_BYTES} bytes per stream. A `docker logs` failure for
2660
- * one service surfaces as that service's `stderr` rather than aborting
2661
- * the whole capture, so a crashed/removed container is still visible.
2773
+ * Capture the per-service log delta for the current case and advance the
2774
+ * markers. Runs on EVERY case (pass or fail). Enumerates boot services
2775
+ * (`namedServices`) plus any runtime services this fork started
2776
+ * (`RUNTIME_SERVICES`), deduped by name. A `docker logs` failure surfaces
2777
+ * as the service's `stderr` WITHOUT advancing the markers — a transient
2778
+ * failure must never desync the chain.
2662
2779
  */
2663
- async function captureServiceLogs(): Promise<ServiceLogCapture[]> {
2780
+ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
2664
2781
  const l = loaded;
2665
2782
  if (!l) return [];
2666
- // Boot services plus any runtime services this fork started — both are
2667
- // real containers a failure post-mortem wants to see. Dedup by name.
2668
2783
  const byName = new Map<string, NamedService>();
2669
2784
  for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
2670
2785
  for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
2671
2786
  const services = [...byName.values()];
2672
2787
  return Promise.all(
2673
- services.map(async (svc): Promise<ServiceLogCapture> => {
2674
- const r = await docker(
2675
- ["logs", "--tail", String(SERVICE_LOG_TAIL_LINES), "--timestamps", svc.name],
2676
- 30_000,
2677
- );
2678
- const stdout = tailBytes(r.stdout, SERVICE_LOG_MAX_BYTES);
2679
- const stderr = tailBytes(r.stderr, SERVICE_LOG_MAX_BYTES);
2788
+ services.map(async (svc): Promise<ServiceLogDelta> => {
2789
+ const r = await docker(["logs", "--timestamps", svc.name], 30_000);
2790
+ if (r.code !== 0) {
2791
+ // Container gone/renamed — surface the CLI error, leave markers put.
2792
+ const err = capMiddle(r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
2793
+ return {
2794
+ service: svc.name,
2795
+ stdout: "",
2796
+ stdoutTruncated: false,
2797
+ stdoutReset: false,
2798
+ stderr: err.value,
2799
+ stderrTruncated: err.truncated,
2800
+ stderrReset: false,
2801
+ };
2802
+ }
2803
+ const outKey = `${svc.name}stdout`;
2804
+ const errKey = `${svc.name}stderr`;
2805
+ const out = streamDelta(r.stdout, LOG_MARKERS.get(outKey)?.lines ?? 0);
2806
+ const err = streamDelta(r.stderr, LOG_MARKERS.get(errKey)?.lines ?? 0);
2807
+ LOG_MARKERS.set(outKey, { lines: out.total });
2808
+ LOG_MARKERS.set(errKey, { lines: err.total });
2680
2809
  return {
2681
2810
  service: svc.name,
2682
- stdout: stdout.value,
2683
- stdoutTruncated: stdout.truncated,
2684
- stderr: stderr.value,
2685
- stderrTruncated: stderr.truncated,
2811
+ stdout: out.delta,
2812
+ stdoutTruncated: out.truncated,
2813
+ stdoutReset: out.reset,
2814
+ stderr: err.delta,
2815
+ stderrTruncated: err.truncated,
2816
+ stderrReset: err.reset,
2686
2817
  };
2687
2818
  }),
2688
2819
  );
@@ -2881,6 +3012,64 @@ async function openInstrumentedTerminal(
2881
3012
  // already populated. Carries arbitrary JS values — no JSON round-trip.
2882
3013
  const TEST_DATA = new Map<string, unknown>();
2883
3014
 
3015
+ // ────────────────────────────────────────────────────────────────────────
3016
+ // Component context — the exec / project-file surface handed to service
3017
+ // `setup` hooks and `helpers` factories (`ComponentContext` in index.ts),
3018
+ // so components don't hand-roll child_process docker execs or hard-code
3019
+ // control-plane paths like /workspace.
3020
+ // ────────────────────────────────────────────────────────────────────────
3021
+
3022
+ const COMPONENT_EXEC_DEFAULT_TIMEOUT_MS = 120_000;
3023
+
3024
+ /** Raw `docker exec` with optional piped stdin. Array command = exact
3025
+ * argv (no shell); string = `sh -lc`. Non-zero exit is reported via
3026
+ * `exitCode`, never thrown. Unlike `execInService` this records nothing —
3027
+ * setup/helpers-factory time has no test timeline. */
3028
+ function componentExec(
3029
+ service: string,
3030
+ command: string | string[],
3031
+ opts?: ComponentExecOpts,
3032
+ ): Promise<ExecResult> {
3033
+ const argv = ["exec", "-i"];
3034
+ if (opts?.cwd) argv.push("-w", opts.cwd);
3035
+ argv.push(service);
3036
+ if (typeof command === "string") argv.push("sh", "-lc", command);
3037
+ else argv.push(...command);
3038
+ const timeoutMs = opts?.timeoutMs ?? COMPONENT_EXEC_DEFAULT_TIMEOUT_MS;
3039
+ return new Promise((resolve, reject) => {
3040
+ const child = spawn("docker", argv, {
3041
+ stdio: [opts?.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
3042
+ });
3043
+ const out: Buffer[] = [];
3044
+ const err: Buffer[] = [];
3045
+ child.stdout!.on("data", (c: Buffer) => out.push(c));
3046
+ child.stderr!.on("data", (c: Buffer) => err.push(c));
3047
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
3048
+ child.on("error", (e) => {
3049
+ clearTimeout(timer);
3050
+ reject(e);
3051
+ });
3052
+ child.on("close", (code) => {
3053
+ clearTimeout(timer);
3054
+ resolve({
3055
+ stdout: Buffer.concat(out).toString("utf8"),
3056
+ stderr: Buffer.concat(err).toString("utf8"),
3057
+ exitCode: code ?? -1,
3058
+ });
3059
+ });
3060
+ if (opts?.stdin !== undefined) child.stdin!.end(opts.stdin);
3061
+ });
3062
+ }
3063
+
3064
+ function componentContext(): ComponentContext {
3065
+ return {
3066
+ projectRoot: WORKSPACE,
3067
+ readProjectFile: (p: string) =>
3068
+ fs.readFile(path.isAbsolute(p) ? p : path.join(WORKSPACE, p), "utf8"),
3069
+ exec: componentExec,
3070
+ };
3071
+ }
3072
+
2884
3073
  // Cached helper namespaces produced by `ServiceDefinition.helpers`
2885
3074
  // factories. Built lazily on first access and reused for the daemon's
2886
3075
  // lifetime — Bun.SQL pools and similar resources are happy to live a
@@ -2900,7 +3089,7 @@ async function ensureHelpers(
2900
3089
  ): Promise<Record<string, unknown>> {
2901
3090
  if (!def.helpers) return {};
2902
3091
  if (!HELPERS_CACHE.has(name)) {
2903
- HELPERS_CACHE.set(name, await def.helpers({ name }));
3092
+ HELPERS_CACHE.set(name, await def.helpers({ name, ...componentContext() }));
2904
3093
  }
2905
3094
  return HELPERS_CACHE.get(name)!;
2906
3095
  }
@@ -3460,7 +3649,8 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3460
3649
  // `finally` — leaked Chromium subprocesses would survive the snapshot
3461
3650
  // and chew memory across forks. Each Browser also gets a session
3462
3651
  // recorder; the records flow back to the control plane as part of
3463
- // RunResult.browserSessions and are persisted to SQLite.
3652
+ // RunResult.browserSessions and are archived to S3 as the case's
3653
+ // replay bundle.
3464
3654
  // Tracks both Browser and Mobile handles for cleanup — both expose an
3465
3655
  // async close() that does the final rrweb drain before teardown.
3466
3656
  const openBrowsers: Array<{ close(): Promise<void> }> = [];
@@ -3564,17 +3754,20 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3564
3754
  for (const s of sessions) s.markClosed();
3565
3755
  }
3566
3756
  const durationMs = Date.now() - start;
3567
- // On failure, grab each service's recent container logs for the
3568
- // post-mortem. Captured after the duration clock stops so the
3569
- // log-fetch round trips aren't billed to the test.
3570
- let serviceLogs: ServiceLogCapture[] = [];
3571
- if (outcome.status === "failed") {
3572
- try {
3573
- serviceLogs = await captureServiceLogs();
3574
- } catch (err) {
3575
- // eslint-disable-next-line no-console
3576
- console.warn("[service-logs] capture failed:", err);
3577
- }
3757
+ // Capture each service's log delta (the lines THIS case added beyond its
3758
+ // ancestors) on every case, pass or fail. Runs after the duration clock
3759
+ // stops so the `docker logs` round trips aren't billed to the test.
3760
+ // Advancing the markers here — before /run returns and the control plane
3761
+ // snapshots the fork — is what lets children tile their own deltas on
3762
+ // seamlessly. Shipped to S3: the dashboard reconstructs the full
3763
+ // cumulative log for a branch, the CLI failure post-mortem shows the
3764
+ // failing case's own delta.
3765
+ let serviceLogDeltas: ServiceLogDelta[] = [];
3766
+ try {
3767
+ serviceLogDeltas = await captureServiceLogDeltas();
3768
+ } catch (err) {
3769
+ // eslint-disable-next-line no-console
3770
+ console.warn("[service-logs] delta capture failed:", err);
3578
3771
  }
3579
3772
  const events = stopRecording();
3580
3773
  // Drop sessions whose linking event didn't survive — an exec/terminal
@@ -3593,7 +3786,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3593
3786
  events,
3594
3787
  browserSessions: sessions.map((s) => s.record),
3595
3788
  terminalSessions: terminalSessions.filter((s) => referencedSessions.has(s.sessionId)),
3596
- serviceLogs,
3789
+ serviceLogDeltas,
3597
3790
  error: outcome.error,
3598
3791
  };
3599
3792
  }
@@ -4080,6 +4273,24 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
4080
4273
  return;
4081
4274
  }
4082
4275
 
4276
+ if (method === "POST" && url === "/capture-log-baseline") {
4277
+ // Snapshot each service's log output produced during env bring-up
4278
+ // (container startup + project setup), advancing the log markers so the
4279
+ // subsequent per-test deltas start AFTER setup. The control plane calls
4280
+ // this once, on the main env, before the first test runs — see
4281
+ // tests.rs::capture_log_baseline. Best-effort: on failure the setup lines
4282
+ // simply fold into the first test's delta as before.
4283
+ let serviceLogDeltas: ServiceLogDelta[] = [];
4284
+ try {
4285
+ serviceLogDeltas = await captureServiceLogDeltas();
4286
+ } catch (err) {
4287
+ // eslint-disable-next-line no-console
4288
+ console.warn("[service-logs] baseline capture failed:", err);
4289
+ }
4290
+ jsonResponse(res, 200, { serviceLogDeltas });
4291
+ return;
4292
+ }
4293
+
4083
4294
  if (method === "POST" && url === "/run") {
4084
4295
  if (state.inFlightTest) {
4085
4296
  jsonResponse(res, 409, { error: "another test is already running" });