@specific.dev/spectest 0.20.0 → 0.22.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 +301 -214
- package/src/components/replayFake.ts +4 -5
- package/src/daemon.ts +307 -8
- package/src/index.ts +252 -20
- package/src/locator.ts +576 -0
- package/src/mobile.ts +34 -359
- package/src/recorder.ts +11 -14
- package/src/replay-bundle.ts +108 -0
|
@@ -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
|
);
|
|
@@ -3882,7 +3946,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3882
3946
|
// An attached session starts mid-page (no navigate event will fire) —
|
|
3883
3947
|
// stamp the inherited URL so the dashboard can still label the replay.
|
|
3884
3948
|
if (attached && session.record.initialUrl === undefined) {
|
|
3885
|
-
session.record.initialUrl = browser.url;
|
|
3949
|
+
session.record.initialUrl = browser.url();
|
|
3886
3950
|
}
|
|
3887
3951
|
browserDetaches.push(async () => {
|
|
3888
3952
|
await detach();
|
|
@@ -3912,7 +3976,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
3912
3976
|
});
|
|
3913
3977
|
if (safeAreaInsets) session.record.safeAreaInsets = safeAreaInsets;
|
|
3914
3978
|
if (attached && session.record.initialUrl === undefined) {
|
|
3915
|
-
session.record.initialUrl = mobile.url;
|
|
3979
|
+
session.record.initialUrl = mobile.url();
|
|
3916
3980
|
}
|
|
3917
3981
|
browserDetaches.push(async () => {
|
|
3918
3982
|
await detach();
|
|
@@ -4204,7 +4268,7 @@ async function evalCode(
|
|
|
4204
4268
|
session.record.safeAreaInsets = browser.safeAreaInsets;
|
|
4205
4269
|
}
|
|
4206
4270
|
if (attached && session.record.initialUrl === undefined) {
|
|
4207
|
-
session.record.initialUrl = browser.url;
|
|
4271
|
+
session.record.initialUrl = browser.url();
|
|
4208
4272
|
}
|
|
4209
4273
|
browserDetaches.push(async () => {
|
|
4210
4274
|
await detach();
|
|
@@ -4234,7 +4298,7 @@ async function evalCode(
|
|
|
4234
4298
|
});
|
|
4235
4299
|
if (safeAreaInsets) session.record.safeAreaInsets = safeAreaInsets;
|
|
4236
4300
|
if (attached && session.record.initialUrl === undefined) {
|
|
4237
|
-
session.record.initialUrl = mobile.url;
|
|
4301
|
+
session.record.initialUrl = mobile.url();
|
|
4238
4302
|
}
|
|
4239
4303
|
browserDetaches.push(async () => {
|
|
4240
4304
|
await detach();
|
|
@@ -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
|
-
|
|
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
|
|