@specific.dev/spectest 0.121.0 → 0.123.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.
@@ -1,25 +1,4 @@
1
- // Text comparison for the web-first matchers, and readable notes for the
2
- // failures that invisible characters cause.
3
- //
4
- // Two rules, and they are deliberately different:
5
- //
6
- // * **Matching is Playwright's.** `expect(locator).toHaveText(...)` and
7
- // `toContainText(...)` normalize whitespace on BOTH sides exactly as
8
- // playwright's own text engine does (`normalizeWhiteSpace` in
9
- // playwright-core). Without this the SDK disagreed with itself: the same
10
- // string matched through `getByText("15 000 kr")` (playwright's engine,
11
- // which normalizes) and failed through `toContainText("15 000 kr")`
12
- // (ours, which did a raw `includes`). The case that reported it is
13
- // Swedish number formatting — `Intl.NumberFormat("sv-SE")` groups
14
- // thousands with U+00A0 NO-BREAK SPACE, and JavaScript's `\s` covers
15
- // U+00A0, so normalization makes a typed space match it.
16
- //
17
- // * **Diagnostics are wider.** A value the author compares themselves
18
- // (`expect(body).toContain(...)`, `toHaveValue`) is still exact, as it
19
- // must be — so when such a comparison fails on characters that print the
20
- // same, the message has to say so. `textDifferenceNote` reports the
21
- // invisible or look-alike characters that are the whole difference, and
22
- // `escapeInvisible` makes the two printed strings differ on screen.
1
+ // Text diagnostics only. Browser comparisons are delegated to Playwright.
23
2
  /** Characters that are invisible on screen (or print as an ordinary space)
24
3
  * and their Unicode names, for a message a reader can act on. */
25
4
  const INVISIBLE_NAMES = {
@@ -82,30 +61,6 @@ const LOOKALIKES = {
82
61
  export function normalizeWhiteSpace(text) {
83
62
  return text.replace(/[\u200b\u00ad]/g, "").trim().replace(/\s+/g, " ");
84
63
  }
85
- function caseFold(s, opts) {
86
- return opts?.ignoreCase ? s.toLowerCase() : s;
87
- }
88
- function withIgnoreCase(re, opts) {
89
- if (!opts?.ignoreCase || re.flags.includes("i"))
90
- return re;
91
- return new RegExp(re.source, `${re.flags}i`);
92
- }
93
- /** The element's text equals `expected`, with whitespace normalized on both
94
- * sides. A RegExp is tested against the normalized text. */
95
- export function matchesText(actual, expected, opts) {
96
- const a = normalizeWhiteSpace(actual);
97
- if (expected instanceof RegExp)
98
- return withIgnoreCase(expected, opts).test(a);
99
- return caseFold(a, opts) === caseFold(normalizeWhiteSpace(expected), opts);
100
- }
101
- /** The element's text contains `expected`, with whitespace normalized on both
102
- * sides. A RegExp is tested against the normalized text. */
103
- export function containsText(actual, expected, opts) {
104
- const a = normalizeWhiteSpace(actual);
105
- if (expected instanceof RegExp)
106
- return withIgnoreCase(expected, opts).test(a);
107
- return caseFold(a, opts).includes(caseFold(normalizeWhiteSpace(expected), opts));
108
- }
109
64
  // ── Diagnostics ───────────────────────────────────────────────────────────
110
65
  /** `" "` for an invisible character, the character itself otherwise.
111
66
  * Applied to an ALREADY `JSON.stringify`-ed string, so the result stays a
@@ -210,30 +165,3 @@ export function textDifferenceNote(actual, expected, mode) {
210
165
  }
211
166
  return "";
212
167
  }
213
- // ── Array forms (`toHaveText([...])` / `toContainText([...])`) ────────────
214
- //
215
- // Playwright's own rule, from its injected script: the expected items are
216
- // matched against the received texts **greedily, in order**, and the lengths
217
- // must agree for `toHaveText` but not for `toContainText`. So
218
- // `toContainText(["Two", "Four"])` passes against a four-row list, while
219
- // `toHaveText(["Two", "Four"])` does not.
220
- function matchesInOrder(actual, expected, item) {
221
- let want = 0;
222
- for (let i = 0; i < actual.length && want < expected.length; i++) {
223
- if (item(actual[i], expected[want]))
224
- want++;
225
- }
226
- return want === expected.length;
227
- }
228
- /** Every element's text equals the item at its position, and there are
229
- * exactly as many elements as items. */
230
- export function matchesTextArray(actual, expected, opts) {
231
- if (actual.length !== expected.length)
232
- return false;
233
- return matchesInOrder(actual, expected, (a, e) => matchesText(a, e, opts));
234
- }
235
- /** The elements' texts contain every item, in order. Extra elements between
236
- * the matches are allowed. */
237
- export function containsTextArray(actual, expected, opts) {
238
- return matchesInOrder(actual, expected, (a, e) => containsText(a, e, opts));
239
- }
@@ -9,3 +9,4 @@ export type UrlPattern = string | RegExp | ((url: URL) => boolean);
9
9
  export declare function matchesUrl(current: string, pattern: UrlPattern): boolean;
10
10
  /** Human display of a pattern, for timeline labels and failure messages. */
11
11
  export declare function describeUrlPattern(pattern: UrlPattern): string;
12
+ export declare function globToRegExp(glob: string): RegExp;
package/dist/url-match.js CHANGED
@@ -1,13 +1,9 @@
1
1
  // URL pattern matching — shared by `browser.waitForURL(...)` and
2
2
  // `expect(browser).toHaveURL(...)`.
3
3
  //
4
- // Both take the same pattern forms as Playwright (glob string | RegExp |
5
- // predicate over a `URL`), and both lower to `matchesUrl` here so the two
6
- // can never disagree: `waitForURL` hands playwright a predicate that calls
7
- // this rather than letting playwright apply its own glob dialect, and the
8
- // matcher polls it directly. Deliberately dependency-free so `index.ts` (the
9
- // module a project imports on the host, where playwright isn't installed) can
10
- // use it without pulling in `browser.ts`.
4
+ // Both accept glob strings, RegExp, or predicates over a URL. waitForURL
5
+ // uses matchesUrl; the assertion adapter passes regexes/predicates directly
6
+ // to Playwright and compiles string globs with the same globToRegExp helper.
11
7
  /** Whether `current` (a full URL string) matches `pattern`. */
12
8
  export function matchesUrl(current, pattern) {
13
9
  if (typeof pattern === "function") {
@@ -34,9 +30,9 @@ export function describeUrlPattern(pattern) {
34
30
  return String(pattern);
35
31
  return JSON.stringify(pattern);
36
32
  }
37
- // Compiled globs are cached: a matcher polls the same pattern every 50ms.
33
+ // Reuse compiled globs across waits and assertions.
38
34
  const GLOB_CACHE = new Map();
39
- function globToRegExp(glob) {
35
+ export function globToRegExp(glob) {
40
36
  const hit = GLOB_CACHE.get(glob);
41
37
  if (hit)
42
38
  return hit;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.121.0",
3
+ "version": "0.123.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -54,10 +54,11 @@
54
54
  },
55
55
  "dependencies": {
56
56
  "@kubernetes/client-node": "^1.0.0",
57
+ "@playwright/test": "1.61.1",
57
58
  "@xterm/headless": "^5.5.0",
58
59
  "acorn": "8.18.0",
59
60
  "dns-packet": "^5.6.1",
60
- "playwright-core": "^1.61.1",
61
+ "playwright-core": "1.61.1",
61
62
  "ws": "^8.20.1"
62
63
  },
63
64
  "devDependencies": {
package/src/browser.ts CHANGED
@@ -2082,9 +2082,8 @@ function buildBackend(
2082
2082
  fields,
2083
2083
  async () => {
2084
2084
  try {
2085
- // Always a predicate, never playwright's own glob: `matchesUrl`
2086
- // is then the single matching implementation shared with
2087
- // `expect(browser).toHaveURL`.
2085
+ // Preserve Spectest's glob dialect. URL assertions use the same
2086
+ // glob compiler through their native matcher adapter.
2088
2087
  await holder.page.waitForURL((u: URL) => matchesUrl(u.href, pattern), {
2089
2088
  timeout: timeoutMs,
2090
2089
  waitUntil: options.waitUntil ?? "commit",
@@ -2310,12 +2309,15 @@ function buildBackend(
2310
2309
  return instrumented(action, fields, () => fn(holder.page), opts);
2311
2310
  },
2312
2311
  };
2313
- // The seam `expect(browser).toHaveURL(...)` polls: a silent URL read plus
2312
+ // The seam `expect(browser).toHaveURL(...)` uses: a native assertion plus
2314
2313
  // the single settled step its assertion nests under — the session twin of
2315
2314
  // the locator probe, and what brands this object as a browser session for
2316
2315
  // `expect`'s overload dispatch.
2317
2316
  attachBrowserProbe(backend, {
2318
- url: () => holder.page.url(),
2317
+ assertURL: async (negated, expected, opts) => {
2318
+ const { assertURL } = await import("./playwright-assertions.js");
2319
+ await assertURL(holder.page, negated, expected, opts);
2320
+ },
2319
2321
  settle: (action, waitedMs, error) =>
2320
2322
  backend.recordSettled(action, { url: holder.page.url() }, waitedMs, error),
2321
2323
  });
@@ -11,6 +11,7 @@ async function check(code: string, workspace?: string): Promise<void> {
11
11
  import { strict as assert } from "node:assert";
12
12
  import { s3 } from ${moduleUrl("./s3.ts")};
13
13
  import { supabase } from ${moduleUrl("./supabase.ts")};
14
+ import { supabaseFunctionsCoverage } from ${moduleUrl("./supabase-coverage.ts")};
14
15
  import { emailHelpers } from ${moduleUrl("./email.ts")};
15
16
  import { installFetchWrapper } from ${moduleUrl("../harness/fetch.ts")};
16
17
  import { createInstrumentationScope, runInstrumented } from ${moduleUrl("../harness/instrumentation-scope.ts")};
@@ -56,6 +57,42 @@ test.each([200, 201, 409, 403, 500])("S3 setup handles HTTP %i inside a wrapped
56
57
  `);
57
58
  });
58
59
 
60
+ test("Supabase report writes overlap and drain before a failed capture returns", async () => {
61
+ const root = await mkdtemp(join(tmpdir(), "spectest-edge-parallel-"));
62
+ try {
63
+ await check(`
64
+ const fs = await import("node:fs/promises");
65
+ const root = ${JSON.stringify(root)};
66
+ const adapter = supabaseFunctionsCoverage("custom/functions");
67
+ const slow = Promise.withResolvers();
68
+ const bothStarted = Promise.withResolvers();
69
+ let started = 0, settled = false;
70
+ respond = () => Response.json({ reports: [
71
+ { name: "edge-1.json", content: "one" },
72
+ { name: "edge-2.json", content: "two" },
73
+ ] });
74
+ const ctx = { service: "functions", reportDir: root, signal: AbortSignal.timeout(5000),
75
+ async writeReport(name, content) {
76
+ if (++started === 2) bothStarted.resolve();
77
+ if (name === "edge-1.json") throw new Error("write failed");
78
+ await slow.promise;
79
+ await fs.writeFile(root + "/" + name, content);
80
+ },
81
+ };
82
+ const pending = adapter.capture(ctx).then(() => null, error => { settled = true; return error; });
83
+ await bothStarted.promise;
84
+ await Bun.sleep(0);
85
+ assert.equal(settled, false);
86
+ slow.resolve();
87
+ assert.match((await pending).message, /write failed/);
88
+ respond = () => Response.json({ reports: [{ name: "edge-3.json", content: "three" }] });
89
+ ctx.writeReport = (name, content) => fs.writeFile(root + "/" + name, content);
90
+ await adapter.capture(ctx);
91
+ assert.deepEqual(await fs.readdir(root), ["edge-3.json"]);
92
+ `);
93
+ } finally { await rm(root, { recursive: true, force: true }); }
94
+ });
95
+
59
96
  test("S3 setup propagates transport failures", async () => {
60
97
  await check(`
61
98
  respond = () => { throw new Error("connection refused"); };
@@ -107,3 +144,34 @@ test("email helpers reject HTTP errors and retain their public event without int
107
144
  assert.equal(events.some(e => e.error), false);
108
145
  `);
109
146
  });
147
+
148
+ test("automatic edge coverage uses raw fetch and replaces only its previously shipped reports", async () => {
149
+ const root = await mkdtemp(join(tmpdir(), "spectest-edge-capture-"));
150
+ try {
151
+ await check(`
152
+ const fs = await import("node:fs/promises");
153
+ const root = ${JSON.stringify(root)};
154
+ const adapter = supabaseFunctionsCoverage("custom/functions");
155
+ const ctx = {
156
+ service: "custom-functions", reportDir: root, signal: AbortSignal.timeout(5000),
157
+ writeReport: (name, content) => fs.writeFile(root + "/" + name, content),
158
+ };
159
+ await fs.writeFile(root + "/other.lcov", "TN:");
160
+ const content = JSON.stringify({ spectestVersion: 2, result: [] });
161
+ respond = () => Response.json({ reports: [{ name: "edge-1.json", content }] });
162
+ await adapter.capture(ctx);
163
+ respond = () => Response.json({ reports: [{ name: "edge-2.json", content }] });
164
+ await adapter.capture(ctx);
165
+ assert.deepEqual((await fs.readdir(root)).sort(), ["edge-2.json", "other.lcov"]);
166
+ assert.deepEqual(requests, [
167
+ { url: "http://custom-functions:9000/_spectest/coverage", method: "POST" },
168
+ { url: "http://custom-functions:9000/_spectest/coverage", method: "POST" },
169
+ ]);
170
+ respond = () => Response.json({ reports: [{ name: "../escape.json", content }] });
171
+ await assert.rejects(() => adapter.capture(ctx), /invalid edge coverage report/);
172
+ respond = () => new Response("lost worker", { status: 500 });
173
+ await assert.rejects(() => adapter.capture(ctx), /lost worker/);
174
+ assert.equal(await fs.readFile(root + "/edge-2.json", "utf8"), content);
175
+ `);
176
+ } finally { await rm(root, { recursive: true, force: true }); }
177
+ });
@@ -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
+ });