@specific.dev/spectest 0.20.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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
- .filter((a) => a.type === "A")
524
- .map((a) => a.data as string)
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 docker(["logs", "--timestamps", svc.name], 30_000);
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
  );
@@ -4400,6 +4464,185 @@ async function evalCode(
4400
4464
  };
4401
4465
  }
4402
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
+
4403
4646
  // ────────────────────────────────────────────────────────────────────────
4404
4647
  // HTTP server
4405
4648
  // ────────────────────────────────────────────────────────────────────────
@@ -4464,6 +4707,11 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
4464
4707
  // resulting catalogue. Called after the warm snapshot (cold path) or
4465
4708
  // against a freshly restored VM (warm path).
4466
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();
4467
4715
  const l = requireLoaded();
4468
4716
  jsonResponse(res, 200, {
4469
4717
  environment: l.project.environment,
@@ -4473,6 +4721,15 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
4473
4721
  return;
4474
4722
  }
4475
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
+
4476
4733
  if (method === "POST" && url === "/unload") {
4477
4734
  loaded = null;
4478
4735
  jsonResponse(res, 200, { unloaded: true });
@@ -4626,13 +4883,55 @@ async function handle(req: http.IncomingMessage, res: http.ServerResponse, state
4626
4883
  state.inFlightTest = exec;
4627
4884
  try {
4628
4885
  const result = await exec;
4629
- jsonResponse(res, 200, result);
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 });
4630
4904
  } finally {
4631
4905
  state.inFlightTest = null;
4632
4906
  }
4633
4907
  return;
4634
4908
  }
4635
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
+
4636
4935
  jsonResponse(res, 404, { error: "not found" });
4637
4936
  }
4638
4937
 
package/src/index.ts CHANGED
@@ -121,14 +121,14 @@ export interface ServiceConfig {
121
121
  * postgres's `docker-entrypoint.sh` initialization. Mutually
122
122
  * exclusive with {@link command}.
123
123
  */
124
- args?: string[];
124
+ args?: readonly string[];
125
125
  env?: Record<string, string>;
126
126
  /**
127
127
  * Ports the container listens on. Advisory only — surfaced in
128
128
  * `spectest list` output. Peer services reach each other by `<service>:<port>`
129
129
  * without any port declaration.
130
130
  */
131
- ports?: number[];
131
+ ports?: readonly number[];
132
132
  /**
133
133
  * Extra DNS names this service answers to inside the environment.
134
134
  * Each entry must be a fully-qualified, multi-label hostname (e.g.
@@ -144,7 +144,7 @@ export interface ServiceConfig {
144
144
  * answers to `<name>.internal` in addition to its bare `<name>`, and
145
145
  * user-supplied hostnames may not end in `.internal`.
146
146
  */
147
- hostnames?: string[];
147
+ hostnames?: readonly string[];
148
148
  /**
149
149
  * Expose this service over HTTPS via a TLS-terminating reverse proxy
150
150
  * hosted in the spectest-daemon. Each entry maps a fully-qualified
@@ -161,9 +161,9 @@ export interface ServiceConfig {
161
161
  * multi-label, lowercase, no `.internal` suffix, no collision with
162
162
  * services, other service TLS hostnames, or fakes.
163
163
  */
164
- tls?: ServiceTls[];
164
+ tls?: readonly ServiceTls[];
165
165
  /** Bind-mounted volumes for state that survives snapshot/fork. */
166
- volumes?: VolumeMount[];
166
+ volumes?: readonly VolumeMount[];
167
167
  /**
168
168
  * Files seeded into the container's filesystem **before it starts**.
169
169
  * Each entry's `content` is written to a VM-host staging path and
@@ -173,9 +173,9 @@ export interface ServiceConfig {
173
173
  * `/etc/rancher/k3s/registries.yaml`, which must exist before
174
174
  * `k3s server` starts.
175
175
  */
176
- files?: FileMount[];
176
+ files?: readonly FileMount[];
177
177
  /** Other services (keys in the services map) that must be ready first. */
178
- dependsOn?: string[];
178
+ dependsOn?: readonly string[];
179
179
  readyCheck?: ReadyCheck;
180
180
  /** Container workdir override. */
181
181
  workdir?: string;
@@ -191,7 +191,7 @@ export interface ServiceConfig {
191
191
  * non-persistent. Snapshots/forks preserve tmpfs contents along with
192
192
  * the rest of process memory.
193
193
  */
194
- tmpfs?: string[];
194
+ tmpfs?: readonly string[];
195
195
  /**
196
196
  * Cgroup namespace mode — `"host"` or `"private"`. Omit for docker's
197
197
  * default. k3s needs `"host"` so its embedded containerd can manage
@@ -685,7 +685,7 @@ export type ServiceImage =
685
685
  */
686
686
  content: string;
687
687
  /** Extra glob patterns to exclude from the build context. */
688
- exclude?: string[];
688
+ exclude?: readonly string[];
689
689
  };
690
690
 
691
691
  export interface VolumeMount {
@@ -1279,7 +1279,7 @@ export interface FakeDefinition<
1279
1279
  * lowercase, no `.internal` suffix, no collisions with services or
1280
1280
  * other fakes.
1281
1281
  */
1282
- hostnames: string[];
1282
+ hostnames: readonly string[];
1283
1283
  /** TCP port the fake listens on. Default `80`. */
1284
1284
  port?: number;
1285
1285
  /**
@@ -1324,7 +1324,7 @@ export interface FakeDefinition<
1324
1324
  * files, the config hash, or a cassette. Not part of the authoring
1325
1325
  * surface; `JSON.stringify` ignores it (fakes never serialize to config).
1326
1326
  */
1327
- secretRefs?: string[];
1327
+ secretRefs?: readonly string[];
1328
1328
  }
1329
1329
 
1330
1330
  /**
@@ -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
+ }