@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/package.json +1 -1
- package/src/browser.ts +309 -46
- package/src/components/email.ts +387 -0
- package/src/components/index.ts +15 -0
- package/src/components/k3s.ts +4 -16
- package/src/components/supabase.ts +1109 -0
- package/src/daemon.ts +392 -89
- package/src/index.ts +481 -17
- package/src/mobile.ts +23 -4
- package/src/recorder.ts +72 -1
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 {
|
|
38
|
-
import {
|
|
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(
|
|
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);
|
|
@@ -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
|
-
*
|
|
2622
|
-
*
|
|
2623
|
-
*
|
|
2624
|
-
*
|
|
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
|
-
|
|
2670
|
+
serviceLogDeltas: ServiceLogDelta[];
|
|
2627
2671
|
error?: { message: string; stack?: string };
|
|
2628
2672
|
}
|
|
2629
2673
|
|
|
2630
|
-
|
|
2631
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 } {
|
|
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
|
-
|
|
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
|
|
2656
|
-
*
|
|
2657
|
-
*
|
|
2658
|
-
*
|
|
2659
|
-
*
|
|
2660
|
-
*
|
|
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
|
|
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<
|
|
2674
|
-
const r = await docker(
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
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:
|
|
2683
|
-
stdoutTruncated:
|
|
2684
|
-
|
|
2685
|
-
|
|
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
|
-
//
|
|
3460
|
-
//
|
|
3461
|
-
//
|
|
3462
|
-
//
|
|
3463
|
-
//
|
|
3464
|
-
//
|
|
3465
|
-
//
|
|
3466
|
-
|
|
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
|
|
3472
|
-
|
|
3473
|
-
|
|
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
|
|
3484
|
-
|
|
3485
|
-
|
|
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
|
-
//
|
|
3554
|
-
// before
|
|
3555
|
-
//
|
|
3556
|
-
//
|
|
3557
|
-
|
|
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
|
|
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
|
-
//
|
|
3568
|
-
//
|
|
3569
|
-
//
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3742
|
-
|
|
3743
|
-
|
|
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
|
|
3754
|
-
|
|
3755
|
-
|
|
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
|
-
|
|
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
|
|
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" });
|