@specific.dev/spectest 0.19.1 → 0.21.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 +62 -18
- package/src/components/replayFake.ts +4 -5
- package/src/daemon.ts +374 -6
- package/src/ids.ts +89 -0
- package/src/index.ts +12 -17
- package/src/mobile.ts +9 -5
- package/src/replay-bundle.ts +108 -0
package/package.json
CHANGED
package/src/browser.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { readFileSync } from "node:fs";
|
|
|
33
33
|
import path from "node:path";
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
35
35
|
|
|
36
|
+
import { generateId } from "./ids.js";
|
|
36
37
|
import { recordBrowser, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
37
38
|
import { wrap } from "./inspect.js";
|
|
38
39
|
import type { Wrapped } from "./inspect.js";
|
|
@@ -76,13 +77,12 @@ export interface BrowserOptions {
|
|
|
76
77
|
recorder?: BrowserSessionRecorder | null;
|
|
77
78
|
}
|
|
78
79
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
quality?: number;
|
|
80
|
+
/** Decoded byte count of a base64 string, without decoding it. */
|
|
81
|
+
function base64ByteLength(b64: string): number {
|
|
82
|
+
let padding = 0;
|
|
83
|
+
if (b64.endsWith("==")) padding = 2;
|
|
84
|
+
else if (b64.endsWith("=")) padding = 1;
|
|
85
|
+
return (b64.length * 3) / 4 - padding;
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/** One Browser-action's worth of rrweb events, in the order rrweb emitted them. */
|
|
@@ -114,6 +114,20 @@ export interface BrowserSessionRecorder {
|
|
|
114
114
|
recordStep(step: BrowserSessionStep): void;
|
|
115
115
|
/** Optional: called whenever `Browser.navigate(url)` is invoked. */
|
|
116
116
|
noteNavigation?(url: string): void;
|
|
117
|
+
/**
|
|
118
|
+
* Optional: register a captured artifact (screenshot bytes) for upload.
|
|
119
|
+
* Present only in eval context — the daemon wires it to the per-eval
|
|
120
|
+
* collector, and its absence is what makes `screenshot()` throw during
|
|
121
|
+
* test runs (artifacts are eval-only for now). Throws when the eval's
|
|
122
|
+
* artifact byte budget is exhausted.
|
|
123
|
+
*/
|
|
124
|
+
registerArtifact?(artifact: {
|
|
125
|
+
id: string;
|
|
126
|
+
kind: "screenshot";
|
|
127
|
+
contentType: string;
|
|
128
|
+
sizeBytes: number;
|
|
129
|
+
bytesBase64: string;
|
|
130
|
+
}): void;
|
|
117
131
|
}
|
|
118
132
|
|
|
119
133
|
/**
|
|
@@ -200,8 +214,14 @@ export interface Browser {
|
|
|
200
214
|
forward(): Promise<void>;
|
|
201
215
|
/** Reload the current page. */
|
|
202
216
|
reload(): Promise<void>;
|
|
203
|
-
/**
|
|
204
|
-
|
|
217
|
+
/**
|
|
218
|
+
* Capture a PNG screenshot of the viewport and upload it as a
|
|
219
|
+
* downloadable **artifact**. Resolves to the artifact's `art_…` id —
|
|
220
|
+
* fetch the image locally with `spectest artifact download <id>`.
|
|
221
|
+
* Only available inside an eval (`spectest_eval` / `spectest env eval`);
|
|
222
|
+
* throws with a clear message during test runs.
|
|
223
|
+
*/
|
|
224
|
+
screenshot(): Promise<string>;
|
|
205
225
|
/**
|
|
206
226
|
* Destroy the underlying view. Idempotent; drains any pending rrweb
|
|
207
227
|
* events first. For the persistent session behind `ctx.browser()` /
|
|
@@ -1521,16 +1541,39 @@ function buildBackend(
|
|
|
1521
1541
|
await holder.page.reload();
|
|
1522
1542
|
});
|
|
1523
1543
|
},
|
|
1524
|
-
async screenshot(
|
|
1525
|
-
const
|
|
1526
|
-
return instrumented("screenshot",
|
|
1527
|
-
//
|
|
1528
|
-
//
|
|
1544
|
+
async screenshot() {
|
|
1545
|
+
const fields: Partial<RecordableFields> = { format: "png" };
|
|
1546
|
+
return instrumented("screenshot", fields, async () => {
|
|
1547
|
+
// Artifacts are eval-only for now: the daemon wires a
|
|
1548
|
+
// registerArtifact sink onto eval sessions' recorders and nothing
|
|
1549
|
+
// else's, so its absence means "test run / recorder-less browser".
|
|
1550
|
+
const register = recorder?.registerArtifact;
|
|
1551
|
+
if (!register) {
|
|
1552
|
+
throw new Error(
|
|
1553
|
+
"screenshot() uploads the capture as a downloadable artifact and is " +
|
|
1554
|
+
"currently only available inside an eval (spectest_eval / `spectest env eval`) — " +
|
|
1555
|
+
"it cannot be used in test runs.",
|
|
1556
|
+
);
|
|
1557
|
+
}
|
|
1558
|
+
// Raw CDP rather than page.screenshot(): captures the viewport
|
|
1559
|
+
// exactly like the pre-Playwright backend.
|
|
1529
1560
|
const res = (await holder.cdp.send("Page.captureScreenshot", {
|
|
1530
|
-
format,
|
|
1531
|
-
...(format === "png" ? {} : { quality: options?.quality ?? 90 }),
|
|
1561
|
+
format: "png",
|
|
1532
1562
|
} as never)) as { data: string };
|
|
1533
|
-
|
|
1563
|
+
const id = generateId("art");
|
|
1564
|
+
register({
|
|
1565
|
+
id,
|
|
1566
|
+
kind: "screenshot",
|
|
1567
|
+
contentType: "image/png",
|
|
1568
|
+
// CDP already hands us base64 — pass it through un-recoded and
|
|
1569
|
+
// derive the raw byte count from the encoding.
|
|
1570
|
+
sizeBytes: base64ByteLength(res.data),
|
|
1571
|
+
bytesBase64: res.data,
|
|
1572
|
+
});
|
|
1573
|
+
// Stamped onto the browser event before instrumented() records it —
|
|
1574
|
+
// same mutate-fields-inside-fn() precedent as waitFor's `attempts`.
|
|
1575
|
+
fields.artifactId = id;
|
|
1576
|
+
return id;
|
|
1534
1577
|
});
|
|
1535
1578
|
},
|
|
1536
1579
|
async close() {
|
|
@@ -1627,6 +1670,7 @@ interface RecordableFields {
|
|
|
1627
1670
|
dy: number;
|
|
1628
1671
|
x: number;
|
|
1629
1672
|
y: number;
|
|
1630
|
-
format:
|
|
1673
|
+
format: string;
|
|
1631
1674
|
attempts: number;
|
|
1675
|
+
artifactId: string;
|
|
1632
1676
|
}
|
|
@@ -519,10 +519,9 @@ function resolveExternalA(host: string): Promise<string[]> {
|
|
|
519
519
|
sock.on("message", (msg) => {
|
|
520
520
|
try {
|
|
521
521
|
const res = dnsPacket.decode(msg);
|
|
522
|
-
const ips = (res.answers ?? [])
|
|
523
|
-
.
|
|
524
|
-
|
|
525
|
-
.filter((d) => typeof d === "string" && d.length > 0);
|
|
522
|
+
const ips = (res.answers ?? []).flatMap((a) =>
|
|
523
|
+
a.type === "A" && typeof a.data === "string" && a.data.length > 0 ? [a.data] : [],
|
|
524
|
+
);
|
|
526
525
|
finish(ips);
|
|
527
526
|
} catch {
|
|
528
527
|
finish([]);
|
|
@@ -766,7 +765,7 @@ export function replayFake(
|
|
|
766
765
|
state.recorded.push(interaction);
|
|
767
766
|
state.dirty = true;
|
|
768
767
|
|
|
769
|
-
return new Response(upstream.bytes, {
|
|
768
|
+
return new Response(upstream.bytes as unknown as BodyInit, {
|
|
770
769
|
status: upstream.status,
|
|
771
770
|
headers: new Headers(respHeaders),
|
|
772
771
|
});
|
package/src/daemon.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
import { wrap, wrapResponse } from "./inspect.js";
|
|
58
58
|
import type { SpectestFetch, Wrapped, WrappedResponse } from "./inspect.js";
|
|
59
59
|
import { clearRecordSecrets, setRecordSecrets } from "./record-secrets.js";
|
|
60
|
+
import { encodeReplayBundle, replayChunk } from "./replay-bundle.js";
|
|
60
61
|
|
|
61
62
|
import type {
|
|
62
63
|
Browser,
|
|
@@ -750,7 +751,7 @@ const BUILD_DEDUP = new Map<
|
|
|
750
751
|
{ name: string; promise: Promise<{ tag: string; buildSteps?: BuildStep[] }> }
|
|
751
752
|
>();
|
|
752
753
|
|
|
753
|
-
function buildContentKey(image: { content: string; exclude?: string[] }): string {
|
|
754
|
+
function buildContentKey(image: { content: string; exclude?: readonly string[] }): string {
|
|
754
755
|
return new Bun.CryptoHasher("sha256")
|
|
755
756
|
.update(image.content)
|
|
756
757
|
.update("\0")
|
|
@@ -836,7 +837,7 @@ async function prepareServiceImage(
|
|
|
836
837
|
|
|
837
838
|
async function buildServiceImage(
|
|
838
839
|
name: string,
|
|
839
|
-
image: { content: string; exclude?: string[] },
|
|
840
|
+
image: { content: string; exclude?: readonly string[] },
|
|
840
841
|
tag: string,
|
|
841
842
|
): Promise<{ tag: string; buildSteps?: BuildStep[] }> {
|
|
842
843
|
let buildSteps: BuildStep[] | undefined;
|
|
@@ -2802,6 +2803,38 @@ interface RunResult {
|
|
|
2802
2803
|
error?: { message: string; stack?: string };
|
|
2803
2804
|
}
|
|
2804
2805
|
|
|
2806
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
2807
|
+
// Replay bundles (rrweb sessions → gzipped side-channel, pulled in chunks)
|
|
2808
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
2809
|
+
//
|
|
2810
|
+
// `RunResult.browserSessions` never rides the `/run` reply: the vm-agent
|
|
2811
|
+
// caps proxied daemon responses at 16 MB and a browser-heavy case's
|
|
2812
|
+
// recording is tens of MB of JSON (see replay-bundle.ts for the whole
|
|
2813
|
+
// story). Instead the /run handler encodes the sessions into a gzipped,
|
|
2814
|
+
// asset-deduplicated bundle, parks it here keyed by case id, and replies
|
|
2815
|
+
// with a tiny `replay: { bytes }` ref; the control plane then pulls the
|
|
2816
|
+
// bundle via `POST /replay-chunk` in base64 chunks sized under the cap,
|
|
2817
|
+
// before it tears the fork down.
|
|
2818
|
+
//
|
|
2819
|
+
// The map is module memory, so it FORKS with the snapshot (the post-test
|
|
2820
|
+
// snapshot is captured before the control plane fetches). The size cap
|
|
2821
|
+
// keeps a long handoff chain from accreting every ancestor's (gzipped)
|
|
2822
|
+
// bundle in guest RAM — entries older than the last few are always
|
|
2823
|
+
// already-fetched leftovers frozen into some snapshot.
|
|
2824
|
+
const REPLAY_BUNDLES = new Map<string, Buffer>();
|
|
2825
|
+
const REPLAY_BUNDLES_MAX = 8;
|
|
2826
|
+
|
|
2827
|
+
function stashReplayBundle(caseId: string, gz: Buffer): void {
|
|
2828
|
+
// Re-insert to refresh recency (Map iterates in insertion order).
|
|
2829
|
+
REPLAY_BUNDLES.delete(caseId);
|
|
2830
|
+
REPLAY_BUNDLES.set(caseId, gz);
|
|
2831
|
+
while (REPLAY_BUNDLES.size > REPLAY_BUNDLES_MAX) {
|
|
2832
|
+
const oldest = REPLAY_BUNDLES.keys().next().value;
|
|
2833
|
+
if (oldest === undefined) break;
|
|
2834
|
+
REPLAY_BUNDLES.delete(oldest);
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2805
2838
|
// ────────────────────────────────────────────────────────────────────────
|
|
2806
2839
|
// Cumulative service logs (per-case deltas → S3, reconstructed on the web)
|
|
2807
2840
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -2846,6 +2879,32 @@ interface ServiceLogDelta {
|
|
|
2846
2879
|
stderr: string;
|
|
2847
2880
|
stderrTruncated: boolean;
|
|
2848
2881
|
stderrReset: boolean;
|
|
2882
|
+
/** The container was no longer running ("exited"/"dead") when this delta
|
|
2883
|
+
* was captured. Absent while running or when inspect fails (e.g. the
|
|
2884
|
+
* container was removed). Shown next to the service on the dashboard's
|
|
2885
|
+
* Logs page. */
|
|
2886
|
+
stopped?: boolean;
|
|
2887
|
+
/** docker's recorded exit code for the stopped container. */
|
|
2888
|
+
exitCode?: number;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
/**
|
|
2892
|
+
* Inspect a container's run state for {@link ServiceLogDelta}: `{}` while
|
|
2893
|
+
* running (or when inspect fails — a removed container has no state left to
|
|
2894
|
+
* report), `{ stopped, exitCode }` once it has exited/died.
|
|
2895
|
+
*/
|
|
2896
|
+
async function containerStopState(
|
|
2897
|
+
name: string,
|
|
2898
|
+
): Promise<{ stopped?: boolean; exitCode?: number }> {
|
|
2899
|
+
const r = await docker(
|
|
2900
|
+
["inspect", "-f", "{{.State.Status}} {{.State.ExitCode}}", name],
|
|
2901
|
+
15_000,
|
|
2902
|
+
);
|
|
2903
|
+
if (r.code !== 0) return {};
|
|
2904
|
+
const [status, codeStr] = r.stdout.trim().split(/\s+/);
|
|
2905
|
+
if (status !== "exited" && status !== "dead") return {};
|
|
2906
|
+
const exitCode = Number.parseInt(codeStr ?? "", 10);
|
|
2907
|
+
return Number.isFinite(exitCode) ? { stopped: true, exitCode } : { stopped: true };
|
|
2849
2908
|
}
|
|
2850
2909
|
|
|
2851
2910
|
/** Keep the head and tail of `s`, eliding the middle when it exceeds
|
|
@@ -2924,7 +2983,10 @@ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
|
|
|
2924
2983
|
const services = [...byName.values()];
|
|
2925
2984
|
return Promise.all(
|
|
2926
2985
|
services.map(async (svc): Promise<ServiceLogDelta> => {
|
|
2927
|
-
const r = await
|
|
2986
|
+
const [r, stop] = await Promise.all([
|
|
2987
|
+
docker(["logs", "--timestamps", svc.name], 30_000),
|
|
2988
|
+
containerStopState(svc.name),
|
|
2989
|
+
]);
|
|
2928
2990
|
if (r.code !== 0) {
|
|
2929
2991
|
// Container gone/renamed — surface the CLI error, leave markers put.
|
|
2930
2992
|
const err = capMiddle(r.stderr || r.stdout, LOG_DELTA_MAX_BYTES);
|
|
@@ -2936,6 +2998,7 @@ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
|
|
|
2936
2998
|
stderr: err.value,
|
|
2937
2999
|
stderrTruncated: err.truncated,
|
|
2938
3000
|
stderrReset: false,
|
|
3001
|
+
...stop,
|
|
2939
3002
|
};
|
|
2940
3003
|
}
|
|
2941
3004
|
const outKey = `${svc.name}stdout`;
|
|
@@ -2952,6 +3015,7 @@ async function captureServiceLogDeltas(): Promise<ServiceLogDelta[]> {
|
|
|
2952
3015
|
stderr: err.delta,
|
|
2953
3016
|
stderrTruncated: err.truncated,
|
|
2954
3017
|
stderrReset: err.reset,
|
|
3018
|
+
...stop,
|
|
2955
3019
|
};
|
|
2956
3020
|
}),
|
|
2957
3021
|
);
|
|
@@ -2994,15 +3058,71 @@ function newSessionId(idScope: string): string {
|
|
|
2994
3058
|
return idScope ? `${idScope}:${randomUUID()}` : randomUUID();
|
|
2995
3059
|
}
|
|
2996
3060
|
|
|
3061
|
+
/**
|
|
3062
|
+
* One artifact captured during an eval (screenshot bytes), shipped inline
|
|
3063
|
+
* (base64) on the `/eval` reply's `artifacts` array — parallel to
|
|
3064
|
+
* `browserSessions`. The control plane uploads each to S3, records the
|
|
3065
|
+
* metadata row, and strips `bytesBase64` before the reply reaches the
|
|
3066
|
+
* caller. The id is minted in-VM by `ids.ts` (see its fork-frozen-RNG
|
|
3067
|
+
* caveat) in the control plane's `art_0…` format.
|
|
3068
|
+
*/
|
|
3069
|
+
interface CapturedArtifact {
|
|
3070
|
+
id: string;
|
|
3071
|
+
kind: "screenshot";
|
|
3072
|
+
contentType: string;
|
|
3073
|
+
sizeBytes: number;
|
|
3074
|
+
bytesBase64: string;
|
|
3075
|
+
}
|
|
3076
|
+
|
|
3077
|
+
/**
|
|
3078
|
+
* Cumulative raw-byte cap on one eval's artifacts. The bytes ride the
|
|
3079
|
+
* `/eval` JSON reply base64'd (~1.33x), and the vm-agent hard-errors on
|
|
3080
|
+
* proxied daemon responses over 16 MB (the same cap that pushed /run's
|
|
3081
|
+
* replay bundles out-of-band — see REPLAY_BUNDLES). 8 MiB raw ≈ 10.7 MB
|
|
3082
|
+
* encoded leaves headroom for the reply's sessions/log; screenshots are
|
|
3083
|
+
* typically well under 2 MiB each. If artifacts ever need to grow past
|
|
3084
|
+
* this, move them to the parked-chunk side channel instead of raising it.
|
|
3085
|
+
*/
|
|
3086
|
+
const MAX_EVAL_ARTIFACT_BYTES = 8 * 1024 * 1024;
|
|
3087
|
+
|
|
3088
|
+
/**
|
|
3089
|
+
* Per-eval artifact sink. `register` throws (failing the screenshot() call,
|
|
3090
|
+
* never the eval) once the byte budget is exhausted.
|
|
3091
|
+
*/
|
|
3092
|
+
function newArtifactCollector(): {
|
|
3093
|
+
artifacts: CapturedArtifact[];
|
|
3094
|
+
register(artifact: CapturedArtifact): void;
|
|
3095
|
+
} {
|
|
3096
|
+
const artifacts: CapturedArtifact[] = [];
|
|
3097
|
+
let total = 0;
|
|
3098
|
+
return {
|
|
3099
|
+
artifacts,
|
|
3100
|
+
register(artifact) {
|
|
3101
|
+
total += artifact.sizeBytes;
|
|
3102
|
+
if (total > MAX_EVAL_ARTIFACT_BYTES) {
|
|
3103
|
+
throw new Error(
|
|
3104
|
+
`artifact byte budget for this eval exceeded (${MAX_EVAL_ARTIFACT_BYTES / (1024 * 1024)} MiB) — ` +
|
|
3105
|
+
"capture fewer screenshots per eval",
|
|
3106
|
+
);
|
|
3107
|
+
}
|
|
3108
|
+
artifacts.push(artifact);
|
|
3109
|
+
},
|
|
3110
|
+
};
|
|
3111
|
+
}
|
|
3112
|
+
|
|
2997
3113
|
/**
|
|
2998
3114
|
* Build the recorder sink + bookkeeping for a single Browser session.
|
|
2999
3115
|
* The returned `recorder` is what `openBrowser` writes into; the
|
|
3000
3116
|
* returned `record` is the in-flight session object the daemon owns.
|
|
3117
|
+
* `artifacts` (eval-only) wires `screenshot()`'s artifact registration —
|
|
3118
|
+
* test-run sessions don't pass it, which is exactly what makes
|
|
3119
|
+
* `screenshot()` throw outside eval.
|
|
3001
3120
|
*/
|
|
3002
3121
|
function newBrowserSession(
|
|
3003
3122
|
testStart: number,
|
|
3004
3123
|
idScope: string,
|
|
3005
3124
|
frame: "browser" | "mobile" = "browser",
|
|
3125
|
+
artifacts?: ReturnType<typeof newArtifactCollector>,
|
|
3006
3126
|
): {
|
|
3007
3127
|
recorder: BrowserSessionRecorder;
|
|
3008
3128
|
record: BrowserSessionRecord;
|
|
@@ -3027,6 +3147,9 @@ function newBrowserSession(
|
|
|
3027
3147
|
if (closed) return;
|
|
3028
3148
|
if (record.initialUrl === undefined) record.initialUrl = url;
|
|
3029
3149
|
},
|
|
3150
|
+
...(artifacts
|
|
3151
|
+
? { registerArtifact: (a: CapturedArtifact) => artifacts.register(a) }
|
|
3152
|
+
: {}),
|
|
3030
3153
|
},
|
|
3031
3154
|
markClosed() {
|
|
3032
3155
|
if (closed) return;
|
|
@@ -4001,6 +4124,9 @@ interface EvalResult {
|
|
|
4001
4124
|
browserSessions: BrowserSessionRecord[];
|
|
4002
4125
|
/** asciicast sessions for any ctx.terminal() call during the eval. */
|
|
4003
4126
|
terminalSessions: TerminalSessionRecord[];
|
|
4127
|
+
/** Artifacts captured during the eval (screenshot bytes, base64 inline —
|
|
4128
|
+
* stripped by the control plane before the reply reaches the caller). */
|
|
4129
|
+
artifacts: CapturedArtifact[];
|
|
4004
4130
|
error?: { message: string; stack?: string };
|
|
4005
4131
|
}
|
|
4006
4132
|
|
|
@@ -4125,11 +4251,14 @@ async function evalCode(
|
|
|
4125
4251
|
// live session — and a snapshot taken afterwards carries it.
|
|
4126
4252
|
const browserDetaches: Array<() => Promise<void>> = [];
|
|
4127
4253
|
const sessions: Array<ReturnType<typeof newBrowserSession>> = [];
|
|
4254
|
+
// Eval-only artifact sink — wiring it here (and nowhere in runOne) is
|
|
4255
|
+
// what gates screenshot() to eval context.
|
|
4256
|
+
const artifactCollector = newArtifactCollector();
|
|
4128
4257
|
let sharedBrowser: Browser | null = null;
|
|
4129
4258
|
const sharedMobiles = new Map<string, Mobile>();
|
|
4130
4259
|
const trackedOpenBrowser = async (opts?: BrowserOptions): Promise<Browser> => {
|
|
4131
4260
|
if (sharedBrowser) return sharedBrowser;
|
|
4132
|
-
const session = newBrowserSession(start, "eval");
|
|
4261
|
+
const session = newBrowserSession(start, "eval", "browser", artifactCollector);
|
|
4133
4262
|
sessions.push(session);
|
|
4134
4263
|
const { browser, attached, detach } = await acquirePersistentBrowser({
|
|
4135
4264
|
...(opts ?? {}),
|
|
@@ -4161,7 +4290,7 @@ async function evalCode(
|
|
|
4161
4290
|
}
|
|
4162
4291
|
const existing = sharedMobiles.get(app.url);
|
|
4163
4292
|
if (existing) return existing;
|
|
4164
|
-
const session = newBrowserSession(start, "eval", "mobile");
|
|
4293
|
+
const session = newBrowserSession(start, "eval", "mobile", artifactCollector);
|
|
4165
4294
|
sessions.push(session);
|
|
4166
4295
|
const { mobile, attached, detach, safeAreaInsets } = await openPersistentMobile({
|
|
4167
4296
|
url: app.url,
|
|
@@ -4310,6 +4439,8 @@ async function evalCode(
|
|
|
4310
4439
|
});
|
|
4311
4440
|
}
|
|
4312
4441
|
}
|
|
4442
|
+
// `artifacts` ships on the error branch too — a screenshot captured
|
|
4443
|
+
// before a snippet crash is exactly the evidence the caller wants.
|
|
4313
4444
|
return outcome.ok
|
|
4314
4445
|
? {
|
|
4315
4446
|
ok: true,
|
|
@@ -4319,6 +4450,7 @@ async function evalCode(
|
|
|
4319
4450
|
result: outcome.result,
|
|
4320
4451
|
browserSessions: sessions.map((s) => s.record),
|
|
4321
4452
|
terminalSessions,
|
|
4453
|
+
artifacts: artifactCollector.artifacts,
|
|
4322
4454
|
}
|
|
4323
4455
|
: {
|
|
4324
4456
|
ok: false,
|
|
@@ -4327,10 +4459,190 @@ async function evalCode(
|
|
|
4327
4459
|
installed,
|
|
4328
4460
|
browserSessions: sessions.map((s) => s.record),
|
|
4329
4461
|
terminalSessions,
|
|
4462
|
+
artifacts: artifactCollector.artifacts,
|
|
4330
4463
|
error: outcome.error,
|
|
4331
4464
|
};
|
|
4332
4465
|
}
|
|
4333
4466
|
|
|
4467
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
4468
|
+
// Typecheck
|
|
4469
|
+
//
|
|
4470
|
+
// `tsc --noEmit` over the user's spectest/ code (APP_DIR — the only tree the
|
|
4471
|
+
// daemon imports; app-under-test source has its own toolchain). Kicked off in
|
|
4472
|
+
// the background at /load-tests so it never sits on the start/run critical
|
|
4473
|
+
// path; the control plane collects the result via POST /typecheck (which
|
|
4474
|
+
// awaits the in-flight run) and attaches it to the SuiteResult as an
|
|
4475
|
+
// advisory report. It must never gate a run: the runtime accepts patterns
|
|
4476
|
+
// the types reject (e.g. bare global `fetch` is monkey-patched to record,
|
|
4477
|
+
// but its *type* stays raw), so a type error is a strong hint, not proof.
|
|
4478
|
+
//
|
|
4479
|
+
// The compiler is the native tsc baked into the base snapshot at
|
|
4480
|
+
// /opt/spectest/typecheck (see base.rs BASE_SETUP_SH); a project that ships
|
|
4481
|
+
// its own `typescript` in spectest/package.json wins. A project tsconfig.json
|
|
4482
|
+
// wins over the generated one the same way.
|
|
4483
|
+
// ────────────────────────────────────────────────────────────────────────
|
|
4484
|
+
|
|
4485
|
+
const TYPECHECK_DIR = "/opt/spectest/typecheck";
|
|
4486
|
+
/** Cap on errors shipped in the report; `totalErrors` carries the true count. */
|
|
4487
|
+
const TYPECHECK_ERROR_CAP = 50;
|
|
4488
|
+
|
|
4489
|
+
interface TypecheckError {
|
|
4490
|
+
/** Path relative to the project's spectest/ dir (e.g. `tests/checkout.ts`). */
|
|
4491
|
+
file: string;
|
|
4492
|
+
line: number;
|
|
4493
|
+
column: number;
|
|
4494
|
+
/** `TS2345` etc. */
|
|
4495
|
+
code: string;
|
|
4496
|
+
message: string;
|
|
4497
|
+
}
|
|
4498
|
+
|
|
4499
|
+
interface TypecheckReport {
|
|
4500
|
+
status: "ok" | "errors" | "skipped" | "failed";
|
|
4501
|
+
errors: TypecheckError[];
|
|
4502
|
+
totalErrors: number;
|
|
4503
|
+
durationMs: number;
|
|
4504
|
+
/** Why the check was skipped / how it failed. */
|
|
4505
|
+
detail?: string;
|
|
4506
|
+
}
|
|
4507
|
+
|
|
4508
|
+
let TYPECHECK: Promise<TypecheckReport> | null = null;
|
|
4509
|
+
|
|
4510
|
+
/** Fire-and-forget kickoff; the promise is parked for POST /typecheck. */
|
|
4511
|
+
function startTypecheck(): void {
|
|
4512
|
+
TYPECHECK = runTypecheck().catch((err) => ({
|
|
4513
|
+
status: "failed" as const,
|
|
4514
|
+
errors: [],
|
|
4515
|
+
totalErrors: 0,
|
|
4516
|
+
durationMs: 0,
|
|
4517
|
+
detail: err instanceof Error ? err.message : String(err),
|
|
4518
|
+
}));
|
|
4519
|
+
// Parked promises must never surface as unhandled rejections.
|
|
4520
|
+
TYPECHECK.catch(() => {});
|
|
4521
|
+
}
|
|
4522
|
+
|
|
4523
|
+
/** The tsconfig used when the project doesn't ship its own. `lib` is left to
|
|
4524
|
+
* the target default (which includes DOM — browser.evaluate callbacks
|
|
4525
|
+
* reference `document`); `types` pulls Bun's ambient globals from the baked
|
|
4526
|
+
* install via `typeRoots` (the walk-up from APP_DIR never reaches it). */
|
|
4527
|
+
function generatedTsconfig(): string {
|
|
4528
|
+
return JSON.stringify(
|
|
4529
|
+
{
|
|
4530
|
+
compilerOptions: {
|
|
4531
|
+
target: "esnext",
|
|
4532
|
+
module: "esnext",
|
|
4533
|
+
moduleResolution: "bundler",
|
|
4534
|
+
strict: true,
|
|
4535
|
+
noEmit: true,
|
|
4536
|
+
skipLibCheck: true,
|
|
4537
|
+
esModuleInterop: true,
|
|
4538
|
+
resolveJsonModule: true,
|
|
4539
|
+
jsx: "react-jsx",
|
|
4540
|
+
types: ["bun"],
|
|
4541
|
+
typeRoots: [
|
|
4542
|
+
path.join(APP_DIR, "node_modules", "@types"),
|
|
4543
|
+
path.join(TYPECHECK_DIR, "node_modules", "@types"),
|
|
4544
|
+
],
|
|
4545
|
+
},
|
|
4546
|
+
include: ["**/*.ts", "**/*.tsx"],
|
|
4547
|
+
exclude: ["node_modules"],
|
|
4548
|
+
},
|
|
4549
|
+
null,
|
|
4550
|
+
2,
|
|
4551
|
+
);
|
|
4552
|
+
}
|
|
4553
|
+
|
|
4554
|
+
/** Parse `--pretty false` tsc output: `file(line,col): error TScode: msg`,
|
|
4555
|
+
* with indented elaboration lines folded into the preceding error. */
|
|
4556
|
+
function parseTscOutput(output: string): {
|
|
4557
|
+
errors: TypecheckError[];
|
|
4558
|
+
total: number;
|
|
4559
|
+
suppressed: number;
|
|
4560
|
+
} {
|
|
4561
|
+
const errors: TypecheckError[] = [];
|
|
4562
|
+
let total = 0;
|
|
4563
|
+
let suppressed = 0;
|
|
4564
|
+
let last: TypecheckError | null = null;
|
|
4565
|
+
for (const line of output.split("\n")) {
|
|
4566
|
+
const m = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
|
|
4567
|
+
if (!m) {
|
|
4568
|
+
// Elaboration lines are indented; fold them into the last kept error.
|
|
4569
|
+
if (last && /^\s+\S/.test(line)) last.message += `\n${line.trimEnd()}`;
|
|
4570
|
+
continue;
|
|
4571
|
+
}
|
|
4572
|
+
const [, rawFile, ln, col, code, message] = m;
|
|
4573
|
+
// Keep only diagnostics in the user's files. tsc runs with cwd=APP_DIR
|
|
4574
|
+
// (the daemon's cwd), so project files come out relative ("tests/x.ts")
|
|
4575
|
+
// and anything outside — the SDK at /opt/spectest/sdk, node_modules —
|
|
4576
|
+
// is `../…` or absolute. Errors in our own SDK are ours to fix, not the
|
|
4577
|
+
// user's to read.
|
|
4578
|
+
const abs = path.resolve(APP_DIR, rawFile!);
|
|
4579
|
+
const inApp = abs.startsWith(APP_DIR + path.sep) && !abs.includes(`${path.sep}node_modules${path.sep}`);
|
|
4580
|
+
if (!inApp) {
|
|
4581
|
+
suppressed += 1;
|
|
4582
|
+
last = null;
|
|
4583
|
+
continue;
|
|
4584
|
+
}
|
|
4585
|
+
total += 1;
|
|
4586
|
+
const err: TypecheckError = {
|
|
4587
|
+
file: path.relative(APP_DIR, abs),
|
|
4588
|
+
line: Number(ln),
|
|
4589
|
+
column: Number(col),
|
|
4590
|
+
code: code!,
|
|
4591
|
+
message: message!,
|
|
4592
|
+
};
|
|
4593
|
+
if (errors.length < TYPECHECK_ERROR_CAP) {
|
|
4594
|
+
errors.push(err);
|
|
4595
|
+
last = err;
|
|
4596
|
+
} else {
|
|
4597
|
+
last = null;
|
|
4598
|
+
}
|
|
4599
|
+
}
|
|
4600
|
+
return { errors, total, suppressed };
|
|
4601
|
+
}
|
|
4602
|
+
|
|
4603
|
+
async function runTypecheck(): Promise<TypecheckReport> {
|
|
4604
|
+
const started = Date.now();
|
|
4605
|
+
// The project's own typescript wins over the baked copy.
|
|
4606
|
+
const tsc = [
|
|
4607
|
+
path.join(APP_DIR, "node_modules", "typescript", "bin", "tsc"),
|
|
4608
|
+
path.join(TYPECHECK_DIR, "node_modules", "typescript", "bin", "tsc"),
|
|
4609
|
+
].find((p) => existsSync(p));
|
|
4610
|
+
if (!tsc) {
|
|
4611
|
+
return {
|
|
4612
|
+
status: "skipped",
|
|
4613
|
+
errors: [],
|
|
4614
|
+
totalErrors: 0,
|
|
4615
|
+
durationMs: 0,
|
|
4616
|
+
detail: "typescript is not installed (base image predates the baked typechecker)",
|
|
4617
|
+
};
|
|
4618
|
+
}
|
|
4619
|
+
let config = path.join(APP_DIR, "tsconfig.json");
|
|
4620
|
+
if (!existsSync(config)) {
|
|
4621
|
+
config = path.join(APP_DIR, ".spectest-tsconfig.json");
|
|
4622
|
+
await fs.writeFile(config, generatedTsconfig());
|
|
4623
|
+
}
|
|
4624
|
+
const res = await shx(process.execPath, [tsc, "--noEmit", "--pretty", "false", "-p", config], 120_000);
|
|
4625
|
+
const durationMs = Date.now() - started;
|
|
4626
|
+
if (res.code === 124) {
|
|
4627
|
+
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
4628
|
+
}
|
|
4629
|
+
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
4630
|
+
if (errors.length === 0) {
|
|
4631
|
+
// Exit 0 → clean. Non-zero with no *user-file* diagnostics is either
|
|
4632
|
+
// all-suppressed (still ok from the user's perspective) or a compiler
|
|
4633
|
+
// crash (config not found, OOM) — surface the latter.
|
|
4634
|
+
if (res.code !== 0 && suppressed === 0) {
|
|
4635
|
+
const tail = (res.stderr || res.stdout).trim().split("\n").slice(-5).join("\n");
|
|
4636
|
+
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: tail || `tsc exited ${res.code}` };
|
|
4637
|
+
}
|
|
4638
|
+
if (suppressed > 0) {
|
|
4639
|
+
console.warn(`[typecheck] ${suppressed} diagnostic(s) outside the project suppressed`);
|
|
4640
|
+
}
|
|
4641
|
+
return { status: "ok", errors: [], totalErrors: 0, durationMs };
|
|
4642
|
+
}
|
|
4643
|
+
return { status: "errors", errors, totalErrors: total, durationMs };
|
|
4644
|
+
}
|
|
4645
|
+
|
|
4334
4646
|
// ────────────────────────────────────────────────────────────────────────
|
|
4335
4647
|
// HTTP server
|
|
4336
4648
|
// ────────────────────────────────────────────────────────────────────────
|
|
@@ -4395,6 +4707,11 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
|
|
|
4395
4707
|
// resulting catalogue. Called after the warm snapshot (cold path) or
|
|
4396
4708
|
// against a freshly restored VM (warm path).
|
|
4397
4709
|
await loadTests();
|
|
4710
|
+
// Background typecheck of the freshly-uploaded app tree — both paths
|
|
4711
|
+
// (cold and warm) funnel through here after the upload, so the check
|
|
4712
|
+
// always sees the current code. Never on the critical path: the reply
|
|
4713
|
+
// doesn't wait, POST /typecheck collects.
|
|
4714
|
+
startTypecheck();
|
|
4398
4715
|
const l = requireLoaded();
|
|
4399
4716
|
jsonResponse(res, 200, {
|
|
4400
4717
|
environment: l.project.environment,
|
|
@@ -4404,6 +4721,15 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
|
|
|
4404
4721
|
return;
|
|
4405
4722
|
}
|
|
4406
4723
|
|
|
4724
|
+
if (method === "POST" && url === "/typecheck") {
|
|
4725
|
+
// Await the run kicked off at /load-tests (or start one on demand —
|
|
4726
|
+
// e.g. a legacy single-file project loaded before this daemon shipped
|
|
4727
|
+
// the kickoff, or a direct debug call).
|
|
4728
|
+
if (!TYPECHECK) startTypecheck();
|
|
4729
|
+
jsonResponse(res, 200, await TYPECHECK!);
|
|
4730
|
+
return;
|
|
4731
|
+
}
|
|
4732
|
+
|
|
4407
4733
|
if (method === "POST" && url === "/unload") {
|
|
4408
4734
|
loaded = null;
|
|
4409
4735
|
jsonResponse(res, 200, { unloaded: true });
|
|
@@ -4557,13 +4883,55 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
|
|
|
4557
4883
|
state.inFlightTest = exec;
|
|
4558
4884
|
try {
|
|
4559
4885
|
const result = await exec;
|
|
4560
|
-
|
|
4886
|
+
// Recordings go out-of-band: encode + park the bundle, reply with a
|
|
4887
|
+
// size ref only (see the REPLAY_BUNDLES comment). A bundle-encoding
|
|
4888
|
+
// failure drops the recordings (warn) rather than failing the case —
|
|
4889
|
+
// and never falls back to inlining, which is exactly the >16 MB
|
|
4890
|
+
// response this path exists to avoid.
|
|
4891
|
+
const { browserSessions, ...wire } = result;
|
|
4892
|
+
let replay: { bytes: number } | undefined;
|
|
4893
|
+
if (browserSessions.length > 0) {
|
|
4894
|
+
try {
|
|
4895
|
+
const gz = encodeReplayBundle(caseId, browserSessions);
|
|
4896
|
+
stashReplayBundle(caseId, gz);
|
|
4897
|
+
replay = { bytes: gz.length };
|
|
4898
|
+
} catch (err) {
|
|
4899
|
+
// eslint-disable-next-line no-console
|
|
4900
|
+
console.warn("[replay] bundle encode failed; dropping recordings:", err);
|
|
4901
|
+
}
|
|
4902
|
+
}
|
|
4903
|
+
jsonResponse(res, 200, { ...wire, replay });
|
|
4561
4904
|
} finally {
|
|
4562
4905
|
state.inFlightTest = null;
|
|
4563
4906
|
}
|
|
4564
4907
|
return;
|
|
4565
4908
|
}
|
|
4566
4909
|
|
|
4910
|
+
if (method === "POST" && url === "/replay-chunk") {
|
|
4911
|
+
// One chunk of a parked replay bundle (see REPLAY_BUNDLES). POST with
|
|
4912
|
+
// a JSON body — case ids are arbitrary user strings, and a body dodges
|
|
4913
|
+
// URL-encoding across both providers' daemon_http transports.
|
|
4914
|
+
const body = await readBody(req);
|
|
4915
|
+
let parsed: { caseId?: string; offset?: number; limit?: number };
|
|
4916
|
+
try {
|
|
4917
|
+
parsed = JSON.parse(body || "{}");
|
|
4918
|
+
} catch {
|
|
4919
|
+
jsonResponse(res, 400, { error: "invalid JSON body" });
|
|
4920
|
+
return;
|
|
4921
|
+
}
|
|
4922
|
+
if (!parsed.caseId) {
|
|
4923
|
+
jsonResponse(res, 400, { error: "caseId is required" });
|
|
4924
|
+
return;
|
|
4925
|
+
}
|
|
4926
|
+
const gz = REPLAY_BUNDLES.get(parsed.caseId);
|
|
4927
|
+
if (!gz) {
|
|
4928
|
+
jsonResponse(res, 404, { error: `no replay bundle for case: ${parsed.caseId}` });
|
|
4929
|
+
return;
|
|
4930
|
+
}
|
|
4931
|
+
jsonResponse(res, 200, replayChunk(gz, parsed.offset, parsed.limit));
|
|
4932
|
+
return;
|
|
4933
|
+
}
|
|
4934
|
+
|
|
4567
4935
|
jsonResponse(res, 404, { error: "not found" });
|
|
4568
4936
|
}
|
|
4569
4937
|
|
package/src/ids.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Stripe-style resource ids minted in-VM — format-compatible with the
|
|
2
|
+
// control plane's `crates/control-plane/src/ids.rs` (`generate`):
|
|
3
|
+
// `<prefix>_0<20 chars>` where the chars are Crockford base32 (lowercase,
|
|
4
|
+
// minus the ambiguous i/l/o/u) and the `0` after the underscore is the
|
|
5
|
+
// format-version digit. If the scheme changes there, change it here in
|
|
6
|
+
// lockstep (`env.rs::valid_artifact_id` checks this exact shape at the
|
|
7
|
+
// trust boundary).
|
|
8
|
+
//
|
|
9
|
+
// LANDMINE — why the entropy here is NOT just `crypto.getRandomValues`:
|
|
10
|
+
// Bun serves those from a userspace pool that is FROZEN into snapshots, so
|
|
11
|
+
// sibling forks of one snapshot draw identical bytes (verified 2026-07-14:
|
|
12
|
+
// two forks' first screenshot() minted the same id). We therefore hash
|
|
13
|
+
// together, per mint:
|
|
14
|
+
// - 16 bytes read straight off /dev/urandom (bypasses Bun's pool; the
|
|
15
|
+
// guest kernel CRNG reseeds across restores — see the VM-determinism
|
|
16
|
+
// notes — so forks diverge),
|
|
17
|
+
// - Date.now() (wall clock is re-synced per post-mortem fork start) and
|
|
18
|
+
// performance.now() (sub-ms), which de-collide even if the kernel pool
|
|
19
|
+
// hasn't diverged yet,
|
|
20
|
+
// - a process-local counter (multiple mints in one tick).
|
|
21
|
+
// This makes duplicates practically impossible, but the control plane
|
|
22
|
+
// still treats an id here as untrusted: shape-validated, and a duplicate
|
|
23
|
+
// insert is flagged per-artifact rather than clobbering anything.
|
|
24
|
+
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import { closeSync, openSync, readSync } from "node:fs";
|
|
27
|
+
|
|
28
|
+
/** Crockford base32, lowercased, minus `i`/`l`/`o`/`u` — mirrors ids.rs. */
|
|
29
|
+
const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
|
|
30
|
+
|
|
31
|
+
/** Random base32 chars after the version digit (100 bits of entropy). */
|
|
32
|
+
const RANDOM_LEN = 20;
|
|
33
|
+
|
|
34
|
+
let mintCounter = 0;
|
|
35
|
+
|
|
36
|
+
/** Mint a fresh id, e.g. `generateId("art")` → `art_0c3k9mpq…`. */
|
|
37
|
+
export function generateId(prefix: string): string {
|
|
38
|
+
const digest = createHash("sha256")
|
|
39
|
+
.update(urandom(16))
|
|
40
|
+
.update(String(Date.now()))
|
|
41
|
+
.update(String(performance.now()))
|
|
42
|
+
.update(String(mintCounter++))
|
|
43
|
+
.digest();
|
|
44
|
+
return `${prefix}_0${base32Crockford(digest.subarray(0, 16)).slice(0, RANDOM_LEN)}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Read `n` bytes directly from /dev/urandom (NOT Bun's cached pool). */
|
|
48
|
+
function urandom(n: number): Uint8Array {
|
|
49
|
+
const buf = new Uint8Array(n);
|
|
50
|
+
try {
|
|
51
|
+
const fd = openSync("/dev/urandom", "r");
|
|
52
|
+
try {
|
|
53
|
+
let off = 0;
|
|
54
|
+
while (off < n) {
|
|
55
|
+
const read = readSync(fd, buf, off, n - off, null);
|
|
56
|
+
if (read <= 0) break;
|
|
57
|
+
off += read;
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
closeSync(fd);
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// Non-Linux host (SDK types compiled locally) or exotic failure — the
|
|
64
|
+
// clock + counter inputs still make the hash unique in practice.
|
|
65
|
+
crypto.getRandomValues(buf);
|
|
66
|
+
}
|
|
67
|
+
return buf;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Encode bytes as Crockford base32 (no padding). 16 bytes → 26 chars. */
|
|
71
|
+
function base32Crockford(bytes: Uint8Array): string {
|
|
72
|
+
let out = "";
|
|
73
|
+
let acc = 0;
|
|
74
|
+
let bits = 0;
|
|
75
|
+
for (const b of bytes) {
|
|
76
|
+
acc = (acc << 8) | b;
|
|
77
|
+
bits += 8;
|
|
78
|
+
while (bits >= 5) {
|
|
79
|
+
bits -= 5;
|
|
80
|
+
out += ALPHABET[(acc >> bits) & 0x1f];
|
|
81
|
+
}
|
|
82
|
+
// Keep the accumulator within 32-bit safe range for the `<<` operator.
|
|
83
|
+
acc &= (1 << bits) - 1;
|
|
84
|
+
}
|
|
85
|
+
if (bits > 0) {
|
|
86
|
+
out += ALPHABET[(acc << (5 - bits)) & 0x1f];
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -44,12 +44,7 @@ export { SQL, type SqlClient, type SqlOptions } from "./sql.js";
|
|
|
44
44
|
export { RedisClient, type RedisClientLike, type RedisOptions } from "./redis.js";
|
|
45
45
|
export { S3Client, type S3ClientLike, type S3File, type S3ClientOptions } from "./s3.js";
|
|
46
46
|
|
|
47
|
-
export type {
|
|
48
|
-
Browser,
|
|
49
|
-
BrowserOptions,
|
|
50
|
-
ScreenshotFormat,
|
|
51
|
-
ScreenshotOptions,
|
|
52
|
-
} from "./browser.js";
|
|
47
|
+
export type { Browser, BrowserOptions } from "./browser.js";
|
|
53
48
|
|
|
54
49
|
import type { Browser, BrowserOptions } from "./browser.js";
|
|
55
50
|
|
|
@@ -126,14 +121,14 @@ export interface ServiceConfig {
|
|
|
126
121
|
* postgres's `docker-entrypoint.sh` initialization. Mutually
|
|
127
122
|
* exclusive with {@link command}.
|
|
128
123
|
*/
|
|
129
|
-
args?: string[];
|
|
124
|
+
args?: readonly string[];
|
|
130
125
|
env?: Record<string, string>;
|
|
131
126
|
/**
|
|
132
127
|
* Ports the container listens on. Advisory only — surfaced in
|
|
133
128
|
* `spectest list` output. Peer services reach each other by `<service>:<port>`
|
|
134
129
|
* without any port declaration.
|
|
135
130
|
*/
|
|
136
|
-
ports?: number[];
|
|
131
|
+
ports?: readonly number[];
|
|
137
132
|
/**
|
|
138
133
|
* Extra DNS names this service answers to inside the environment.
|
|
139
134
|
* Each entry must be a fully-qualified, multi-label hostname (e.g.
|
|
@@ -149,7 +144,7 @@ export interface ServiceConfig {
|
|
|
149
144
|
* answers to `<name>.internal` in addition to its bare `<name>`, and
|
|
150
145
|
* user-supplied hostnames may not end in `.internal`.
|
|
151
146
|
*/
|
|
152
|
-
hostnames?: string[];
|
|
147
|
+
hostnames?: readonly string[];
|
|
153
148
|
/**
|
|
154
149
|
* Expose this service over HTTPS via a TLS-terminating reverse proxy
|
|
155
150
|
* hosted in the spectest-daemon. Each entry maps a fully-qualified
|
|
@@ -166,9 +161,9 @@ export interface ServiceConfig {
|
|
|
166
161
|
* multi-label, lowercase, no `.internal` suffix, no collision with
|
|
167
162
|
* services, other service TLS hostnames, or fakes.
|
|
168
163
|
*/
|
|
169
|
-
tls?: ServiceTls[];
|
|
164
|
+
tls?: readonly ServiceTls[];
|
|
170
165
|
/** Bind-mounted volumes for state that survives snapshot/fork. */
|
|
171
|
-
volumes?: VolumeMount[];
|
|
166
|
+
volumes?: readonly VolumeMount[];
|
|
172
167
|
/**
|
|
173
168
|
* Files seeded into the container's filesystem **before it starts**.
|
|
174
169
|
* Each entry's `content` is written to a VM-host staging path and
|
|
@@ -178,9 +173,9 @@ export interface ServiceConfig {
|
|
|
178
173
|
* `/etc/rancher/k3s/registries.yaml`, which must exist before
|
|
179
174
|
* `k3s server` starts.
|
|
180
175
|
*/
|
|
181
|
-
files?: FileMount[];
|
|
176
|
+
files?: readonly FileMount[];
|
|
182
177
|
/** Other services (keys in the services map) that must be ready first. */
|
|
183
|
-
dependsOn?: string[];
|
|
178
|
+
dependsOn?: readonly string[];
|
|
184
179
|
readyCheck?: ReadyCheck;
|
|
185
180
|
/** Container workdir override. */
|
|
186
181
|
workdir?: string;
|
|
@@ -196,7 +191,7 @@ export interface ServiceConfig {
|
|
|
196
191
|
* non-persistent. Snapshots/forks preserve tmpfs contents along with
|
|
197
192
|
* the rest of process memory.
|
|
198
193
|
*/
|
|
199
|
-
tmpfs?: string[];
|
|
194
|
+
tmpfs?: readonly string[];
|
|
200
195
|
/**
|
|
201
196
|
* Cgroup namespace mode — `"host"` or `"private"`. Omit for docker's
|
|
202
197
|
* default. k3s needs `"host"` so its embedded containerd can manage
|
|
@@ -690,7 +685,7 @@ export type ServiceImage =
|
|
|
690
685
|
*/
|
|
691
686
|
content: string;
|
|
692
687
|
/** Extra glob patterns to exclude from the build context. */
|
|
693
|
-
exclude?: string[];
|
|
688
|
+
exclude?: readonly string[];
|
|
694
689
|
};
|
|
695
690
|
|
|
696
691
|
export interface VolumeMount {
|
|
@@ -1284,7 +1279,7 @@ export interface FakeDefinition<
|
|
|
1284
1279
|
* lowercase, no `.internal` suffix, no collisions with services or
|
|
1285
1280
|
* other fakes.
|
|
1286
1281
|
*/
|
|
1287
|
-
hostnames: string[];
|
|
1282
|
+
hostnames: readonly string[];
|
|
1288
1283
|
/** TCP port the fake listens on. Default `80`. */
|
|
1289
1284
|
port?: number;
|
|
1290
1285
|
/**
|
|
@@ -1329,7 +1324,7 @@ export interface FakeDefinition<
|
|
|
1329
1324
|
* files, the config hash, or a cassette. Not part of the authoring
|
|
1330
1325
|
* surface; `JSON.stringify` ignores it (fakes never serialize to config).
|
|
1331
1326
|
*/
|
|
1332
|
-
secretRefs?: string[];
|
|
1327
|
+
secretRefs?: readonly string[];
|
|
1333
1328
|
}
|
|
1334
1329
|
|
|
1335
1330
|
/**
|
package/src/mobile.ts
CHANGED
|
@@ -23,7 +23,6 @@ import type {
|
|
|
23
23
|
BrowserSessionRecorder,
|
|
24
24
|
MobileBackend,
|
|
25
25
|
SafeAreaInsets,
|
|
26
|
-
ScreenshotOptions,
|
|
27
26
|
} from "./browser.js";
|
|
28
27
|
import type { Locator, Page } from "playwright-core";
|
|
29
28
|
import type { Wrapped } from "./inspect.js";
|
|
@@ -312,8 +311,13 @@ export interface Mobile {
|
|
|
312
311
|
expression: string,
|
|
313
312
|
options?: { timeoutMs?: number; intervalMs?: number },
|
|
314
313
|
): Promise<Wrapped<T>>;
|
|
315
|
-
/**
|
|
316
|
-
|
|
314
|
+
/**
|
|
315
|
+
* Capture a PNG screenshot of the viewport and upload it as a
|
|
316
|
+
* downloadable artifact. Resolves to the artifact's `art_…` id
|
|
317
|
+
* (`spectest artifact download <id>`). Eval-only for now — throws with a
|
|
318
|
+
* clear message during test runs.
|
|
319
|
+
*/
|
|
320
|
+
screenshot(): Promise<string>;
|
|
317
321
|
/** Close the session. Idempotent; drains pending rrweb events. */
|
|
318
322
|
close(): Promise<void>;
|
|
319
323
|
}
|
|
@@ -386,8 +390,8 @@ function wrapMobile(backend: MobileBackend): Mobile {
|
|
|
386
390
|
waitFor(description, expression, options) {
|
|
387
391
|
return backend.waitFor(description, expression, options);
|
|
388
392
|
},
|
|
389
|
-
screenshot(
|
|
390
|
-
return backend.screenshot(
|
|
393
|
+
screenshot() {
|
|
394
|
+
return backend.screenshot();
|
|
391
395
|
},
|
|
392
396
|
close() {
|
|
393
397
|
return backend.close();
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Replay-bundle encoding: a case's rrweb sessions → one gzipped JSON blob
|
|
2
|
+
// with large inlined assets deduplicated.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: recordings must NOT ride the `/run` response. The
|
|
5
|
+
// vm-agent proxies daemon HTTP through a 16 MB response cap, and a
|
|
6
|
+
// browser-heavy case blows straight past it — rrweb's `inlineImages`
|
|
7
|
+
// re-inlines every image into every full snapshot, and animation-heavy
|
|
8
|
+
// apps (e.g. Reanimated's per-frame inline-style writes) emit mutation
|
|
9
|
+
// streams in the tens of MB. The daemon parks the encoded bundle in
|
|
10
|
+
// memory instead and the control plane pulls it in chunks via
|
|
11
|
+
// `POST /replay-chunk` (see daemon.ts), each chunk sized under the cap.
|
|
12
|
+
//
|
|
13
|
+
// Inside the bundle, every large base64 `data:` URI is extracted into an
|
|
14
|
+
// `assets` table keyed by content hash and replaced with a
|
|
15
|
+
// `data:x-spectest-asset/<hash>` token — the N re-inlined copies of one
|
|
16
|
+
// image collapse to one asset plus N short tokens. The dashboard's case
|
|
17
|
+
// viewer substitutes the real URI back into the raw JSON text before
|
|
18
|
+
// parsing (mirror of this file's token format lives in
|
|
19
|
+
// `web/case_viewer.rs::rehydrateAssets`). Only base64 URIs are extracted:
|
|
20
|
+
// their charset needs no JSON escaping, so plain text substitution in
|
|
21
|
+
// either direction cannot corrupt the surrounding JSON document.
|
|
22
|
+
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { gzipSync } from "node:zlib";
|
|
25
|
+
|
|
26
|
+
/** Minimum data: URI payload length (chars) worth extracting. Below this
|
|
27
|
+
* the token + asset-table overhead rivals the URI itself. */
|
|
28
|
+
const ASSET_MIN_BASE64_CHARS = 1024;
|
|
29
|
+
|
|
30
|
+
/** Base64 `data:` URIs, ≥1 KiB of payload. Mediatype + parameters are
|
|
31
|
+
* restricted to charsets that never need JSON escaping — a match taken
|
|
32
|
+
* from serialized JSON text is therefore byte-identical to the logical
|
|
33
|
+
* string value it sits inside. */
|
|
34
|
+
const ASSET_RX = new RegExp(
|
|
35
|
+
"data:[A-Za-z0-9.+-]+/[A-Za-z0-9.+-]+(?:;[A-Za-z0-9.=+-]+)*;base64," +
|
|
36
|
+
`[A-Za-z0-9+/=]{${ASSET_MIN_BASE64_CHARS},}`,
|
|
37
|
+
"g",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/** Token an extracted asset is replaced with. Still a syntactically valid
|
|
41
|
+
* data: URI so anything that merely carries it along stays well-formed;
|
|
42
|
+
* it never renders (the viewer rehydrates before the events reach rrweb). */
|
|
43
|
+
export function assetToken(hash: string): string {
|
|
44
|
+
return `data:x-spectest-asset/${hash}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Serialize `sessions` and pull every large base64 data: URI out into a
|
|
49
|
+
* content-addressed asset table. Returns the deduplicated sessions as a
|
|
50
|
+
* JSON *string* (already serialized — splice it into the bundle document
|
|
51
|
+
* verbatim) plus the asset table.
|
|
52
|
+
*/
|
|
53
|
+
export function extractReplayAssets(sessions: unknown): {
|
|
54
|
+
sessionsJson: string;
|
|
55
|
+
assets: Record<string, string>;
|
|
56
|
+
} {
|
|
57
|
+
const assets: Record<string, string> = {};
|
|
58
|
+
const raw = JSON.stringify(sessions);
|
|
59
|
+
const sessionsJson = raw.replace(ASSET_RX, (uri) => {
|
|
60
|
+
// 16 hex chars (64 bits) of SHA-256: collision-safe at replay-asset
|
|
61
|
+
// scale (dozens of assets per case), and short enough that a token is
|
|
62
|
+
// negligible next to the URI it replaces.
|
|
63
|
+
const hash = createHash("sha256").update(uri).digest("hex").slice(0, 16);
|
|
64
|
+
assets[hash] = uri;
|
|
65
|
+
return assetToken(hash);
|
|
66
|
+
});
|
|
67
|
+
return { sessionsJson, assets };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Encode a case's sessions as the gzipped replay-bundle document the
|
|
72
|
+
* control plane archives to S3 verbatim:
|
|
73
|
+
* `{ caseId, sessions: [...], assets: { <hash>: <dataUri> } }` — the
|
|
74
|
+
* shape of `storage.rs::CaseReplayBundle`.
|
|
75
|
+
*/
|
|
76
|
+
export function encodeReplayBundle(caseId: string, sessions: unknown): Buffer {
|
|
77
|
+
const { sessionsJson, assets } = extractReplayAssets(sessions);
|
|
78
|
+
const doc = `{"caseId":${JSON.stringify(caseId)},"sessions":${sessionsJson},"assets":${JSON.stringify(assets)}}`;
|
|
79
|
+
return gzipSync(Buffer.from(doc));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Per-chunk raw-byte ceiling for `/replay-chunk` replies. Base64 inflates
|
|
83
|
+
* 4/3× and the JSON reply must clear the vm-agent's 16 MB response cap
|
|
84
|
+
* with headroom. The control plane's `fetch_replay_bundle` requests
|
|
85
|
+
* exactly this much per round trip. */
|
|
86
|
+
export const REPLAY_CHUNK_MAX_BYTES = 6 * 1024 * 1024;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One `/replay-chunk` reply: base64 of `gz[offset, offset+limit)`, with
|
|
90
|
+
* offset/limit clamped to sane values (never more than
|
|
91
|
+
* `REPLAY_CHUNK_MAX_BYTES` raw bytes). An out-of-range offset yields an
|
|
92
|
+
* empty `b64`, which the control plane treats as an error — it only ever
|
|
93
|
+
* asks for offsets below the advertised total.
|
|
94
|
+
*/
|
|
95
|
+
export function replayChunk(
|
|
96
|
+
gz: Buffer,
|
|
97
|
+
offsetIn: unknown,
|
|
98
|
+
limitIn: unknown,
|
|
99
|
+
): { total: number; offset: number; b64: string } {
|
|
100
|
+
const offset = Math.max(0, Math.floor(Number(offsetIn) || 0));
|
|
101
|
+
const wanted = Math.floor(Number(limitIn) || REPLAY_CHUNK_MAX_BYTES);
|
|
102
|
+
const limit = Math.min(Math.max(1, wanted), REPLAY_CHUNK_MAX_BYTES);
|
|
103
|
+
return {
|
|
104
|
+
total: gz.length,
|
|
105
|
+
offset,
|
|
106
|
+
b64: gz.subarray(offset, offset + limit).toString("base64"),
|
|
107
|
+
};
|
|
108
|
+
}
|