@specific.dev/spectest 0.13.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/package.json +1 -1
- package/src/components/index.ts +6 -0
- package/src/components/k3s.ts +4 -16
- package/src/components/supabase.ts +1109 -0
- package/src/daemon.ts +270 -59
- package/src/index.ts +460 -12
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(
|
|
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}`, {
|
|
1071
|
-
|
|
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(
|
|
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
|
-
*
|
|
2622
|
-
*
|
|
2623
|
-
*
|
|
2624
|
-
*
|
|
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
|
-
|
|
2663
|
+
serviceLogDeltas: ServiceLogDelta[];
|
|
2627
2664
|
error?: { message: string; stack?: string };
|
|
2628
2665
|
}
|
|
2629
2666
|
|
|
2630
|
-
|
|
2631
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
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
|
-
|
|
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
|
|
2656
|
-
*
|
|
2657
|
-
*
|
|
2658
|
-
*
|
|
2659
|
-
*
|
|
2660
|
-
*
|
|
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
|
|
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<
|
|
2674
|
-
const r = await docker(
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
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:
|
|
2683
|
-
stdoutTruncated:
|
|
2684
|
-
|
|
2685
|
-
|
|
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
|
|
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
|
-
//
|
|
3568
|
-
//
|
|
3569
|
-
//
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
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
|
-
|
|
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" });
|