@specific.dev/spectest 0.121.0 → 0.122.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.
@@ -0,0 +1,261 @@
1
+ import { expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, readdir, rm } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { supabase } from "./supabase.js";
6
+ import { SUPABASE_COVERAGE_COLLECTOR, supabaseFunctionsCoverage } from "./supabase-coverage.js";
7
+ import { isWellFormedReport } from "../harness/coverage.js";
8
+ import type { ServiceDefinition } from "../index.js";
9
+
10
+ type Report = { name: string; content: string };
11
+ const runtime = process.env.SPECTEST_TEST_EDGE_RUNTIME;
12
+
13
+ // Use Supabase's pinned v1.74.0 release executable (and its bundled libraries),
14
+ // not stock Deno. No database, Docker daemon, or production endpoint is needed.
15
+ // SPECTEST_TEST_EDGE_RUNTIME=/path/bin/edge-runtime bun test src/components/supabase-coverage.test.ts
16
+ for (const layout of ["shared", "per-function", "workspace"] as const) {
17
+ const mode = layout === "shared" ? "shared" : "per-function";
18
+ test.skipIf(!runtime)(`real edge runtime: ${layout} source identity, setup and capture deltas`, async () => {
19
+ // /tmp is a private in-memory filesystem inside Edge Runtime. Keep sources
20
+ // on disk and generated helpers on a different root to exercise relocation
21
+ // exactly as production's /repo and /home/deno do.
22
+ const root = await mkdtemp(path.join(homedir(), ".spectest-edge-test-"));
23
+ const generated = await mkdtemp("/dev/shm/spectest-edge-test-");
24
+ const repository = path.join(root, "repo");
25
+ const functions = path.join(repository, "custom/backend/functions");
26
+ const oldWorkspace = process.env.SPECTEST_WORKSPACE;
27
+ let child: ReturnType<typeof Bun.spawn> | undefined;
28
+ let logs: Promise<string> | undefined;
29
+ // Reserve random ports; the executable starts immediately after release.
30
+ const reserve = () => Bun.serve({ port: 0, fetch: () => new Response() });
31
+ const http = reserve(), inspector = reserve();
32
+ const httpPort = http.port!, inspectorPort = inspector.port!;
33
+ http.stop(true); inspector.stop(true);
34
+ const base = `http://127.0.0.1:${httpPort}`;
35
+ try {
36
+ await mkdir(path.join(functions, "alpha"), { recursive: true });
37
+ await mkdir(path.join(functions, "beta"), { recursive: true });
38
+ await mkdir(path.join(repository, "packages/shared"), { recursive: true });
39
+ await writeFile(path.join(repository, "packages/shared/logic.ts"), [
40
+ 'export function first(): string {', ' return "first ✓🙂";', '}',
41
+ 'export function second(): string {', ' return "second";', '}',
42
+ 'export function initialize(): string { return "initialized"; }',
43
+ ].join("\n"));
44
+ await writeFile(path.join(functions, "alpha/index.ts"), [
45
+ `import { first, second, initialize } from "${mode === "per-function" ? "shared-logic" : "../../../../packages/shared/logic.ts"}";`,
46
+ 'initialize();',
47
+ 'Deno.serve(req => new Response(new URL(req.url).pathname.endsWith("/first") ? first() : second()));',
48
+ ].join("\n"));
49
+ await writeFile(path.join(functions, "beta/index.ts"), 'export default { fetch() { return new Response("beta"); } };\n');
50
+ if (mode === "per-function") {
51
+ const imports = { "shared-logic": "../../../../packages/shared/logic.ts" };
52
+ if (layout === "workspace") {
53
+ await writeFile(path.join(repository, "deno.json"), JSON.stringify({
54
+ workspace: ["./custom/backend/functions/alpha", "./packages/shared"],
55
+ }));
56
+ await writeFile(path.join(repository, "packages/shared/deno.json"), JSON.stringify({
57
+ name: "@fixture/shared", version: "1.0.0", exports: "./logic.ts",
58
+ }));
59
+ await writeFile(path.join(functions, "alpha/deno.json"), JSON.stringify({ imports }));
60
+ } else {
61
+ await writeFile(path.join(functions, "alpha/deno.json"), '{"importMap":"./import_map.json"}');
62
+ await writeFile(path.join(functions, "alpha/import_map.json"), JSON.stringify({ imports }));
63
+ }
64
+ // More workers than runtime threads: coverage gates must keep the
65
+ // event loop live while all inspectors attach concurrently.
66
+ for (let i = 0; i < 18; i++) {
67
+ const dir = path.join(functions, `extra${i}`);
68
+ await mkdir(dir);
69
+ await writeFile(path.join(dir, "index.ts"), 'Deno.serve(() => new Response("extra"));\n');
70
+ }
71
+ }
72
+ process.env.SPECTEST_WORKSPACE = repository;
73
+ const parts = supabase({ name: "custom-stack", dir: "custom/backend" }).group.services({
74
+ name: "custom-stack", key: part => `custom-stack-${part}`,
75
+ });
76
+ const service = parts.functions as ServiceDefinition;
77
+ expect(service.coverage).toBeDefined();
78
+ const replace = (text: string) => text.replaceAll("/home/deno", generated)
79
+ .replaceAll("/repo", repository).replaceAll("127.0.0.1:9229", `127.0.0.1:${inspectorPort}`);
80
+ for (const file of service.files!) {
81
+ const target = replace(file.path);
82
+ await mkdir(path.dirname(target), { recursive: true });
83
+ await writeFile(target, replace(file.content));
84
+ }
85
+ child = Bun.spawn([runtime!, "start", "--main-service", path.join(generated, "main"),
86
+ "--port", String(httpPort), `--inspect=127.0.0.1:${inspectorPort}`], {
87
+ env: { ...process.env, FUNCTIONS_DIR: functions, VERIFY_JWT: "false", EDGE_RUNTIME_WORKER_POOL_SIZE: "1" }, stdout: "ignore", stderr: "pipe",
88
+ });
89
+ logs = new Response(child.stderr).text();
90
+ const request = async (route: string, method = "GET") => {
91
+ const response = await fetch(base + route, { method, signal: AbortSignal.timeout(60_000) });
92
+ const text = await response.text();
93
+ if (!response.ok) throw new Error(`${route}: ${response.status}: ${text}`);
94
+ return text;
95
+ };
96
+ const deadline = Date.now() + 20_000;
97
+ while (true) {
98
+ try { await request("/_spectest/health"); break; } catch (error) {
99
+ if (Date.now() > deadline || child.exitCode !== null) throw error;
100
+ await Bun.sleep(30);
101
+ }
102
+ }
103
+ const warmed = JSON.parse(await request("/_spectest/warm"));
104
+ expect(warmed.mode).toBe(mode);
105
+ expect(warmed.failed).toEqual({});
106
+ const records = new Map<string, any>();
107
+ const captures: Report[][] = [];
108
+ async function capture() {
109
+ const { reports } = JSON.parse(await request("/_spectest/coverage", "POST")) as { reports: Report[] };
110
+ captures.push(reports);
111
+ for (const report of reports) {
112
+ expect(isWellFormedReport(report.content, report.name)).toBe(true);
113
+ const doc = JSON.parse(report.content);
114
+ if (doc.spectestScript) {
115
+ records.set(path.basename(report.name, ".json"), doc.spectestScript);
116
+ expect(report.content).not.toContain("sourcesContent");
117
+ expect(doc.spectestScript.url.startsWith(`file://${repository}/`)).toBe(true);
118
+ }
119
+ }
120
+ const dump = JSON.parse(reports.find(r => /^edge-\d+\.json$/.test(r.name))!.content);
121
+ const names = new Set<string>();
122
+ for (const script of dump.result) {
123
+ const record = records.get(script.spectestScript);
124
+ expect(record).toBeDefined();
125
+ for (const f of script.functions) {
126
+ const definition = record.functions.find((d: any) => d[1] === f[0] && d[2] === f[0] + f[1]);
127
+ expect(definition).toBeDefined();
128
+ if (f[2] > 0) names.add(definition[0]);
129
+ }
130
+ }
131
+ return { names, dump, reports };
132
+ }
133
+ const setup = await capture();
134
+ expect(setup.dump.result.length).toBeGreaterThan(0);
135
+ expect(setup.names.has("initialize")).toBe(true);
136
+ expect(setup.names.has("first")).toBe(false);
137
+ expect(setup.names.has("second")).toBe(false);
138
+ expect((await capture()).dump.result).toEqual([]);
139
+ expect(await request("/alpha/first")).toBe("first ✓🙂");
140
+ const first = await capture();
141
+ expect(first.names.has("first")).toBe(true);
142
+ expect(first.names.has("second")).toBe(false);
143
+ expect(first.reports.every(r => !r.name.startsWith("edge-scripts/"))).toBe(true);
144
+ expect(await request("/alpha/second")).toBe("second");
145
+ const second = await capture();
146
+ expect(second.names.has("first")).toBe(false);
147
+ expect(second.names.has("second")).toBe(true);
148
+ expect(await request("/beta")).toBe("beta");
149
+ expect((await capture()).dump.result.some((s: any) => s.url.endsWith("/beta/index.ts"))).toBe(true);
150
+ expect((await capture()).dump.result).toEqual([]);
151
+ // Optional handoff to the control-plane decoder regression below.
152
+ if (process.env.SPECTEST_EDGE_COVERAGE_FIXTURE) {
153
+ const serialized = JSON.stringify(captures).replaceAll(repository, "/repo");
154
+ await writeFile(process.env.SPECTEST_EDGE_COVERAGE_FIXTURE + "." + layout + ".json", serialized);
155
+ }
156
+ // Ensure the fallback actually created independent workers.
157
+ const targets = await fetch(`http://127.0.0.1:${inspectorPort}/json/list`).then(r => r.json()) as any[];
158
+ expect(targets.length).toBe(mode === "shared" ? 1 : 20);
159
+ expect((await readdir(path.join(functions, "alpha"))).some(name => name.startsWith(".spectest"))).toBe(false);
160
+ } finally {
161
+ if (oldWorkspace === undefined) delete process.env.SPECTEST_WORKSPACE;
162
+ else process.env.SPECTEST_WORKSPACE = oldWorkspace;
163
+ if (child) { child.kill("SIGKILL"); await child.exited; }
164
+ const output = await logs;
165
+ if (output?.includes("coverage") || output?.includes("error")) console.error(output);
166
+ await rm(root, { recursive: true, force: true });
167
+ await rm(generated, { recursive: true, force: true });
168
+ }
169
+ }, 150_000);
170
+ }
171
+
172
+ test("collector rejects missing workers and latches capture gaps", async () => {
173
+ // Exercise failure behavior without requiring an executable. Stop its timer;
174
+ // the protocol itself is exercised by the real-runtime tests above.
175
+ const { EdgeCoverage } = await import(`data:text/javascript;base64,${Buffer.from(SUPABASE_COVERAGE_COLLECTOR).toString("base64")}`);
176
+ const collector = new EdgeCoverage();
177
+ clearInterval(collector.timer);
178
+ collector.ready = async () => {};
179
+ collector.sessions.set("lost", { ready: Promise.resolve(), post: async () => { throw new Error("lost isolate"); } });
180
+ await expect(collector.capture()).rejects.toThrow("lost isolate");
181
+ collector.sessions.clear();
182
+ await expect(collector.capture()).rejects.toThrow("lost isolate");
183
+ });
184
+
185
+ test("collector overlaps isolate takes and descriptors, then merges deterministically", async () => {
186
+ const { EdgeCoverage } = await import(`data:text/javascript;base64,${Buffer.from(SUPABASE_COVERAGE_COLLECTOR).toString("base64")}`);
187
+ const collector = new EdgeCoverage();
188
+ clearInterval(collector.timer);
189
+ collector.ready = async () => {};
190
+ const takes = [Promise.withResolvers<any>(), Promise.withResolvers<any>()];
191
+ const descriptions = Array.from({ length: 4 }, () => Promise.withResolvers<any>());
192
+ const allTaking = Promise.withResolvers<void>();
193
+ const allDescribing = Promise.withResolvers<void>();
194
+ let taking = 0, describing = 0;
195
+ for (let i = 0; i < 2; i++) collector.sessions.set(String(i), {
196
+ base: "/", prefix: "/", entry: "/generated/entry.mjs",
197
+ post: async (method: string) => {
198
+ expect(method).toBe("Profiler.takePreciseCoverage");
199
+ if (++taking === 2) allTaking.resolve();
200
+ return takes[i]!.promise;
201
+ },
202
+ });
203
+ collector.describe = async (_session: unknown, script: any) => {
204
+ if (++describing === 4) allDescribing.resolve();
205
+ return descriptions[Number(script.scriptId)]!.promise;
206
+ };
207
+ const script = (i: number) => ({ scriptId: String(i), url: `file:///repo/${i % 2}.ts`,
208
+ functions: [{ functionName: "handler", ranges: [{ startOffset: 0, endOffset: 20, count: 1 }] }] });
209
+ const pending = collector.capture();
210
+ await allTaking.promise; // neither isolate needs the other's response to start
211
+ takes[1]!.resolve({ result: [script(2), script(3)] });
212
+ takes[0]!.resolve({ result: [script(0), script(1)] });
213
+ await allDescribing.promise; // all scripts overlap, including within one isolate
214
+ for (const i of [3, 2, 1, 0]) descriptions[i]!.resolve({ id: `record-${i % 2}`, content: `descriptor-${i % 2}` });
215
+ const { reports } = await pending;
216
+ expect(reports.map((r: Report) => r.name)).toEqual(["edge-scripts/record-0.json", "edge-scripts/record-1.json", "edge-1.json"]);
217
+ const dump = JSON.parse(reports[2].content);
218
+ expect(dump.result.map((r: any) => r.url)).toEqual(["file:///repo/0.ts", "file:///repo/1.ts", "file:///repo/0.ts", "file:///repo/1.ts"]);
219
+ });
220
+
221
+ test("failed parallel captures drain pending takes and permanently reject later captures", async () => {
222
+ const { EdgeCoverage } = await import(`data:text/javascript;base64,${Buffer.from(SUPABASE_COVERAGE_COLLECTOR).toString("base64")}`);
223
+ const collector = new EdgeCoverage();
224
+ clearInterval(collector.timer);
225
+ collector.ready = async () => {};
226
+ const slow = Promise.withResolvers<any>();
227
+ const started = Promise.withResolvers<void>();
228
+ let takes = 0;
229
+ collector.sessions.set("failed", { post: async () => { takes++; throw new Error("lost isolate"); } });
230
+ collector.sessions.set("slow", { post: async () => { takes++; started.resolve(); return slow.promise; } });
231
+ let settled = false;
232
+ const pending = collector.capture().then(() => null, (error: Error) => { settled = true; return error; });
233
+ await started.promise;
234
+ await Bun.sleep(0);
235
+ expect(settled).toBe(false);
236
+ await expect(collector.capture()).rejects.toThrow("already in progress");
237
+ expect(takes).toBe(2);
238
+ slow.resolve({ result: [] });
239
+ expect((await pending)?.message).toBe("lost isolate");
240
+ expect(collector.records.size).toBe(0);
241
+ await expect(collector.capture()).rejects.toThrow("lost isolate");
242
+ expect(takes).toBe(2);
243
+ });
244
+
245
+ test("function discovery changes invalidate the inventory for any component directory", async () => {
246
+ const adapter = supabaseFunctionsCoverage("nested/backend/functions");
247
+ const files = ["nested/backend/functions/one/index.ts", "nested/backend/functions/_shared/helpers.ts"];
248
+ let config = '{"imports":{}}';
249
+ const ctx = { projectFiles: async () => files, readProjectFile: async () => config } as any;
250
+ const before = await adapter.inventory!(ctx);
251
+ files.push("nested/backend/functions/_private/index.ts", "unrelated/functions/two/index.ts");
252
+ expect(await adapter.inventory!(ctx)).toEqual(before);
253
+ files.push("nested/backend/functions/two/index.ts");
254
+ expect(await adapter.inventory!(ctx)).not.toEqual(before);
255
+ const added = await adapter.inventory!(ctx);
256
+ files.push("nested/backend/functions/two/deno.json");
257
+ expect(await adapter.inventory!(ctx)).not.toEqual(added);
258
+ const configured = await adapter.inventory!(ctx);
259
+ config = '{"imports":{"shared":"./other.ts"}}';
260
+ expect(await adapter.inventory!(ctx)).not.toEqual(configured);
261
+ });
@@ -0,0 +1,373 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import type { CoverageAdapter } from "../coverage.js";
5
+ import { rawFetch } from "../harness/raw-fetch.js";
6
+
7
+ // Drain all work even on failure: no writes may outlive the capture boundary.
8
+ async function finishAll<T>(pending: Promise<T>[]): Promise<T[]> {
9
+ const results = await Promise.allSettled(pending);
10
+ return results.map(result => {
11
+ if (result.status === "rejected") throw result.reason;
12
+ return result.value;
13
+ });
14
+ }
15
+
16
+ /** The inspector stays on container loopback; only the component's router
17
+ * talks to it. Reports use the existing, runtime-independent V8 wire format. */
18
+ export function supabaseFunctionsCoverage(functionRoot: string): CoverageAdapter {
19
+ const shippedByService = new Map<string, Set<string>>();
20
+ return {
21
+ name: "supabase-functions",
22
+ reports: "delta",
23
+ sourceOptions: { sourceRoot: "." },
24
+ ownsReport: name => name.startsWith("edge-") || name.startsWith("edge-scripts/"),
25
+ async inventory(ctx) {
26
+ // Functions are discovered by directory, not by an import in user code.
27
+ // Changing the catalogue must invalidate selection even for a new file.
28
+ // A removed fingerprint with no executed unit conservatively runs all.
29
+ const prefix = functionRoot + "/";
30
+ const files = await ctx.projectFiles();
31
+ const entries = files.filter(file => {
32
+ if (!file.startsWith(prefix)) return false;
33
+ const parts = file.slice(prefix.length).split("/");
34
+ return parts.length === 2 && !/^[_.]/.test(parts[0]!) && parts[1] === "index.ts";
35
+ }).sort();
36
+ const configNames = new Set(["deno.json", "deno.jsonc", "deno.lock", "import_map.json", "package.json"]);
37
+ const configPaths = files.filter(file => {
38
+ if (!configNames.has(path.posix.basename(file))) return file === path.posix.join(path.posix.dirname(functionRoot), "config.toml");
39
+ const dir = path.posix.dirname(file);
40
+ return dir === "." || file.startsWith(prefix) || functionRoot.startsWith(dir + "/") || dir === functionRoot;
41
+ }).sort();
42
+ const configs = await Promise.all(configPaths.map(async file => [file, await ctx.readProjectFile(file)]));
43
+ const digest = createHash("sha256").update(JSON.stringify({ entries, configs })).digest("hex");
44
+ return [`manifest:edge-functions:${encodeURIComponent(functionRoot)}#${digest}`];
45
+ },
46
+ async capture(ctx) {
47
+ let shipped = shippedByService.get(ctx.service);
48
+ if (!shipped) shippedByService.set(ctx.service, shipped = new Set());
49
+ const response = await rawFetch(`http://${ctx.service}:9000/_spectest/coverage`, {
50
+ method: "POST", signal: ctx.signal,
51
+ });
52
+ if (!response.ok) throw new Error(`edge coverage: ${await response.text()}`);
53
+ const payload = await response.json() as { reports: { name: string; content: string }[] };
54
+ if (!payload || !Array.isArray(payload.reports) || !payload.reports.length) throw new Error("edge coverage returned no reports");
55
+ for (const report of payload.reports) {
56
+ if (!report || !/^(edge-[\w-]+\.json|edge-scripts\/[a-f0-9]+\.json)$/.test(report.name) || typeof report.content !== "string") {
57
+ throw new Error("invalid edge coverage report");
58
+ }
59
+ }
60
+ // State and files both fork with the VM. Remove only reports that this
61
+ // adapter previously shipped, never another adapter's output.
62
+ await finishAll([...shipped].map(name => fs.rm(path.join(ctx.reportDir, name), { force: true })));
63
+ shipped.clear();
64
+ await finishAll(payload.reports.map(async report => {
65
+ // Track attempted writes too, so a failed write is cleaned up next time.
66
+ shipped.add(report.name);
67
+ await ctx.writeReport(report.name, report.content);
68
+ }));
69
+ },
70
+ };
71
+ }
72
+
73
+ /** Runs in the trusted main worker, not in a sandboxed user function. Keep
74
+ * this plain JS so the same module is exercised against the pinned runtime. */
75
+ export const SUPABASE_COVERAGE_COLLECTOR = String.raw`
76
+ const INTERNAL = /^(ext:|node:|internal:)/;
77
+ const COMPILED = /^\/var\/tmp\/sb-compile-[^/]+\//;
78
+ const TIMEOUT = 30000;
79
+ // Keep failed captures fenced until every independent operation has settled.
80
+ const finishAll = async pending => {
81
+ const results = await Promise.allSettled(pending);
82
+ return results.map(result => {
83
+ if (result.status === "rejected") throw result.reason;
84
+ return result.value;
85
+ });
86
+ };
87
+ const hash = async text => Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text))), b => b.toString(16).padStart(2, "0")).join("");
88
+ const fileUrl = path => "file://" + path.split("/").map(encodeURIComponent).join("/");
89
+
90
+ export class EdgeCoverage {
91
+ constructor({ inspector = "http://127.0.0.1:9229", projectRoot = "/repo", generatedRoot = "/home/deno/spectest-functions" } = {}) {
92
+ this.inspector = inspector;
93
+ this.projectRoot = projectRoot.replace(/\/$/, "");
94
+ this.generatedRoot = generatedRoot;
95
+ this.entries = new Set();
96
+ this.wrappers = new Map();
97
+ this.mirrors = new Map();
98
+ this.sessions = new Map();
99
+ this.records = new Set();
100
+ this.error = null;
101
+ this.polling = null;
102
+ this.capturing = false;
103
+ this.sequence = 0;
104
+ this.timer = setInterval(() => this.poll().catch(e => this.fail(e)), 100);
105
+ }
106
+ fail(error) { this.error ??= String(error?.stack ?? error); }
107
+ register(entry) { this.entries.add(entry); }
108
+ wrap(entry) {
109
+ if (!this.wrappers.has(entry)) this.wrappers.set(entry, this.writeEntry(entry));
110
+ return this.wrappers.get(entry);
111
+ }
112
+ mirrorDirectory(relative) {
113
+ if (this.mirrors.has(relative)) return this.mirrors.get(relative);
114
+ const pending = (async () => {
115
+ const source = this.projectRoot + (relative ? "/" + relative : "");
116
+ const target = this.generatedRoot + "/project" + (relative ? "/" + relative : "");
117
+ if (relative) {
118
+ await this.mirrorDirectory(relative.includes("/") ? relative.slice(0, relative.lastIndexOf("/")) : "");
119
+ await Deno.remove(target); // replace this directory's link with an overlay
120
+ }
121
+ await Deno.mkdir(target, { recursive: true });
122
+ const items = [];
123
+ for await (const item of Deno.readDir(source)) items.push(item.name);
124
+ await finishAll(items.map(name => Deno.symlink(source + "/" + name, target + "/" + name)));
125
+ return target;
126
+ })();
127
+ this.mirrors.set(relative, pending);
128
+ return pending;
129
+ }
130
+ async writeEntry(entry) {
131
+ const slash = entry.lastIndexOf("/");
132
+ let directory = entry.slice(0, slash);
133
+ if (entry.startsWith(this.projectRoot + "/")) {
134
+ // Keep the complete directory/config layout, including ancestor Deno
135
+ // workspaces, relative import maps and packages. Only directories along
136
+ // the entry's ancestry are materialized; everything else is a symlink.
137
+ directory = await this.mirrorDirectory(directory.slice(this.projectRoot.length + 1));
138
+ }
139
+ const wrapper = directory + "/.spectest-coverage-" + (await hash(entry)).slice(0, 16) + ".mjs";
140
+ const code = [
141
+ "// spectest-generated coverage gate. Do not edit.",
142
+ "const keepAlive = setInterval(() => {}, 25);",
143
+ "const gate = {};",
144
+ "globalThis[Symbol.for('spectest.edgeCoverage')] = gate;",
145
+ "await new Promise(resolve => { gate.release = resolve; });",
146
+ "clearInterval(keepAlive);",
147
+ "const mod = await import(" + JSON.stringify("./" + entry.slice(slash + 1)) + ");",
148
+ "export default ('default' in mod ? mod.default : {});",
149
+ ].join("\n");
150
+ await Deno.writeTextFile(wrapper, code, { createNew: true });
151
+ this.register(wrapper);
152
+ return fileUrl(wrapper);
153
+ }
154
+ async forgetUnstarted(entry) {
155
+ // Compilation can reject the shared graph before an isolate exists. That
156
+ // is a normal reason for the component to retry in per-function mode.
157
+ const wrapper = await this.wrappers.get(entry);
158
+ if (!wrapper) return;
159
+ const original = decodeURIComponent(new URL(wrapper).pathname);
160
+ if (![...this.sessions.values()].some(s => s.entry === original)) this.entries.delete(original);
161
+ }
162
+ async ready() {
163
+ const deadline = Date.now() + TIMEOUT;
164
+ while (true) {
165
+ await this.poll();
166
+ if (this.error) throw new Error(this.error);
167
+ const attached = new Set([...this.sessions.values()].map(s => s.entry));
168
+ if ([...this.entries].every(entry => attached.has(entry))) {
169
+ await Promise.all([...this.sessions.values()].map(s => s.ready));
170
+ return;
171
+ }
172
+ if (Date.now() >= deadline) throw new Error("edge coverage did not attach to every warmed worker");
173
+ await new Promise(resolve => setTimeout(resolve, 25));
174
+ }
175
+ }
176
+ async poll() {
177
+ if (this.polling) return this.polling;
178
+ this.polling = this.discover();
179
+ try { await this.polling; } finally { this.polling = null; }
180
+ }
181
+ async discover() {
182
+ const response = await fetch(this.inspector + "/json/list", { signal: AbortSignal.timeout(TIMEOUT) });
183
+ if (!response.ok) throw new Error("edge inspector discovery failed: " + response.status);
184
+ const targets = await response.json();
185
+ if (!Array.isArray(targets)) throw new Error("invalid edge inspector target list");
186
+ const live = new Set(targets.map(t => t.id));
187
+ for (const [id] of this.sessions) if (!live.has(id)) throw new Error("edge coverage lost worker " + id);
188
+ for (const target of targets) {
189
+ if (this.sessions.has(target.id)) continue;
190
+ const session = this.connect(target);
191
+ this.sessions.set(target.id, session);
192
+ // Discover all isolates while their sessions initialize. ready() joins
193
+ // initialization separately; one worker never blocks discovery of others.
194
+ session.ready.catch(e => this.fail(e));
195
+ }
196
+ }
197
+ connect(target) {
198
+ const url = new URL(target.url);
199
+ if (url.protocol !== "file:") throw new Error("unexpected edge worker entry: " + target.url);
200
+ const compiledPath = decodeURIComponent(url.pathname);
201
+ const relative = compiledPath.replace(COMPILED, "");
202
+ // The runtime relocates the entire module graph under sb-compile-*/.
203
+ // Its registered entrypoint and our exact original entrypoint establish
204
+ // the relocation, independently for every isolate. Never guess a source
205
+ // file by basename or by a repository suffix.
206
+ const candidates = [...this.entries].filter(entry => compiledPath === entry || entry.endsWith("/" + relative));
207
+ if (candidates.length !== 1) throw new Error("ambiguous edge worker entry: " + target.url);
208
+ const original = candidates[0];
209
+ const base = COMPILED.test(compiledPath) ? original.slice(0, -relative.length) : "/";
210
+ const prefix = COMPILED.exec(compiledPath)?.[0] ?? "/";
211
+ const socketUrl = new URL(target.webSocketDebuggerUrl);
212
+ const inspector = new URL(this.inspector);
213
+ socketUrl.host = inspector.host;
214
+ const ws = new WebSocket(socketUrl);
215
+ const session = { ws, base, prefix, entry: original, scripts: new Map(), descriptions: new Map(), nextId: 0, pending: new Map() };
216
+ session.post = (method, params = {}) => new Promise((resolve, reject) => {
217
+ const id = ++session.nextId;
218
+ const timer = setTimeout(() => { session.pending.delete(id); reject(new Error("edge inspector timeout: " + method)); }, TIMEOUT);
219
+ session.pending.set(id, { resolve, reject, timer });
220
+ ws.send(JSON.stringify({ id, method, params }));
221
+ });
222
+ ws.onmessage = event => {
223
+ try {
224
+ const message = JSON.parse(event.data);
225
+ if (message.id) {
226
+ const pending = session.pending.get(message.id);
227
+ if (!pending) return;
228
+ clearTimeout(pending.timer);
229
+ session.pending.delete(message.id);
230
+ if (message.error) pending.reject(new Error(JSON.stringify(message.error)));
231
+ else pending.resolve(message.result);
232
+ } else if (message.method === "Debugger.scriptParsed") {
233
+ session.scripts.set(message.params.scriptId, message.params);
234
+ } else if (message.method === "Debugger.paused") {
235
+ session.post("Debugger.resume").catch(e => this.fail(e));
236
+ }
237
+ } catch (e) { this.fail(e); }
238
+ };
239
+ ws.onclose = () => {
240
+ const error = new Error("edge coverage inspector disconnected: " + target.id);
241
+ for (const p of session.pending.values()) { clearTimeout(p.timer); p.reject(error); }
242
+ session.pending.clear();
243
+ this.fail(error);
244
+ };
245
+ session.ready = (async () => {
246
+ await new Promise((resolve, reject) => {
247
+ const timer = setTimeout(() => reject(new Error("edge inspector connection timeout")), TIMEOUT);
248
+ ws.onopen = () => { clearTimeout(timer); resolve(); };
249
+ ws.onerror = () => { clearTimeout(timer); reject(new Error("edge inspector connection failed")); };
250
+ });
251
+ await session.post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
252
+ await session.post("Profiler.enable");
253
+ await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
254
+ // --inspect-brk can park inside the pinned runtime's inspector before
255
+ // its pending session handshake is polled. Use a live JS gate instead:
256
+ // no project module executes until precise coverage is acknowledged.
257
+ const released = await session.post("Runtime.evaluate", {
258
+ expression: "(async () => { const key = Symbol.for('spectest.edgeCoverage'); while (!globalThis[key]?.release) await new Promise(resolve => setTimeout(resolve, 10)); globalThis[key].release(); await new Promise(resolve => setTimeout(resolve, 0)); })()\n//# sourceURL=spectest:edge-coverage-bootstrap",
259
+ awaitPromise: true,
260
+ });
261
+ if (released.exceptionDetails) throw new Error("edge coverage gate failed: " + JSON.stringify(released.exceptionDetails));
262
+ })();
263
+ return session;
264
+ }
265
+ originalPath(session, url) {
266
+ const parsed = new URL(url);
267
+ if (parsed.protocol !== "file:") return null;
268
+ const pathname = decodeURIComponent(parsed.pathname);
269
+ if (!pathname.startsWith(session.prefix)) throw new Error("unknown edge source root: " + url);
270
+ return session.base + pathname.slice(session.prefix.length);
271
+ }
272
+ sourcePath(file) {
273
+ const mirror = this.generatedRoot + "/project/";
274
+ return file.startsWith(mirror) ? this.projectRoot + "/" + file.slice(mirror.length) : file;
275
+ }
276
+ async describe(session, script, original) {
277
+ if (session.descriptions.has(script.scriptId)) return session.descriptions.get(script.scriptId);
278
+ const { scriptSource: text } = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId });
279
+ if (typeof text !== "string") throw new Error("missing edge source: " + script.url);
280
+ const metadata = session.scripts.get(script.scriptId);
281
+ const mapUrl = metadata?.sourceMapURL;
282
+ let sourceMap = null;
283
+ if (mapUrl) {
284
+ if (!mapUrl.startsWith("data:")) throw new Error("unsupported edge source map: " + mapUrl);
285
+ const comma = mapUrl.indexOf(",");
286
+ const encoded = mapUrl.slice(comma + 1);
287
+ const decoded = mapUrl.slice(0, comma).endsWith(";base64")
288
+ ? new TextDecoder().decode(Uint8Array.from(atob(encoded), c => c.charCodeAt(0))) : decodeURIComponent(encoded);
289
+ const data = JSON.parse(decoded);
290
+ if (!Array.isArray(data.sources) || !Array.isArray(data.sourcesContent) || typeof data.mappings !== "string" || data.sections || data.sourceRoot) {
291
+ throw new Error("missing edge source-map identity: " + script.url);
292
+ }
293
+ // Edge Runtime's map sources are relative to the graph root, rather
294
+ // than the script directory. Validate their contents against the actual
295
+ // mounted sources before archiving the normalized identities.
296
+ data.sources = await finishAll(data.sources.map(async (name, i) => {
297
+ const source = new URL(name, fileUrl(session.base));
298
+ if (source.protocol !== "file:") throw new Error("unexpected mapped edge source: " + source.href);
299
+ const file = this.sourcePath(decodeURIComponent(source.pathname));
300
+ if (!file.startsWith(this.projectRoot + "/")) throw new Error("edge map escapes project: " + file);
301
+ if (await Deno.readTextFile(file) !== data.sourcesContent[i]) throw new Error("edge source content mismatch: " + file);
302
+ return fileUrl(file);
303
+ }));
304
+ delete data.sourcesContent;
305
+ delete data.sourceRoot;
306
+ sourceMap = { url: null, data };
307
+ } else {
308
+ // JavaScript may need no transpilation. TypeScript without a map must
309
+ // not silently attribute generated line numbers to authored source.
310
+ if (await Deno.readTextFile(original) !== text) throw new Error("edge source has no usable map: " + original);
311
+ }
312
+ const record = { url: fileUrl(original), sourceHash: await hash(text),
313
+ lineLengths: text.split(/[\n\u2028\u2029]/).map(line => line.length),
314
+ functions: script.functions.map(f => [f.functionName, f.ranges[0].startOffset, f.ranges[0].endOffset]), sourceMap };
315
+ const content = JSON.stringify({ spectestScript: record });
316
+ const descriptor = { id: await hash(content), content };
317
+ session.descriptions.set(script.scriptId, descriptor);
318
+ return descriptor;
319
+ }
320
+ async capture() {
321
+ if (this.capturing) throw new Error("edge coverage capture already in progress");
322
+ this.capturing = true;
323
+ try {
324
+ await this.ready();
325
+ if (this.error) throw new Error(this.error);
326
+ // Each isolate owns its counters. Take all deltas concurrently, and
327
+ // fetch independent script descriptors concurrently within each isolate.
328
+ // Join before merging so ordering/deduplication don't depend on timing.
329
+ const captures = await finishAll([...this.sessions.values()].map(async session => {
330
+ const dump = await session.post("Profiler.takePreciseCoverage");
331
+ return finishAll(dump.result.map(async script => {
332
+ if (!script.functions.some(f => f.ranges.some(r => r.count > 0))) return null;
333
+ if (INTERNAL.test(script.url)) return null;
334
+ if (script.url === "spectest:edge-coverage-bootstrap") return null;
335
+ if (/^(https?:|npm:|jsr:)/.test(script.url)) return { unit: "deno:" + script.url };
336
+ if (!script.url) throw new Error("anonymous executed edge script cannot be mapped");
337
+ const rawPath = this.originalPath(session, script.url);
338
+ if (rawPath === session.entry) return null; // the generated startup gate
339
+ const original = rawPath && this.sourcePath(rawPath);
340
+ if (original?.startsWith(this.generatedRoot + "/")) return null;
341
+ if (original?.includes("/node_modules/")) return { unit: "deno:" + script.url };
342
+ if (!original?.startsWith(this.projectRoot + "/")) throw new Error("unmapped edge script: " + script.url);
343
+ const descriptor = await this.describe(session, script, original);
344
+ return { descriptor, result: { url: fileUrl(original), spectestScript: descriptor.id,
345
+ functions: script.functions.filter(f => f.ranges.some(r => r.count > 0)).map(f => {
346
+ const start = f.ranges[0].startOffset;
347
+ return f.ranges.flatMap((r, i) => [i ? r.startOffset - start : start, r.endOffset - r.startOffset, r.count]);
348
+ }) } };
349
+ }));
350
+ }));
351
+ const reports = [], result = [], units = new Set();
352
+ for (const capture of captures) for (const item of capture) {
353
+ if (!item) continue;
354
+ if (item.unit) { units.add(item.unit); continue; }
355
+ const { descriptor } = item;
356
+ if (!this.records.has(descriptor.id)) {
357
+ reports.push({ name: "edge-scripts/" + descriptor.id + ".json", content: descriptor.content });
358
+ this.records.add(descriptor.id);
359
+ }
360
+ result.push(item.result);
361
+ }
362
+ if (this.error) throw new Error(this.error);
363
+ reports.push({ name: "edge-" + (++this.sequence) + ".json", content: JSON.stringify({ spectestVersion: 2, result, units: [...units] }) });
364
+ return { reports };
365
+ } catch (e) {
366
+ // A take resets V8 counters. If processing fails afterwards, subsequent
367
+ // captures cannot repair that gap. Keep the failure sticky on this fork.
368
+ this.fail(e);
369
+ throw e;
370
+ } finally { this.capturing = false; }
371
+ }
372
+ }
373
+ `;