@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/README.md
CHANGED
|
@@ -8,18 +8,46 @@ Install the SDK in your project's `spectest/` directory:
|
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
mkdir -p spectest
|
|
11
|
-
npm install --prefix spectest --save-exact @specific.dev/spectest
|
|
11
|
+
npm install --prefix spectest --save-exact @specific.dev/spectest
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
Install the [Spectest CLI](https://github.com/specific-dev/spectest/releases/latest)
|
|
15
15
|
and run `spectest docs /installation` for project setup and authentication.
|
|
16
16
|
`spectest docs` contains the complete offline authoring guide.
|
|
17
17
|
|
|
18
|
+
For browser tests, read `spectest docs /tests/browser`; for phone-emulated Expo
|
|
19
|
+
apps, read `spectest docs /tests/mobile`. The shared test model is in `/tests`. Prefer
|
|
20
|
+
unique, scoped role/name or test-ID locators and awaited browser assertions:
|
|
21
|
+
`await expect(browser.getByRole("status")).toHaveText("Saved")`. A one-time read
|
|
22
|
+
inside `expect` does not retry. Observe the exact saved state before dependent
|
|
23
|
+
work; do not replace readiness with sleeps or arbitrary `.first()` selections.
|
|
24
|
+
SDK 0.123.0 uses native Playwright assertions while retaining Spectest's API and
|
|
25
|
+
recording. Ambiguous single-element selectors fail immediately; make them unique.
|
|
26
|
+
|
|
18
27
|
Pub/Sub integrations are project-owned as of SDK 0.98.0. The former
|
|
19
28
|
`pubsub`/`pubsubClient` exports and `/components/gcp` entry point are removed.
|
|
20
29
|
Use a local service definition or `serviceGroup()` wrapper; the repository’s
|
|
21
30
|
`examples/google-pubsub` demonstrates this using public SDK APIs.
|
|
22
31
|
|
|
32
|
+
## Supabase function coverage (0.122.0)
|
|
33
|
+
|
|
34
|
+
`supabase({ dir: "supabase" })` automatically collects coverage for its edge
|
|
35
|
+
functions, including local shared modules outside that directory. No application
|
|
36
|
+
instrumentation or coverage option is needed. Custom component names and folder
|
|
37
|
+
layouts work the same way. Stacks without functions have no extra collector.
|
|
38
|
+
|
|
39
|
+
Coverage starts before user code in both shared and per-function isolates. Setup
|
|
40
|
+
and individual test captures are separate, with source maps checked against the
|
|
41
|
+
uploaded TypeScript/JavaScript. Generated startup gates let functions warm
|
|
42
|
+
concurrently while preserving their Deno configuration and import maps.
|
|
43
|
+
Isolate captures, source metadata reads, and report writes run concurrently.
|
|
44
|
+
Lost workers or unusable source metadata fail
|
|
45
|
+
collection rather than silently reporting no execution. Changes to the function
|
|
46
|
+
catalogue or runtime configuration conservatively invalidate subset selection,
|
|
47
|
+
including newly added functions. This collects edge function code only: SQL and
|
|
48
|
+
application frontend coverage are separate, and subset enforcement remains a
|
|
49
|
+
project setting.
|
|
50
|
+
|
|
23
51
|
## Interactive entrypoints (0.114.0)
|
|
24
52
|
|
|
25
53
|
Choose the links shown in the interactive dashboard and PR preview comment:
|
package/dist/browser.js
CHANGED
|
@@ -1554,9 +1554,8 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1554
1554
|
const fields = { description: label };
|
|
1555
1555
|
return instrumented("waitForURL", fields, async () => {
|
|
1556
1556
|
try {
|
|
1557
|
-
//
|
|
1558
|
-
//
|
|
1559
|
-
// `expect(browser).toHaveURL`.
|
|
1557
|
+
// Preserve Spectest's glob dialect. URL assertions use the same
|
|
1558
|
+
// glob compiler through their native matcher adapter.
|
|
1560
1559
|
await holder.page.waitForURL((u) => matchesUrl(u.href, pattern), {
|
|
1561
1560
|
timeout: timeoutMs,
|
|
1562
1561
|
waitUntil: options.waitUntil ?? "commit",
|
|
@@ -1763,12 +1762,15 @@ function buildBackend(holder, recorder, buildOpts) {
|
|
|
1763
1762
|
return instrumented(action, fields, () => fn(holder.page), opts);
|
|
1764
1763
|
},
|
|
1765
1764
|
};
|
|
1766
|
-
// The seam `expect(browser).toHaveURL(...)`
|
|
1765
|
+
// The seam `expect(browser).toHaveURL(...)` uses: a native assertion plus
|
|
1767
1766
|
// the single settled step its assertion nests under — the session twin of
|
|
1768
1767
|
// the locator probe, and what brands this object as a browser session for
|
|
1769
1768
|
// `expect`'s overload dispatch.
|
|
1770
1769
|
attachBrowserProbe(backend, {
|
|
1771
|
-
|
|
1770
|
+
assertURL: async (negated, expected, opts) => {
|
|
1771
|
+
const { assertURL } = await import("./playwright-assertions.js");
|
|
1772
|
+
await assertURL(holder.page, negated, expected, opts);
|
|
1773
|
+
},
|
|
1772
1774
|
settle: (action, waitedMs, error) => backend.recordSettled(action, { url: holder.page.url() }, waitedMs, error),
|
|
1773
1775
|
});
|
|
1774
1776
|
return { backend, detach: endRecording };
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CoverageAdapter } from "../coverage.js";
|
|
2
|
+
/** The inspector stays on container loopback; only the component's router
|
|
3
|
+
* talks to it. Reports use the existing, runtime-independent V8 wire format. */
|
|
4
|
+
export declare function supabaseFunctionsCoverage(functionRoot: string): CoverageAdapter;
|
|
5
|
+
/** Runs in the trusted main worker, not in a sandboxed user function. Keep
|
|
6
|
+
* this plain JS so the same module is exercised against the pinned runtime. */
|
|
7
|
+
export declare const SUPABASE_COVERAGE_COLLECTOR: string;
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { rawFetch } from "../harness/raw-fetch.js";
|
|
5
|
+
// Drain all work even on failure: no writes may outlive the capture boundary.
|
|
6
|
+
async function finishAll(pending) {
|
|
7
|
+
const results = await Promise.allSettled(pending);
|
|
8
|
+
return results.map(result => {
|
|
9
|
+
if (result.status === "rejected")
|
|
10
|
+
throw result.reason;
|
|
11
|
+
return result.value;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
/** The inspector stays on container loopback; only the component's router
|
|
15
|
+
* talks to it. Reports use the existing, runtime-independent V8 wire format. */
|
|
16
|
+
export function supabaseFunctionsCoverage(functionRoot) {
|
|
17
|
+
const shippedByService = new Map();
|
|
18
|
+
return {
|
|
19
|
+
name: "supabase-functions",
|
|
20
|
+
reports: "delta",
|
|
21
|
+
sourceOptions: { sourceRoot: "." },
|
|
22
|
+
ownsReport: name => name.startsWith("edge-") || name.startsWith("edge-scripts/"),
|
|
23
|
+
async inventory(ctx) {
|
|
24
|
+
// Functions are discovered by directory, not by an import in user code.
|
|
25
|
+
// Changing the catalogue must invalidate selection even for a new file.
|
|
26
|
+
// A removed fingerprint with no executed unit conservatively runs all.
|
|
27
|
+
const prefix = functionRoot + "/";
|
|
28
|
+
const files = await ctx.projectFiles();
|
|
29
|
+
const entries = files.filter(file => {
|
|
30
|
+
if (!file.startsWith(prefix))
|
|
31
|
+
return false;
|
|
32
|
+
const parts = file.slice(prefix.length).split("/");
|
|
33
|
+
return parts.length === 2 && !/^[_.]/.test(parts[0]) && parts[1] === "index.ts";
|
|
34
|
+
}).sort();
|
|
35
|
+
const configNames = new Set(["deno.json", "deno.jsonc", "deno.lock", "import_map.json", "package.json"]);
|
|
36
|
+
const configPaths = files.filter(file => {
|
|
37
|
+
if (!configNames.has(path.posix.basename(file)))
|
|
38
|
+
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)
|
|
49
|
+
shippedByService.set(ctx.service, shipped = new Set());
|
|
50
|
+
const response = await rawFetch(`http://${ctx.service}:9000/_spectest/coverage`, {
|
|
51
|
+
method: "POST", signal: ctx.signal,
|
|
52
|
+
});
|
|
53
|
+
if (!response.ok)
|
|
54
|
+
throw new Error(`edge coverage: ${await response.text()}`);
|
|
55
|
+
const payload = await response.json();
|
|
56
|
+
if (!payload || !Array.isArray(payload.reports) || !payload.reports.length)
|
|
57
|
+
throw new Error("edge coverage returned no reports");
|
|
58
|
+
for (const report of payload.reports) {
|
|
59
|
+
if (!report || !/^(edge-[\w-]+\.json|edge-scripts\/[a-f0-9]+\.json)$/.test(report.name) || typeof report.content !== "string") {
|
|
60
|
+
throw new Error("invalid edge coverage report");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// State and files both fork with the VM. Remove only reports that this
|
|
64
|
+
// adapter previously shipped, never another adapter's output.
|
|
65
|
+
await finishAll([...shipped].map(name => fs.rm(path.join(ctx.reportDir, name), { force: true })));
|
|
66
|
+
shipped.clear();
|
|
67
|
+
await finishAll(payload.reports.map(async (report) => {
|
|
68
|
+
// Track attempted writes too, so a failed write is cleaned up next time.
|
|
69
|
+
shipped.add(report.name);
|
|
70
|
+
await ctx.writeReport(report.name, report.content);
|
|
71
|
+
}));
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Runs in the trusted main worker, not in a sandboxed user function. Keep
|
|
76
|
+
* this plain JS so the same module is exercised against the pinned runtime. */
|
|
77
|
+
export const SUPABASE_COVERAGE_COLLECTOR = String.raw `
|
|
78
|
+
const INTERNAL = /^(ext:|node:|internal:)/;
|
|
79
|
+
const COMPILED = /^\/var\/tmp\/sb-compile-[^/]+\//;
|
|
80
|
+
const TIMEOUT = 30000;
|
|
81
|
+
// Keep failed captures fenced until every independent operation has settled.
|
|
82
|
+
const finishAll = async pending => {
|
|
83
|
+
const results = await Promise.allSettled(pending);
|
|
84
|
+
return results.map(result => {
|
|
85
|
+
if (result.status === "rejected") throw result.reason;
|
|
86
|
+
return result.value;
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
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("");
|
|
90
|
+
const fileUrl = path => "file://" + path.split("/").map(encodeURIComponent).join("/");
|
|
91
|
+
|
|
92
|
+
export class EdgeCoverage {
|
|
93
|
+
constructor({ inspector = "http://127.0.0.1:9229", projectRoot = "/repo", generatedRoot = "/home/deno/spectest-functions" } = {}) {
|
|
94
|
+
this.inspector = inspector;
|
|
95
|
+
this.projectRoot = projectRoot.replace(/\/$/, "");
|
|
96
|
+
this.generatedRoot = generatedRoot;
|
|
97
|
+
this.entries = new Set();
|
|
98
|
+
this.wrappers = new Map();
|
|
99
|
+
this.mirrors = new Map();
|
|
100
|
+
this.sessions = new Map();
|
|
101
|
+
this.records = new Set();
|
|
102
|
+
this.error = null;
|
|
103
|
+
this.polling = null;
|
|
104
|
+
this.capturing = false;
|
|
105
|
+
this.sequence = 0;
|
|
106
|
+
this.timer = setInterval(() => this.poll().catch(e => this.fail(e)), 100);
|
|
107
|
+
}
|
|
108
|
+
fail(error) { this.error ??= String(error?.stack ?? error); }
|
|
109
|
+
register(entry) { this.entries.add(entry); }
|
|
110
|
+
wrap(entry) {
|
|
111
|
+
if (!this.wrappers.has(entry)) this.wrappers.set(entry, this.writeEntry(entry));
|
|
112
|
+
return this.wrappers.get(entry);
|
|
113
|
+
}
|
|
114
|
+
mirrorDirectory(relative) {
|
|
115
|
+
if (this.mirrors.has(relative)) return this.mirrors.get(relative);
|
|
116
|
+
const pending = (async () => {
|
|
117
|
+
const source = this.projectRoot + (relative ? "/" + relative : "");
|
|
118
|
+
const target = this.generatedRoot + "/project" + (relative ? "/" + relative : "");
|
|
119
|
+
if (relative) {
|
|
120
|
+
await this.mirrorDirectory(relative.includes("/") ? relative.slice(0, relative.lastIndexOf("/")) : "");
|
|
121
|
+
await Deno.remove(target); // replace this directory's link with an overlay
|
|
122
|
+
}
|
|
123
|
+
await Deno.mkdir(target, { recursive: true });
|
|
124
|
+
const items = [];
|
|
125
|
+
for await (const item of Deno.readDir(source)) items.push(item.name);
|
|
126
|
+
await finishAll(items.map(name => Deno.symlink(source + "/" + name, target + "/" + name)));
|
|
127
|
+
return target;
|
|
128
|
+
})();
|
|
129
|
+
this.mirrors.set(relative, pending);
|
|
130
|
+
return pending;
|
|
131
|
+
}
|
|
132
|
+
async writeEntry(entry) {
|
|
133
|
+
const slash = entry.lastIndexOf("/");
|
|
134
|
+
let directory = entry.slice(0, slash);
|
|
135
|
+
if (entry.startsWith(this.projectRoot + "/")) {
|
|
136
|
+
// Keep the complete directory/config layout, including ancestor Deno
|
|
137
|
+
// workspaces, relative import maps and packages. Only directories along
|
|
138
|
+
// the entry's ancestry are materialized; everything else is a symlink.
|
|
139
|
+
directory = await this.mirrorDirectory(directory.slice(this.projectRoot.length + 1));
|
|
140
|
+
}
|
|
141
|
+
const wrapper = directory + "/.spectest-coverage-" + (await hash(entry)).slice(0, 16) + ".mjs";
|
|
142
|
+
const code = [
|
|
143
|
+
"// spectest-generated coverage gate. Do not edit.",
|
|
144
|
+
"const keepAlive = setInterval(() => {}, 25);",
|
|
145
|
+
"const gate = {};",
|
|
146
|
+
"globalThis[Symbol.for('spectest.edgeCoverage')] = gate;",
|
|
147
|
+
"await new Promise(resolve => { gate.release = resolve; });",
|
|
148
|
+
"clearInterval(keepAlive);",
|
|
149
|
+
"const mod = await import(" + JSON.stringify("./" + entry.slice(slash + 1)) + ");",
|
|
150
|
+
"export default ('default' in mod ? mod.default : {});",
|
|
151
|
+
].join("\n");
|
|
152
|
+
await Deno.writeTextFile(wrapper, code, { createNew: true });
|
|
153
|
+
this.register(wrapper);
|
|
154
|
+
return fileUrl(wrapper);
|
|
155
|
+
}
|
|
156
|
+
async forgetUnstarted(entry) {
|
|
157
|
+
// Compilation can reject the shared graph before an isolate exists. That
|
|
158
|
+
// is a normal reason for the component to retry in per-function mode.
|
|
159
|
+
const wrapper = await this.wrappers.get(entry);
|
|
160
|
+
if (!wrapper) return;
|
|
161
|
+
const original = decodeURIComponent(new URL(wrapper).pathname);
|
|
162
|
+
if (![...this.sessions.values()].some(s => s.entry === original)) this.entries.delete(original);
|
|
163
|
+
}
|
|
164
|
+
async ready() {
|
|
165
|
+
const deadline = Date.now() + TIMEOUT;
|
|
166
|
+
while (true) {
|
|
167
|
+
await this.poll();
|
|
168
|
+
if (this.error) throw new Error(this.error);
|
|
169
|
+
const attached = new Set([...this.sessions.values()].map(s => s.entry));
|
|
170
|
+
if ([...this.entries].every(entry => attached.has(entry))) {
|
|
171
|
+
await Promise.all([...this.sessions.values()].map(s => s.ready));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (Date.now() >= deadline) throw new Error("edge coverage did not attach to every warmed worker");
|
|
175
|
+
await new Promise(resolve => setTimeout(resolve, 25));
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async poll() {
|
|
179
|
+
if (this.polling) return this.polling;
|
|
180
|
+
this.polling = this.discover();
|
|
181
|
+
try { await this.polling; } finally { this.polling = null; }
|
|
182
|
+
}
|
|
183
|
+
async discover() {
|
|
184
|
+
const response = await fetch(this.inspector + "/json/list", { signal: AbortSignal.timeout(TIMEOUT) });
|
|
185
|
+
if (!response.ok) throw new Error("edge inspector discovery failed: " + response.status);
|
|
186
|
+
const targets = await response.json();
|
|
187
|
+
if (!Array.isArray(targets)) throw new Error("invalid edge inspector target list");
|
|
188
|
+
const live = new Set(targets.map(t => t.id));
|
|
189
|
+
for (const [id] of this.sessions) if (!live.has(id)) throw new Error("edge coverage lost worker " + id);
|
|
190
|
+
for (const target of targets) {
|
|
191
|
+
if (this.sessions.has(target.id)) continue;
|
|
192
|
+
const session = this.connect(target);
|
|
193
|
+
this.sessions.set(target.id, session);
|
|
194
|
+
// Discover all isolates while their sessions initialize. ready() joins
|
|
195
|
+
// initialization separately; one worker never blocks discovery of others.
|
|
196
|
+
session.ready.catch(e => this.fail(e));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
connect(target) {
|
|
200
|
+
const url = new URL(target.url);
|
|
201
|
+
if (url.protocol !== "file:") throw new Error("unexpected edge worker entry: " + target.url);
|
|
202
|
+
const compiledPath = decodeURIComponent(url.pathname);
|
|
203
|
+
const relative = compiledPath.replace(COMPILED, "");
|
|
204
|
+
// The runtime relocates the entire module graph under sb-compile-*/.
|
|
205
|
+
// Its registered entrypoint and our exact original entrypoint establish
|
|
206
|
+
// the relocation, independently for every isolate. Never guess a source
|
|
207
|
+
// file by basename or by a repository suffix.
|
|
208
|
+
const candidates = [...this.entries].filter(entry => compiledPath === entry || entry.endsWith("/" + relative));
|
|
209
|
+
if (candidates.length !== 1) throw new Error("ambiguous edge worker entry: " + target.url);
|
|
210
|
+
const original = candidates[0];
|
|
211
|
+
const base = COMPILED.test(compiledPath) ? original.slice(0, -relative.length) : "/";
|
|
212
|
+
const prefix = COMPILED.exec(compiledPath)?.[0] ?? "/";
|
|
213
|
+
const socketUrl = new URL(target.webSocketDebuggerUrl);
|
|
214
|
+
const inspector = new URL(this.inspector);
|
|
215
|
+
socketUrl.host = inspector.host;
|
|
216
|
+
const ws = new WebSocket(socketUrl);
|
|
217
|
+
const session = { ws, base, prefix, entry: original, scripts: new Map(), descriptions: new Map(), nextId: 0, pending: new Map() };
|
|
218
|
+
session.post = (method, params = {}) => new Promise((resolve, reject) => {
|
|
219
|
+
const id = ++session.nextId;
|
|
220
|
+
const timer = setTimeout(() => { session.pending.delete(id); reject(new Error("edge inspector timeout: " + method)); }, TIMEOUT);
|
|
221
|
+
session.pending.set(id, { resolve, reject, timer });
|
|
222
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
223
|
+
});
|
|
224
|
+
ws.onmessage = event => {
|
|
225
|
+
try {
|
|
226
|
+
const message = JSON.parse(event.data);
|
|
227
|
+
if (message.id) {
|
|
228
|
+
const pending = session.pending.get(message.id);
|
|
229
|
+
if (!pending) return;
|
|
230
|
+
clearTimeout(pending.timer);
|
|
231
|
+
session.pending.delete(message.id);
|
|
232
|
+
if (message.error) pending.reject(new Error(JSON.stringify(message.error)));
|
|
233
|
+
else pending.resolve(message.result);
|
|
234
|
+
} else if (message.method === "Debugger.scriptParsed") {
|
|
235
|
+
session.scripts.set(message.params.scriptId, message.params);
|
|
236
|
+
} else if (message.method === "Debugger.paused") {
|
|
237
|
+
session.post("Debugger.resume").catch(e => this.fail(e));
|
|
238
|
+
}
|
|
239
|
+
} catch (e) { this.fail(e); }
|
|
240
|
+
};
|
|
241
|
+
ws.onclose = () => {
|
|
242
|
+
const error = new Error("edge coverage inspector disconnected: " + target.id);
|
|
243
|
+
for (const p of session.pending.values()) { clearTimeout(p.timer); p.reject(error); }
|
|
244
|
+
session.pending.clear();
|
|
245
|
+
this.fail(error);
|
|
246
|
+
};
|
|
247
|
+
session.ready = (async () => {
|
|
248
|
+
await new Promise((resolve, reject) => {
|
|
249
|
+
const timer = setTimeout(() => reject(new Error("edge inspector connection timeout")), TIMEOUT);
|
|
250
|
+
ws.onopen = () => { clearTimeout(timer); resolve(); };
|
|
251
|
+
ws.onerror = () => { clearTimeout(timer); reject(new Error("edge inspector connection failed")); };
|
|
252
|
+
});
|
|
253
|
+
await session.post("Debugger.enable", { maxScriptsCacheSize: 16 * 1024 * 1024 });
|
|
254
|
+
await session.post("Profiler.enable");
|
|
255
|
+
await session.post("Profiler.startPreciseCoverage", { callCount: true, detailed: true });
|
|
256
|
+
// --inspect-brk can park inside the pinned runtime's inspector before
|
|
257
|
+
// its pending session handshake is polled. Use a live JS gate instead:
|
|
258
|
+
// no project module executes until precise coverage is acknowledged.
|
|
259
|
+
const released = await session.post("Runtime.evaluate", {
|
|
260
|
+
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",
|
|
261
|
+
awaitPromise: true,
|
|
262
|
+
});
|
|
263
|
+
if (released.exceptionDetails) throw new Error("edge coverage gate failed: " + JSON.stringify(released.exceptionDetails));
|
|
264
|
+
})();
|
|
265
|
+
return session;
|
|
266
|
+
}
|
|
267
|
+
originalPath(session, url) {
|
|
268
|
+
const parsed = new URL(url);
|
|
269
|
+
if (parsed.protocol !== "file:") return null;
|
|
270
|
+
const pathname = decodeURIComponent(parsed.pathname);
|
|
271
|
+
if (!pathname.startsWith(session.prefix)) throw new Error("unknown edge source root: " + url);
|
|
272
|
+
return session.base + pathname.slice(session.prefix.length);
|
|
273
|
+
}
|
|
274
|
+
sourcePath(file) {
|
|
275
|
+
const mirror = this.generatedRoot + "/project/";
|
|
276
|
+
return file.startsWith(mirror) ? this.projectRoot + "/" + file.slice(mirror.length) : file;
|
|
277
|
+
}
|
|
278
|
+
async describe(session, script, original) {
|
|
279
|
+
if (session.descriptions.has(script.scriptId)) return session.descriptions.get(script.scriptId);
|
|
280
|
+
const { scriptSource: text } = await session.post("Debugger.getScriptSource", { scriptId: script.scriptId });
|
|
281
|
+
if (typeof text !== "string") throw new Error("missing edge source: " + script.url);
|
|
282
|
+
const metadata = session.scripts.get(script.scriptId);
|
|
283
|
+
const mapUrl = metadata?.sourceMapURL;
|
|
284
|
+
let sourceMap = null;
|
|
285
|
+
if (mapUrl) {
|
|
286
|
+
if (!mapUrl.startsWith("data:")) throw new Error("unsupported edge source map: " + mapUrl);
|
|
287
|
+
const comma = mapUrl.indexOf(",");
|
|
288
|
+
const encoded = mapUrl.slice(comma + 1);
|
|
289
|
+
const decoded = mapUrl.slice(0, comma).endsWith(";base64")
|
|
290
|
+
? new TextDecoder().decode(Uint8Array.from(atob(encoded), c => c.charCodeAt(0))) : decodeURIComponent(encoded);
|
|
291
|
+
const data = JSON.parse(decoded);
|
|
292
|
+
if (!Array.isArray(data.sources) || !Array.isArray(data.sourcesContent) || typeof data.mappings !== "string" || data.sections || data.sourceRoot) {
|
|
293
|
+
throw new Error("missing edge source-map identity: " + script.url);
|
|
294
|
+
}
|
|
295
|
+
// Edge Runtime's map sources are relative to the graph root, rather
|
|
296
|
+
// than the script directory. Validate their contents against the actual
|
|
297
|
+
// mounted sources before archiving the normalized identities.
|
|
298
|
+
data.sources = await finishAll(data.sources.map(async (name, i) => {
|
|
299
|
+
const source = new URL(name, fileUrl(session.base));
|
|
300
|
+
if (source.protocol !== "file:") throw new Error("unexpected mapped edge source: " + source.href);
|
|
301
|
+
const file = this.sourcePath(decodeURIComponent(source.pathname));
|
|
302
|
+
if (!file.startsWith(this.projectRoot + "/")) throw new Error("edge map escapes project: " + file);
|
|
303
|
+
if (await Deno.readTextFile(file) !== data.sourcesContent[i]) throw new Error("edge source content mismatch: " + file);
|
|
304
|
+
return fileUrl(file);
|
|
305
|
+
}));
|
|
306
|
+
delete data.sourcesContent;
|
|
307
|
+
delete data.sourceRoot;
|
|
308
|
+
sourceMap = { url: null, data };
|
|
309
|
+
} else {
|
|
310
|
+
// JavaScript may need no transpilation. TypeScript without a map must
|
|
311
|
+
// not silently attribute generated line numbers to authored source.
|
|
312
|
+
if (await Deno.readTextFile(original) !== text) throw new Error("edge source has no usable map: " + original);
|
|
313
|
+
}
|
|
314
|
+
const record = { url: fileUrl(original), sourceHash: await hash(text),
|
|
315
|
+
lineLengths: text.split(/[\n\u2028\u2029]/).map(line => line.length),
|
|
316
|
+
functions: script.functions.map(f => [f.functionName, f.ranges[0].startOffset, f.ranges[0].endOffset]), sourceMap };
|
|
317
|
+
const content = JSON.stringify({ spectestScript: record });
|
|
318
|
+
const descriptor = { id: await hash(content), content };
|
|
319
|
+
session.descriptions.set(script.scriptId, descriptor);
|
|
320
|
+
return descriptor;
|
|
321
|
+
}
|
|
322
|
+
async capture() {
|
|
323
|
+
if (this.capturing) throw new Error("edge coverage capture already in progress");
|
|
324
|
+
this.capturing = true;
|
|
325
|
+
try {
|
|
326
|
+
await this.ready();
|
|
327
|
+
if (this.error) throw new Error(this.error);
|
|
328
|
+
// Each isolate owns its counters. Take all deltas concurrently, and
|
|
329
|
+
// fetch independent script descriptors concurrently within each isolate.
|
|
330
|
+
// Join before merging so ordering/deduplication don't depend on timing.
|
|
331
|
+
const captures = await finishAll([...this.sessions.values()].map(async session => {
|
|
332
|
+
const dump = await session.post("Profiler.takePreciseCoverage");
|
|
333
|
+
return finishAll(dump.result.map(async script => {
|
|
334
|
+
if (!script.functions.some(f => f.ranges.some(r => r.count > 0))) return null;
|
|
335
|
+
if (INTERNAL.test(script.url)) return null;
|
|
336
|
+
if (script.url === "spectest:edge-coverage-bootstrap") return null;
|
|
337
|
+
if (/^(https?:|npm:|jsr:)/.test(script.url)) return { unit: "deno:" + script.url };
|
|
338
|
+
if (!script.url) throw new Error("anonymous executed edge script cannot be mapped");
|
|
339
|
+
const rawPath = this.originalPath(session, script.url);
|
|
340
|
+
if (rawPath === session.entry) return null; // the generated startup gate
|
|
341
|
+
const original = rawPath && this.sourcePath(rawPath);
|
|
342
|
+
if (original?.startsWith(this.generatedRoot + "/")) return null;
|
|
343
|
+
if (original?.includes("/node_modules/")) return { unit: "deno:" + script.url };
|
|
344
|
+
if (!original?.startsWith(this.projectRoot + "/")) throw new Error("unmapped edge script: " + script.url);
|
|
345
|
+
const descriptor = await this.describe(session, script, original);
|
|
346
|
+
return { descriptor, result: { url: fileUrl(original), spectestScript: descriptor.id,
|
|
347
|
+
functions: script.functions.filter(f => f.ranges.some(r => r.count > 0)).map(f => {
|
|
348
|
+
const start = f.ranges[0].startOffset;
|
|
349
|
+
return f.ranges.flatMap((r, i) => [i ? r.startOffset - start : start, r.endOffset - r.startOffset, r.count]);
|
|
350
|
+
}) } };
|
|
351
|
+
}));
|
|
352
|
+
}));
|
|
353
|
+
const reports = [], result = [], units = new Set();
|
|
354
|
+
for (const capture of captures) for (const item of capture) {
|
|
355
|
+
if (!item) continue;
|
|
356
|
+
if (item.unit) { units.add(item.unit); continue; }
|
|
357
|
+
const { descriptor } = item;
|
|
358
|
+
if (!this.records.has(descriptor.id)) {
|
|
359
|
+
reports.push({ name: "edge-scripts/" + descriptor.id + ".json", content: descriptor.content });
|
|
360
|
+
this.records.add(descriptor.id);
|
|
361
|
+
}
|
|
362
|
+
result.push(item.result);
|
|
363
|
+
}
|
|
364
|
+
if (this.error) throw new Error(this.error);
|
|
365
|
+
reports.push({ name: "edge-" + (++this.sequence) + ".json", content: JSON.stringify({ spectestVersion: 2, result, units: [...units] }) });
|
|
366
|
+
return { reports };
|
|
367
|
+
} catch (e) {
|
|
368
|
+
// A take resets V8 counters. If processing fails afterwards, subsequent
|
|
369
|
+
// captures cannot repair that gap. Keep the failure sticky on this fork.
|
|
370
|
+
this.fail(e);
|
|
371
|
+
throw e;
|
|
372
|
+
} finally { this.capturing = false; }
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
`;
|
|
@@ -65,6 +65,7 @@ import { microsoft } from "./microsoft.js";
|
|
|
65
65
|
import { SUPABASE_WEBHOOKS_SQL } from "./supabase-webhooks.js";
|
|
66
66
|
import { postgresTls } from "./postgres-tls.js";
|
|
67
67
|
import { supabasePoolerService } from "./supabase-pooler.js";
|
|
68
|
+
import { SUPABASE_COVERAGE_COLLECTOR, supabaseFunctionsCoverage } from "./supabase-coverage.js";
|
|
68
69
|
import { mapSupabaseConfig, parseSupabaseConfigFile, } from "./supabase-config.js";
|
|
69
70
|
// ──────────────────────────────────────────────────────────────────────────
|
|
70
71
|
// Pinned upstream image tags (github.com/supabase/supabase docker-compose.yml,
|
|
@@ -690,6 +691,8 @@ services:
|
|
|
690
691
|
// with, and Web Crypto verifies it in a few lines with no dependency.
|
|
691
692
|
// ──────────────────────────────────────────────────────────────────────────
|
|
692
693
|
const FUNCTIONS_MAIN_TS = String.raw `// spectest-generated edge-functions router. Do not edit.
|
|
694
|
+
import { EdgeCoverage } from "./coverage.js";
|
|
695
|
+
const edgeCoverage = new EdgeCoverage();
|
|
693
696
|
const FUNCTIONS_DIR = Deno.env.get("FUNCTIONS_DIR") ?? "/repo/supabase/functions";
|
|
694
697
|
const JWT_SECRET = Deno.env.get("JWT_SECRET") ?? "";
|
|
695
698
|
const VERIFY_JWT = Deno.env.get("VERIFY_JWT") === "true";
|
|
@@ -754,8 +757,10 @@ const WORKER_TIMEOUT_MS = 24 * 60 * 60 * 1000;
|
|
|
754
757
|
* isolates, and an isolate reaped for idleness is rebooted by the next
|
|
755
758
|
* test that calls the function — a boot's worth of dirtied memory in that
|
|
756
759
|
* test's own snapshot diff. */
|
|
757
|
-
function workerFor(name: string) {
|
|
760
|
+
async function workerFor(name: string) {
|
|
761
|
+
const maybeEntrypoint = await edgeCoverage.wrap(FUNCTIONS_DIR + "/" + name + "/index.ts");
|
|
758
762
|
return EdgeRuntime.userWorkers.create({
|
|
763
|
+
maybeEntrypoint,
|
|
759
764
|
servicePath: FUNCTIONS_DIR + "/" + name,
|
|
760
765
|
memoryLimitMb: 256,
|
|
761
766
|
workerTimeoutMs: WORKER_TIMEOUT_MS,
|
|
@@ -978,6 +983,7 @@ async function sharedWorker(): Promise<any | null> {
|
|
|
978
983
|
try {
|
|
979
984
|
await createShared();
|
|
980
985
|
} catch (e) {
|
|
986
|
+
await edgeCoverage.forgetUnstarted(SHARED_DIR + "/index.ts");
|
|
981
987
|
reason = "the shared isolate failed to boot: " + String(e);
|
|
982
988
|
}
|
|
983
989
|
}
|
|
@@ -989,8 +995,10 @@ async function sharedWorker(): Promise<any | null> {
|
|
|
989
995
|
return spec.reason === null ? createShared() : null;
|
|
990
996
|
}
|
|
991
997
|
|
|
992
|
-
function createShared() {
|
|
998
|
+
async function createShared() {
|
|
999
|
+
const maybeEntrypoint = await edgeCoverage.wrap(SHARED_DIR + "/index.ts");
|
|
993
1000
|
return EdgeRuntime.userWorkers.create({
|
|
1001
|
+
maybeEntrypoint,
|
|
994
1002
|
servicePath: SHARED_DIR,
|
|
995
1003
|
// A cap on the one isolate that holds every function, not a reservation.
|
|
996
1004
|
memoryLimitMb: 2048,
|
|
@@ -1018,8 +1026,8 @@ function createShared() {
|
|
|
1018
1026
|
async function warmAll(): Promise<{ mode: "shared" | "per-function"; warmed: string[]; failed: Record<string, string> }> {
|
|
1019
1027
|
const names = await functionNames();
|
|
1020
1028
|
if ((await sharedWorker()) !== null) return { mode: "shared", warmed: names, failed: {} };
|
|
1021
|
-
//
|
|
1022
|
-
//
|
|
1029
|
+
// Each worker starts behind a generated gate; attach and initialize them
|
|
1030
|
+
// concurrently, then release each only after precise coverage has started.
|
|
1023
1031
|
const results = await Promise.allSettled(names.map((name) => workerFor(name)));
|
|
1024
1032
|
const warmed: string[] = [];
|
|
1025
1033
|
const failed: Record<string, string> = {};
|
|
@@ -1036,8 +1044,16 @@ Deno.serve(async (req: Request) => {
|
|
|
1036
1044
|
// Ready probe — answered before any auth check, so bring-up never needs a
|
|
1037
1045
|
// token, and never treated as a function name.
|
|
1038
1046
|
if (pathname === "/_spectest/health") return new Response("ok");
|
|
1047
|
+
if (pathname === "/_spectest/coverage" && req.method === "POST") {
|
|
1048
|
+
try { return json(await edgeCoverage.capture()); }
|
|
1049
|
+
catch (error) { return json({ error: String(error) }, 500); }
|
|
1050
|
+
}
|
|
1039
1051
|
// Setup-time warm-up (see warmAll); never treated as a function name.
|
|
1040
|
-
if (pathname === "/_spectest/warm")
|
|
1052
|
+
if (pathname === "/_spectest/warm") {
|
|
1053
|
+
const warmed = await warmAll();
|
|
1054
|
+
await edgeCoverage.ready();
|
|
1055
|
+
return json(warmed);
|
|
1056
|
+
}
|
|
1041
1057
|
|
|
1042
1058
|
const name = pathname.split("/")[1] ?? "";
|
|
1043
1059
|
if (name === "") return json({ msg: "missing function name in request" }, 400);
|
|
@@ -1787,8 +1803,15 @@ CREATE SCHEMA IF NOT EXISTS _supavisor AUTHORIZATION supabase_admin;
|
|
|
1787
1803
|
volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true, reachExempt: true }],
|
|
1788
1804
|
// Our router (see FUNCTIONS_MAIN_TS) sits outside that mount, so
|
|
1789
1805
|
// the project never has to carry a `main/` function of ours.
|
|
1790
|
-
files: [
|
|
1791
|
-
|
|
1806
|
+
files: [
|
|
1807
|
+
{ path: "/home/deno/main/index.ts", content: FUNCTIONS_MAIN_TS },
|
|
1808
|
+
{ path: "/home/deno/main/coverage.js", content: SUPABASE_COVERAGE_COLLECTOR },
|
|
1809
|
+
],
|
|
1810
|
+
// Generated entry gates hold project imports until coverage starts;
|
|
1811
|
+
// the inspector stays live and is not exposed to peer containers.
|
|
1812
|
+
args: ["start", "--main-service", "/home/deno/main", "--port", "9000", "--inspect=127.0.0.1:9229"],
|
|
1813
|
+
workdir: REPO_MOUNT,
|
|
1814
|
+
coverage: { adapters: [supabaseFunctionsCoverage(fnRelPath)] },
|
|
1792
1815
|
env: {
|
|
1793
1816
|
JWT_SECRET: jwtSecret,
|
|
1794
1817
|
// Where the router looks for `<name>/`, inside the repo mount.
|
package/dist/index.d.ts
CHANGED
|
@@ -363,7 +363,8 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
363
363
|
*/
|
|
364
364
|
readonly fakes: FakeHandlesFor<F>;
|
|
365
365
|
/**
|
|
366
|
-
* Poll a predicate until it returns a
|
|
366
|
+
* Poll a predicate until it returns a value other than null, undefined,
|
|
367
|
+
* or false, then return that
|
|
367
368
|
* value. In a test it records one `wait` event for the whole loop (with
|
|
368
369
|
* attempt count, total duration, and the description) instead of one
|
|
369
370
|
* event per probe — useful for "wait until pod Running"-style checks
|
|
@@ -371,19 +372,20 @@ export interface SpectestContext<S extends ServicesMap = ServicesMap, F extends
|
|
|
371
372
|
* timeline; it's just the loop you'd otherwise hand-roll.
|
|
372
373
|
*
|
|
373
374
|
* - `null`, `undefined`, or `false` from `fn` mean "not yet" — wait
|
|
374
|
-
* `intervalMs` and try again.
|
|
375
|
+
* `intervalMs` and try again. A directly returned wrapped value is
|
|
376
|
+
* checked using its underlying value. Use `.unwrap()` when branching
|
|
377
|
+
* on wrapped primitives inside `fn`; wrapper objects are truthy.
|
|
375
378
|
* - Anything else is the success value and is returned, tagged with
|
|
376
379
|
* the wait event's seq. Downstream `expect(...)` on it links to
|
|
377
380
|
* the wait (one logical step), not to N suppressed HTTP calls.
|
|
378
|
-
* -
|
|
379
|
-
*
|
|
380
|
-
* abort.
|
|
381
|
+
* - Instrumented fetch transport failures are retried. Other throws
|
|
382
|
+
* from `fn` propagate immediately; the wait event records the abort.
|
|
381
383
|
* - Defaults: `timeoutMs = 30_000`, `intervalMs = 1_000`.
|
|
382
384
|
*
|
|
383
|
-
*
|
|
384
|
-
*
|
|
385
|
-
*
|
|
386
|
-
*
|
|
385
|
+
* The winning attempt's events, or the final failed attempt's events,
|
|
386
|
+
* are retained beneath the wait; earlier attempts are discarded. Use
|
|
387
|
+
* `ctx.poll` for read-only observation of the exact required state.
|
|
388
|
+
* Perform mutations once outside the poll so retries cannot repeat them.
|
|
387
389
|
*/
|
|
388
390
|
poll<T>(description: string, fn: () => T | null | undefined | false | Promise<T | null | undefined | false>, opts?: {
|
|
389
391
|
timeoutMs?: number;
|
|
@@ -1871,9 +1873,10 @@ export interface TextMatcherOptions extends TextMatchOptions {
|
|
|
1871
1873
|
}
|
|
1872
1874
|
/**
|
|
1873
1875
|
* Auto-retrying web-first assertions for a {@link Locator} — Playwright's
|
|
1874
|
-
* `expect(locator)` matchers.
|
|
1875
|
-
* deadline elapses (default
|
|
1876
|
-
*
|
|
1876
|
+
* `expect(locator)` matchers. Playwright re-resolves the locator until it
|
|
1877
|
+
* passes or a deadline elapses (default 10 s, `{ timeout }` overrides; 0
|
|
1878
|
+
* disables the assertion timeout). Records an assertion event just like a
|
|
1879
|
+
* value `expect`. `await` them — they are async.
|
|
1877
1880
|
*/
|
|
1878
1881
|
export interface LocatorMatchers {
|
|
1879
1882
|
/** The element is present and visible. */
|
|
@@ -1887,8 +1890,9 @@ export interface LocatorMatchers {
|
|
|
1887
1890
|
/**
|
|
1888
1891
|
* The element's text equals `expected`, or matches it when it is a RegExp.
|
|
1889
1892
|
*
|
|
1890
|
-
* **
|
|
1891
|
-
*
|
|
1893
|
+
* **String expectations normalize whitespace on both sides**, as Playwright
|
|
1894
|
+
* does. Regex expectations test the original text. String comparisons trim
|
|
1895
|
+
* the text and collapse every run of whitespace to one space.
|
|
1892
1896
|
* So a typed space matches the no-break space (U+00A0) that
|
|
1893
1897
|
* `Intl.NumberFormat` puts between thousands, and a value wrapped across
|
|
1894
1898
|
* two lines in the markup matches the one-line string you wrote.
|
|
@@ -1899,7 +1903,8 @@ export interface LocatorMatchers {
|
|
|
1899
1903
|
toHaveText(expected: string | RegExp | Array<string | RegExp>, opts?: TextMatcherOptions): Promise<void>;
|
|
1900
1904
|
/**
|
|
1901
1905
|
* The element's text contains `expected` — a substring, or a RegExp tested
|
|
1902
|
-
* against the text.
|
|
1906
|
+
* against the text. Strings normalize whitespace as in {@link toHaveText};
|
|
1907
|
+
* regexes test the original text.
|
|
1903
1908
|
*
|
|
1904
1909
|
* An **array** asserts that the matched elements contain these texts in
|
|
1905
1910
|
* order; unlike `toHaveText` extra elements between them are allowed.
|
|
@@ -1930,8 +1935,8 @@ export interface LocatorAssertion extends LocatorMatchers {
|
|
|
1930
1935
|
/**
|
|
1931
1936
|
* Auto-retrying assertions for a browser/mobile **session** — Playwright's
|
|
1932
1937
|
* `expect(page)` matchers. Like the locator ones, they poll until the
|
|
1933
|
-
* condition holds or the deadline elapses (default
|
|
1934
|
-
* assertion under a settled browser step.
|
|
1938
|
+
* condition holds or the deadline elapses (default 10 s; 0 disables the
|
|
1939
|
+
* assertion timeout) and record one assertion under a settled browser step.
|
|
1935
1940
|
*/
|
|
1936
1941
|
export interface BrowserMatchers {
|
|
1937
1942
|
/**
|