@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.
- package/README.md +29 -1
- package/dist/browser.js +7 -5
- package/dist/components/supabase-coverage.d.ts +7 -0
- package/dist/components/supabase-coverage.js +375 -0
- package/dist/components/supabase.js +30 -7
- package/dist/index.d.ts +22 -17
- package/dist/index.js +70 -247
- package/dist/locator-errors.js +2 -1
- package/dist/locator.d.ts +32 -39
- package/dist/locator.js +12 -14
- package/dist/playwright-assertions.d.ts +11 -0
- package/dist/playwright-assertions.js +32 -0
- package/dist/text-match.d.ts +1 -14
- package/dist/text-match.js +1 -73
- package/dist/url-match.d.ts +1 -0
- package/dist/url-match.js +5 -9
- package/package.json +3 -2
- package/src/browser.ts +7 -5
- package/src/components/internal-fetch.test.ts +68 -0
- package/src/components/supabase-coverage.test.ts +261 -0
- package/src/components/supabase-coverage.ts +373 -0
- package/src/components/supabase.ts +30 -7
- package/src/index.ts +105 -356
- package/src/locator-assertions.test.ts +59 -0
- package/src/locator-errors.test.ts +4 -4
- package/src/locator-errors.ts +2 -1
- package/src/locator.ts +40 -50
- package/src/playwright-assertions.test.ts +231 -0
- package/src/playwright-assertions.ts +49 -0
- package/src/text-match.test.ts +0 -57
- package/src/text-match.ts +2 -98
- package/src/url-match.ts +5 -9
package/dist/text-match.js
CHANGED
|
@@ -1,25 +1,4 @@
|
|
|
1
|
-
// Text
|
|
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
|
-
}
|
package/dist/url-match.d.ts
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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": "
|
|
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
|
-
//
|
|
2086
|
-
//
|
|
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(...)`
|
|
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
|
-
|
|
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
|
+
});
|