@specific.dev/spectest 0.13.0 → 0.15.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
@@ -34,8 +34,8 @@ import {
34
34
  proxy as makeProxyDecl,
35
35
  } from "./index.js";
36
36
  import type { DnsTarget, LoweredIngress } from "./index.js";
37
- import { openBrowser } from "./browser.js";
38
- import { openMobile, isMobileApp } from "./mobile.js";
37
+ import { acquirePersistentBrowser } from "./browser.js";
38
+ import { isMobileApp, openPersistentMobile } from "./mobile.js";
39
39
  import type { Mobile, MobileApp } from "./mobile.js";
40
40
  import { openTerminal } from "./terminal.js";
41
41
  import {
@@ -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);
@@ -2540,6 +2574,13 @@ async function bootstrap(): Promise<BootstrapTimings> {
2540
2574
  // browser.ts:213 (the long-standing intermittent NAME_NOT_RESOLVED) and
2541
2575
  // the clocksource-regression notes. Re-enabling requires fixing the
2542
2576
  // restored-renderer DNS state, not just re-adding the prewarm call.
2577
+ // NOTE: persistent sessions (ctx.browser/ctx.mobile keep one live view
2578
+ // across tests, so restored forks navigate on a pre-snapshot renderer
2579
+ // routinely) hit the same bug head-on; browser.ts handles it there by
2580
+ // rebuilding the view in the same Chrome and retrying the navigation
2581
+ // (`rebuildView`) — profile state survives, so auth carries over. That
2582
+ // recovery is scoped to inherited-navigation failures and does NOT make
2583
+ // the about:blank prewarm pool safe to re-enable.
2543
2584
 
2544
2585
  const result: BootstrapTimings = {
2545
2586
  totalMs: Date.now() - bootStart,
@@ -2618,71 +2659,168 @@ interface RunResult {
2618
2659
  /** asciicast sessions for each `ctx.terminal(...)` call. */
2619
2660
  terminalSessions: TerminalSessionRecord[];
2620
2661
  /**
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.
2662
+ * Per-service log *delta* — the lines each service emitted during THIS
2663
+ * case, beyond what ancestor cases already captured. Captured on every
2664
+ * case (pass or fail). Because a child fork restores the parent's log
2665
+ * markers (module memory travels with the snapshot), these deltas tile
2666
+ * along the ancestor chain into the full cumulative log for any branch —
2667
+ * reconstructed on the dashboard; the CLI failure post-mortem shows the
2668
+ * failing case's own delta. See {captureServiceLogDeltas}.
2625
2669
  */
2626
- serviceLogs: ServiceLogCapture[];
2670
+ serviceLogDeltas: ServiceLogDelta[];
2627
2671
  error?: { message: string; stack?: string };
2628
2672
  }
2629
2673
 
2630
- /** Captured container logs for one service. */
2631
- interface ServiceLogCapture {
2674
+ // ────────────────────────────────────────────────────────────────────────
2675
+ // Cumulative service logs (per-case deltas → S3, reconstructed on the web)
2676
+ // ────────────────────────────────────────────────────────────────────────
2677
+ //
2678
+ // A child test runs in a fork restored from its parent's memory+filesystem
2679
+ // snapshot, so `docker logs <svc>` in the child already contains the
2680
+ // parent's entire history plus the child's own output — logs are
2681
+ // inherently cumulative along each branch. Rather than store the (growing,
2682
+ // redundant) full log at every case, each case stores only its DELTA (the
2683
+ // lines it added), and the dashboard reconstructs a branch's full log by
2684
+ // concatenating deltas along the ancestor chain.
2685
+ //
2686
+ // The load-bearing trick: `LOG_MARKERS` lives in module memory, so it
2687
+ // FORKS with the snapshot (same mechanism as `TEST_DATA` / `RUNTIME_SERVICES`).
2688
+ // `runOne` advances the markers BEFORE `/run` returns, and the control
2689
+ // plane snapshots the fork AFTER `/run` returns — so a child (forked or
2690
+ // handed off) restores the parent's final markers and its delta tiles on
2691
+ // with no gap or cross-branch duplication.
2692
+
2693
+ /**
2694
+ * Per-(service, stream) count of newline-terminated log lines already
2695
+ * captured by an ancestor case. Key is `"<service><stream>"`. The
2696
+ * next case on this branch captures only `lines[marker..]`. Module-scope
2697
+ * so it travels with the snapshot into every fork.
2698
+ */
2699
+ const LOG_MARKERS = new Map<string, { lines: number }>();
2700
+
2701
+ /** Per-(service, stream) delta byte cap. Over this we keep head+tail and
2702
+ * elide the middle — the head preserves the continuation from the parent,
2703
+ * the tail preserves the newest output — while still advancing the marker
2704
+ * to the true line count so the chain stays aligned. */
2705
+ const LOG_DELTA_MAX_BYTES = 2 * 1024 * 1024;
2706
+
2707
+ /** One service's per-case log delta (new output since the parent case). */
2708
+ interface ServiceLogDelta {
2632
2709
  service: string;
2633
- /** Trailing slice of the container's stdout (RFC3339-timestamped). */
2634
2710
  stdout: string;
2635
2711
  stdoutTruncated: boolean;
2636
- /** Trailing slice of the container's stderr. */
2712
+ /** The container's stdout shrank below our marker (it was recreated /
2713
+ * rotated), so this delta is the full current log, not a continuation. */
2714
+ stdoutReset: boolean;
2637
2715
  stderr: string;
2638
2716
  stderrTruncated: boolean;
2717
+ stderrReset: boolean;
2639
2718
  }
2640
2719
 
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 } {
2720
+ /** Keep the head and tail of `s`, eliding the middle when it exceeds
2721
+ * `max` (string length, a byte proxy as elsewhere here). Head+tail so an
2722
+ * over-long delta keeps both the parent-continuation and the newest
2723
+ * output. */
2724
+ function capMiddle(s: string, max: number): { value: string; truncated: boolean } {
2650
2725
  if (s.length <= max) return { value: s, truncated: false };
2651
- return { value: s.slice(s.length - max), truncated: true };
2726
+ const half = Math.floor(max / 2);
2727
+ const elided = s.length - 2 * half;
2728
+ return {
2729
+ value: `${s.slice(0, half)}\n… [${elided} bytes elided] …\n${s.slice(s.length - half)}`,
2730
+ truncated: true,
2731
+ };
2732
+ }
2733
+
2734
+ /**
2735
+ * Compute one stream's delta beyond `marker` complete lines.
2736
+ * - Counts only newline-terminated lines; a trailing partial line (no
2737
+ * `\n` yet) is held back from both the delta and the count, so a line
2738
+ * completed by a later capture isn't split across the fork boundary.
2739
+ * - Reset guard: if the stream shrank below `marker` (container recreated
2740
+ * or rotated) the whole current log is re-emitted and `reset` is set.
2741
+ */
2742
+ function streamDelta(
2743
+ full: string,
2744
+ marker: number,
2745
+ ): { delta: string; total: number; reset: boolean; truncated: boolean } {
2746
+ const lastNl = full.lastIndexOf("\n");
2747
+ const complete = lastNl < 0 ? "" : full.slice(0, lastNl + 1);
2748
+ let total = 0;
2749
+ for (let i = 0; i < complete.length; i++) {
2750
+ if (complete.charCodeAt(i) === 10) total++;
2751
+ }
2752
+ let reset = false;
2753
+ let startLine = marker;
2754
+ if (total < marker) {
2755
+ reset = true;
2756
+ startLine = 0;
2757
+ }
2758
+ let delta: string;
2759
+ if (startLine <= 0) {
2760
+ delta = complete;
2761
+ } else if (startLine >= total) {
2762
+ delta = "";
2763
+ } else {
2764
+ // Byte offset just past the `startLine`-th newline.
2765
+ let seen = 0;
2766
+ let off = 0;
2767
+ for (let i = 0; i < complete.length; i++) {
2768
+ if (complete.charCodeAt(i) === 10 && ++seen === startLine) {
2769
+ off = i + 1;
2770
+ break;
2771
+ }
2772
+ }
2773
+ delta = complete.slice(off);
2774
+ }
2775
+ const capped = capMiddle(delta, LOG_DELTA_MAX_BYTES);
2776
+ return { delta: capped.value, total, reset, truncated: capped.truncated };
2652
2777
  }
2653
2778
 
2654
2779
  /**
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.
2780
+ * Capture the per-service log delta for the current case and advance the
2781
+ * markers. Runs on EVERY case (pass or fail). Enumerates boot services
2782
+ * (`namedServices`) plus any runtime services this fork started
2783
+ * (`RUNTIME_SERVICES`), deduped by name. A `docker logs` failure surfaces
2784
+ * as the service's `stderr` WITHOUT advancing the markers — a transient
2785
+ * failure must never desync the chain.
2662
2786
  */
2663
- async function captureServiceLogs(): Promise<ServiceLogCapture[]> {
2787
+ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
2664
2788
  const l = loaded;
2665
2789
  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
2790
  const byName = new Map<string, NamedService>();
2669
2791
  for (const s of namedServices(l.project.environment)) byName.set(s.name, s);
2670
2792
  for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
2671
2793
  const services = [...byName.values()];
2672
2794
  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);
2795
+ services.map(async (svc): Promise<ServiceLogDelta> => {
2796
+ const r = await docker(["logs", "--timestamps", svc.name], 30_000);
2797
+ if (r.code !== 0) {
2798
+ // Container gone/renamed — surface the CLI error, leave markers put.
2799
+ const err = capMiddle(r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
2800
+ return {
2801
+ service: svc.name,
2802
+ stdout: "",
2803
+ stdoutTruncated: false,
2804
+ stdoutReset: false,
2805
+ stderr: err.value,
2806
+ stderrTruncated: err.truncated,
2807
+ stderrReset: false,
2808
+ };
2809
+ }
2810
+ const outKey = `${svc.name}stdout`;
2811
+ const errKey = `${svc.name}stderr`;
2812
+ const out = streamDelta(r.stdout, LOG_MARKERS.get(outKey)?.lines ?? 0);
2813
+ const err = streamDelta(r.stderr, LOG_MARKERS.get(errKey)?.lines ?? 0);
2814
+ LOG_MARKERS.set(outKey, { lines: out.total });
2815
+ LOG_MARKERS.set(errKey, { lines: err.total });
2680
2816
  return {
2681
2817
  service: svc.name,
2682
- stdout: stdout.value,
2683
- stdoutTruncated: stdout.truncated,
2684
- stderr: stderr.value,
2685
- stderrTruncated: stderr.truncated,
2818
+ stdout: out.delta,
2819
+ stdoutTruncated: out.truncated,
2820
+ stdoutReset: out.reset,
2821
+ stderr: err.delta,
2822
+ stderrTruncated: err.truncated,
2823
+ stderrReset: err.reset,
2686
2824
  };
2687
2825
  }),
2688
2826
  );
@@ -2881,6 +3019,64 @@ async function openInstrumentedTerminal(
2881
3019
  // already populated. Carries arbitrary JS values — no JSON round-trip.
2882
3020
  const TEST_DATA = new Map<string, unknown>();
2883
3021
 
3022
+ // ────────────────────────────────────────────────────────────────────────
3023
+ // Component context — the exec / project-file surface handed to service
3024
+ // `setup` hooks and `helpers` factories (`ComponentContext` in index.ts),
3025
+ // so components don't hand-roll child_process docker execs or hard-code
3026
+ // control-plane paths like /workspace.
3027
+ // ────────────────────────────────────────────────────────────────────────
3028
+
3029
+ const COMPONENT_EXEC_DEFAULT_TIMEOUT_MS = 120_000;
3030
+
3031
+ /** Raw `docker exec` with optional piped stdin. Array command = exact
3032
+ * argv (no shell); string = `sh -lc`. Non-zero exit is reported via
3033
+ * `exitCode`, never thrown. Unlike `execInService` this records nothing —
3034
+ * setup/helpers-factory time has no test timeline. */
3035
+ function componentExec(
3036
+ service: string,
3037
+ command: string | string[],
3038
+ opts?: ComponentExecOpts,
3039
+ ): Promise<ExecResult> {
3040
+ const argv = ["exec", "-i"];
3041
+ if (opts?.cwd) argv.push("-w", opts.cwd);
3042
+ argv.push(service);
3043
+ if (typeof command === "string") argv.push("sh", "-lc", command);
3044
+ else argv.push(...command);
3045
+ const timeoutMs = opts?.timeoutMs ?? COMPONENT_EXEC_DEFAULT_TIMEOUT_MS;
3046
+ return new Promise((resolve, reject) => {
3047
+ const child = spawn("docker", argv, {
3048
+ stdio: [opts?.stdin !== undefined ? "pipe" : "ignore", "pipe", "pipe"],
3049
+ });
3050
+ const out: Buffer[] = [];
3051
+ const err: Buffer[] = [];
3052
+ child.stdout!.on("data", (c: Buffer) => out.push(c));
3053
+ child.stderr!.on("data", (c: Buffer) => err.push(c));
3054
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
3055
+ child.on("error", (e) => {
3056
+ clearTimeout(timer);
3057
+ reject(e);
3058
+ });
3059
+ child.on("close", (code) => {
3060
+ clearTimeout(timer);
3061
+ resolve({
3062
+ stdout: Buffer.concat(out).toString("utf8"),
3063
+ stderr: Buffer.concat(err).toString("utf8"),
3064
+ exitCode: code ?? -1,
3065
+ });
3066
+ });
3067
+ if (opts?.stdin !== undefined) child.stdin!.end(opts.stdin);
3068
+ });
3069
+ }
3070
+
3071
+ function componentContext(): ComponentContext {
3072
+ return {
3073
+ projectRoot: WORKSPACE,
3074
+ readProjectFile: (p: string) =>
3075
+ fs.readFile(path.isAbsolute(p) ? p : path.join(WORKSPACE, p), "utf8"),
3076
+ exec: componentExec,
3077
+ };
3078
+ }
3079
+
2884
3080
  // Cached helper namespaces produced by `ServiceDefinition.helpers`
2885
3081
  // factories. Built lazily on first access and reused for the daemon's
2886
3082
  // lifetime — Bun.SQL pools and similar resources are happy to live a
@@ -2900,7 +3096,7 @@ async function ensureHelpers(
2900
3096
  ): Promise<Record<string, unknown>> {
2901
3097
  if (!def.helpers) return {};
2902
3098
  if (!HELPERS_CACHE.has(name)) {
2903
- HELPERS_CACHE.set(name, await def.helpers({ name }));
3099
+ HELPERS_CACHE.set(name, await def.helpers({ name, ...componentContext() }));
2904
3100
  }
2905
3101
  return HELPERS_CACHE.get(name)!;
2906
3102
  }
@@ -3456,21 +3652,49 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3456
3652
  const parentId = testCase.dependsOn?.id;
3457
3653
  const parent = parentId !== undefined ? TEST_DATA.get(parentId) : undefined;
3458
3654
 
3459
- // Track every Browser opened during this test so we can close them in
3460
- // `finally` — leaked Chromium subprocesses would survive the snapshot
3461
- // and chew memory across forks. Each Browser also gets a session
3462
- // recorder; the records flow back to the control plane as part of
3463
- // RunResult.browserSessions and are persisted to SQLite.
3464
- // Tracks both Browser and Mobile handles for cleanup — both expose an
3465
- // async close() that does the final rrweb drain before teardown.
3466
- const openBrowsers: Array<{ close(): Promise<void> }> = [];
3655
+ // Browser/mobile sessions are PERSISTENT: `ctx.browser()` acquires THE
3656
+ // shared desktop browser and `ctx.mobile(app)` the one session for that
3657
+ // app (browser.ts's module-scoped registry, which forks with the
3658
+ // snapshot like fake state). At test end we DETACH — final rrweb drain,
3659
+ // stop writing to this test's recorder — but deliberately keep the
3660
+ // Chromium alive so the post-test snapshot captures it and dependsOn
3661
+ // children resume the live page (cookies, localStorage, signed-in SPA
3662
+ // state) instead of re-navigating. Each test still gets its own session
3663
+ // record (attach re-arms rrweb with a fresh full snapshot, so replays
3664
+ // stay per-case self-contained); records flow back to the control plane
3665
+ // on RunResult.browserSessions and are archived to S3 as the case's
3666
+ // replay bundle. Within one test repeated ctx.browser()/ctx.mobile(app)
3667
+ // calls return the same handle (memoized below) so one test = one
3668
+ // session per device. An explicit `.close()` destroys the shared
3669
+ // instance — the memo is cleared so a later call starts fresh.
3670
+ const browserDetaches: Array<() => Promise<void>> = [];
3467
3671
  const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
3672
+ let sharedBrowser: Browser | null = null;
3673
+ const sharedMobiles = new Map<string, Mobile>();
3468
3674
  const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
3675
+ if (sharedBrowser) return sharedBrowser;
3469
3676
  const session = newBrowserSession(start, testCase.id);
3470
3677
  sessions.push(session);
3471
- const b = await openBrowser({ ...(opts ?? {}), recorder: session.recorder });
3472
- openBrowsers.push(b);
3473
- return b;
3678
+ const { browser, attached, detach } = await acquirePersistentBrowser({
3679
+ ...(opts ?? {}),
3680
+ recorder: session.recorder,
3681
+ });
3682
+ // An attached session starts mid-page (no navigate event will fire) —
3683
+ // stamp the inherited URL so the dashboard can still label the replay.
3684
+ if (attached && session.record.initialUrl === undefined) {
3685
+ session.record.initialUrl = browser.url;
3686
+ }
3687
+ browserDetaches.push(async () => {
3688
+ await detach();
3689
+ session.markClosed();
3690
+ });
3691
+ const innerClose = browser.close.bind(browser);
3692
+ browser.close = async () => {
3693
+ await innerClose();
3694
+ if (sharedBrowser === browser) sharedBrowser = null;
3695
+ };
3696
+ sharedBrowser = browser;
3697
+ return browser;
3474
3698
  };
3475
3699
  const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
3476
3700
  if (!isMobileApp(app)) {
@@ -3478,11 +3702,28 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3478
3702
  "ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().",
3479
3703
  );
3480
3704
  }
3705
+ const existing = sharedMobiles.get(app.url);
3706
+ if (existing) return existing;
3481
3707
  const session = newBrowserSession(start, testCase.id, "mobile");
3482
3708
  sessions.push(session);
3483
- const m = await openMobile({ url: app.url, recorder: session.recorder });
3484
- openBrowsers.push(m);
3485
- return m;
3709
+ const { mobile, attached, detach } = await openPersistentMobile({
3710
+ url: app.url,
3711
+ recorder: session.recorder,
3712
+ });
3713
+ if (attached && session.record.initialUrl === undefined) {
3714
+ session.record.initialUrl = mobile.url;
3715
+ }
3716
+ browserDetaches.push(async () => {
3717
+ await detach();
3718
+ session.markClosed();
3719
+ });
3720
+ const innerClose = mobile.close.bind(mobile);
3721
+ mobile.close = async () => {
3722
+ await innerClose();
3723
+ if (sharedMobiles.get(app.url) === mobile) sharedMobiles.delete(app.url);
3724
+ };
3725
+ sharedMobiles.set(app.url, mobile);
3726
+ return mobile;
3486
3727
  };
3487
3728
 
3488
3729
  // Build convenience handles (e.g. ctx.svc.db.client) from the loaded
@@ -3550,13 +3791,14 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3550
3791
  (process.stdout as any).write = origStdout;
3551
3792
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3552
3793
  (process.stderr as any).write = origStderr;
3553
- // Best-effort browser cleanup. `close()` does a final rrweb drain
3554
- // before tearing the view down, so we must `await` it before
3555
- // collecting session records. Leaked Chromium subprocesses would
3556
- // survive the snapshot and chew memory across forks.
3557
- for (const b of openBrowsers) {
3794
+ // Detach every browser/mobile session: final rrweb drain (must be
3795
+ // awaited before collecting session records), then stop writing to
3796
+ // this test's recorder. The Chromium itself deliberately stays alive
3797
+ // — it's part of the state the post-test snapshot captures for
3798
+ // dependsOn children (see the acquire comment above).
3799
+ for (const detach of browserDetaches) {
3558
3800
  try {
3559
- await b.close();
3801
+ await detach();
3560
3802
  } catch {
3561
3803
  /* ignore */
3562
3804
  }
@@ -3564,17 +3806,20 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3564
3806
  for (const s of sessions) s.markClosed();
3565
3807
  }
3566
3808
  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
- }
3809
+ // Capture each service's log delta (the lines THIS case added beyond its
3810
+ // ancestors) on every case, pass or fail. Runs after the duration clock
3811
+ // stops so the `docker logs` round trips aren't billed to the test.
3812
+ // Advancing the markers here — before /run returns and the control plane
3813
+ // snapshots the fork — is what lets children tile their own deltas on
3814
+ // seamlessly. Shipped to S3: the dashboard reconstructs the full
3815
+ // cumulative log for a branch, the CLI failure post-mortem shows the
3816
+ // failing case's own delta.
3817
+ let serviceLogDeltas: ServiceLogDelta[] = [];
3818
+ try {
3819
+ serviceLogDeltas = await captureServiceLogDeltas();
3820
+ } catch (err) {
3821
+ // eslint-disable-next-line no-console
3822
+ console.warn("[service-logs] delta capture failed:", err);
3578
3823
  }
3579
3824
  const events = stopRecording();
3580
3825
  // Drop sessions whose linking event didn't survive — an exec/terminal
@@ -3593,7 +3838,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
3593
3838
  events,
3594
3839
  browserSessions: sessions.map((s) => s.record),
3595
3840
  terminalSessions: terminalSessions.filter((s) => referencedSessions.has(s.sessionId)),
3596
- serviceLogs,
3841
+ serviceLogDeltas,
3597
3842
  error: outcome.error,
3598
3843
  };
3599
3844
  }
@@ -3733,14 +3978,35 @@ async function evalCode(
3733
3978
  // wrapped type is honest at runtime). Restored in the `finally` below.
3734
3979
  const restoreFetch = installFetchWrapper();
3735
3980
 
3736
- const openBrowsers: Array<{ close(): Promise<void> }> = [];
3981
+ // Same persistent acquire/detach as a test run (see runOne): the browser
3982
+ // survives the eval, so successive `spectest env eval` calls continue one
3983
+ // live session — and a snapshot taken afterwards carries it.
3984
+ const browserDetaches: Array<() => Promise<void>> = [];
3737
3985
  const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
3986
+ let sharedBrowser: Browser | null = null;
3987
+ const sharedMobiles = new Map<string, Mobile>();
3738
3988
  const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
3989
+ if (sharedBrowser) return sharedBrowser;
3739
3990
  const session = newBrowserSession(start, "eval");
3740
3991
  sessions.push(session);
3741
- const b = await openBrowser({ ...(opts ?? {}), recorder: session.recorder });
3742
- openBrowsers.push(b);
3743
- return b;
3992
+ const { browser, attached, detach } = await acquirePersistentBrowser({
3993
+ ...(opts ?? {}),
3994
+ recorder: session.recorder,
3995
+ });
3996
+ if (attached && session.record.initialUrl === undefined) {
3997
+ session.record.initialUrl = browser.url;
3998
+ }
3999
+ browserDetaches.push(async () => {
4000
+ await detach();
4001
+ session.markClosed();
4002
+ });
4003
+ const innerClose = browser.close.bind(browser);
4004
+ browser.close = async () => {
4005
+ await innerClose();
4006
+ if (sharedBrowser === browser) sharedBrowser = null;
4007
+ };
4008
+ sharedBrowser = browser;
4009
+ return browser;
3744
4010
  };
3745
4011
  const trackedOpenMobile = async (app: MobileApp): Promise<Mobile> => {
3746
4012
  if (!isMobileApp(app)) {
@@ -3748,11 +4014,28 @@ async function evalCode(
3748
4014
  "ctx.mobile(app): pass a mobile-app handle from ctx.svc, e.g. ctx.mobile(ctx.svc.app) for a service declared with expo().",
3749
4015
  );
3750
4016
  }
4017
+ const existing = sharedMobiles.get(app.url);
4018
+ if (existing) return existing;
3751
4019
  const session = newBrowserSession(start, "eval", "mobile");
3752
4020
  sessions.push(session);
3753
- const m = await openMobile({ url: app.url, recorder: session.recorder });
3754
- openBrowsers.push(m);
3755
- return m;
4021
+ const { mobile, attached, detach } = await openPersistentMobile({
4022
+ url: app.url,
4023
+ recorder: session.recorder,
4024
+ });
4025
+ if (attached && session.record.initialUrl === undefined) {
4026
+ session.record.initialUrl = mobile.url;
4027
+ }
4028
+ browserDetaches.push(async () => {
4029
+ await detach();
4030
+ session.markClosed();
4031
+ });
4032
+ const innerClose = mobile.close.bind(mobile);
4033
+ mobile.close = async () => {
4034
+ await innerClose();
4035
+ if (sharedMobiles.get(app.url) === mobile) sharedMobiles.delete(app.url);
4036
+ };
4037
+ sharedMobiles.set(app.url, mobile);
4038
+ return mobile;
3756
4039
  };
3757
4040
 
3758
4041
  // Terminal sessions — same shape as runOne, but eval has no active
@@ -3865,9 +4148,11 @@ async function evalCode(
3865
4148
  (process.stdout as any).write = origStdout;
3866
4149
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
3867
4150
  (process.stderr as any).write = origStderr;
3868
- for (const b of openBrowsers) {
4151
+ // Detach (final rrweb drain) — the browser itself stays alive; see
4152
+ // the acquire comment above.
4153
+ for (const detach of browserDetaches) {
3869
4154
  try {
3870
- await b.close();
4155
+ await detach();
3871
4156
  } catch {
3872
4157
  /* ignore */
3873
4158
  }
@@ -4080,6 +4365,24 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
4080
4365
  return;
4081
4366
  }
4082
4367
 
4368
+ if (method === "POST" && url === "/capture-log-baseline") {
4369
+ // Snapshot each service's log output produced during env bring-up
4370
+ // (container startup + project setup), advancing the log markers so the
4371
+ // subsequent per-test deltas start AFTER setup. The control plane calls
4372
+ // this once, on the main env, before the first test runs — see
4373
+ // tests.rs::capture_log_baseline. Best-effort: on failure the setup lines
4374
+ // simply fold into the first test's delta as before.
4375
+ let serviceLogDeltas: ServiceLogDelta[] = [];
4376
+ try {
4377
+ serviceLogDeltas = await captureServiceLogDeltas();
4378
+ } catch (err) {
4379
+ // eslint-disable-next-line no-console
4380
+ console.warn("[service-logs] baseline capture failed:", err);
4381
+ }
4382
+ jsonResponse(res, 200, { serviceLogDeltas });
4383
+ return;
4384
+ }
4385
+
4083
4386
  if (method === "POST" && url === "/run") {
4084
4387
  if (state.inFlightTest) {
4085
4388
  jsonResponse(res, 409, { error: "another test is already running" });