@specific.dev/spectest 0.86.2 → 0.88.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/dist/browser.d.ts +7 -0
- package/dist/browser.js +102 -10
- package/dist/components/aws.d.ts +8 -7
- package/dist/components/aws.js +6 -0
- package/dist/components/electron-chrome.d.ts +1 -0
- package/dist/components/electron-chrome.js +76 -0
- package/dist/components/electron.d.ts +46 -0
- package/dist/components/electron.js +99 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/supabase-functions-reach.d.ts +18 -0
- package/dist/components/supabase-functions-reach.js +155 -0
- package/dist/components/supabase.js +33 -5
- package/dist/daemon.js +79 -17
- package/dist/desktop-recorder.d.ts +1 -0
- package/dist/desktop-recorder.js +30 -0
- package/dist/desktop.d.ts +42 -0
- package/dist/desktop.js +10 -0
- package/dist/harness/coverage.d.ts +9 -3
- package/dist/harness/coverage.js +21 -3
- package/dist/harness/reach.d.ts +33 -2
- package/dist/harness/reach.js +111 -9
- package/dist/harness/wrapper-rules.d.ts +13 -0
- package/dist/harness/wrapper-rules.js +37 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +5 -0
- package/dist/recorder.d.ts +2 -0
- package/package.json +1 -1
- package/src/browser.ts +106 -10
- package/src/components/aws.ts +6 -0
- package/src/components/electron-chrome.ts +76 -0
- package/src/components/electron.test.ts +38 -0
- package/src/components/electron.ts +115 -0
- package/src/components/index.ts +1 -0
- package/src/components/supabase-functions-reach.test.ts +90 -0
- package/src/components/supabase-functions-reach.ts +153 -0
- package/src/components/supabase.ts +33 -5
- package/src/daemon.ts +90 -21
- package/src/desktop-recorder.test.ts +55 -0
- package/src/desktop-recorder.ts +30 -0
- package/src/desktop.ts +43 -0
- package/src/harness/coverage.ts +20 -3
- package/src/harness/reach.test.ts +79 -1
- package/src/harness/reach.ts +118 -9
- package/src/harness/wrapper-rules.test.ts +26 -0
- package/src/harness/wrapper-rules.ts +39 -0
- package/src/index.ts +33 -0
- package/src/inputs.test.ts +20 -0
- package/src/recorder.ts +2 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
|
|
6
|
+
import { excludeAllBut, functionsReach, importMapLocalTargets, importSpecifiers } from "./supabase-functions-reach.js";
|
|
7
|
+
|
|
8
|
+
let root: string;
|
|
9
|
+
|
|
10
|
+
function file(rel: string, text: string): void {
|
|
11
|
+
mkdirSync(join(root, rel, ".."), { recursive: true });
|
|
12
|
+
writeFileSync(join(root, rel), text);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
beforeAll(() => {
|
|
16
|
+
root = mkdtempSync(join(tmpdir(), "sb-fn-reach-"));
|
|
17
|
+
file("supabase/functions/hello/index.ts", 'import { greet } from "../../../packages/shared/src/greet.ts";\nimport "npm:zod";\nDeno.serve(() => new Response(greet()));\n');
|
|
18
|
+
file("supabase/functions/bye/index.ts", 'import { db } from "@db/client.ts";\nexport default 1;\n');
|
|
19
|
+
file("supabase/functions/deno.json", JSON.stringify({ imports: { "@db/": "../../lib/db/", "zod": "npm:zod" } }));
|
|
20
|
+
file("packages/shared/src/greet.ts", 'import { fmt } from "./fmt.ts";\nimport { util } from "../../util/mod.ts";\nexport const greet = () => fmt(util());\n');
|
|
21
|
+
file("packages/shared/src/fmt.ts", "export const fmt = (s: string) => s;\n");
|
|
22
|
+
file("packages/util/mod.ts", "export const util = () => 'x';\n");
|
|
23
|
+
file("lib/db/client.ts", "export const db = 1;\n");
|
|
24
|
+
file("docs/guide.md", "# not code\n");
|
|
25
|
+
file("site/app/page.tsx", 'import x from "../../packages/shared/src/greet.ts";\n');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
|
29
|
+
|
|
30
|
+
describe("functionsReach", () => {
|
|
31
|
+
test("follows relative imports and local import-map entries transitively, into directories", () => {
|
|
32
|
+
expect(functionsReach(root, "supabase/functions")).toEqual(["lib/db", "packages/shared/src", "packages/util", "supabase/functions"]);
|
|
33
|
+
});
|
|
34
|
+
test("a self-contained functions directory is the whole answer; a missing one is nothing", () => {
|
|
35
|
+
file("alone/functions/f/index.ts", 'import "https://deno.land/std/http/server.ts";\nimport("./helper.ts");\n');
|
|
36
|
+
expect(functionsReach(root, "alone/functions")).toEqual(["alone/functions"]);
|
|
37
|
+
expect(functionsReach(root, "nowhere/functions")).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
test("an import that leaves the project is ignored", () => {
|
|
40
|
+
file("escape/functions/f/index.ts", 'import "../../../../../../etc/passwd";\n');
|
|
41
|
+
expect(functionsReach(root, "escape/functions")).toEqual(["escape/functions"]);
|
|
42
|
+
});
|
|
43
|
+
test("the exclude list drops everything but the answer", () => {
|
|
44
|
+
expect(excludeAllBut(["lib/db", "supabase/functions"])).toEqual([
|
|
45
|
+
"**",
|
|
46
|
+
"!lib/db",
|
|
47
|
+
"!lib/db/**",
|
|
48
|
+
"!supabase/functions",
|
|
49
|
+
"!supabase/functions/**",
|
|
50
|
+
"**/node_modules/**",
|
|
51
|
+
"**/.git/**",
|
|
52
|
+
"**/.spectest/**",
|
|
53
|
+
"**/__pycache__/**",
|
|
54
|
+
"**/.venv/**",
|
|
55
|
+
]);
|
|
56
|
+
expect(excludeAllBut(["."])).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("parsers", () => {
|
|
61
|
+
test("import specifiers of every shape", () => {
|
|
62
|
+
const t = 'import a from "./a.ts";\nexport * from "../b.ts";\nimport "./side.ts";\nconst m = await import("./dyn.ts");\nimport type { T } from "npm:x";\n';
|
|
63
|
+
expect(importSpecifiers(t)).toEqual(["./a.ts", "../b.ts", "./side.ts", "./dyn.ts", "npm:x"]);
|
|
64
|
+
});
|
|
65
|
+
test("import map local targets", () => {
|
|
66
|
+
expect(importMapLocalTargets('{"imports":{"@a/":"../../a/","b":"npm:b","c":"/c/mod.ts"}}')).toEqual(["../../a/", "/c/mod.ts"]);
|
|
67
|
+
expect(importMapLocalTargets("not json")).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("supabase() wiring", () => {
|
|
72
|
+
test("the functions image context is the module graph, and the root mount is reach-exempt", async () => {
|
|
73
|
+
const prev = process.env.SPECTEST_WORKSPACE;
|
|
74
|
+
process.env.SPECTEST_WORKSPACE = root;
|
|
75
|
+
try {
|
|
76
|
+
const { supabase } = await import("./supabase.js");
|
|
77
|
+
const stack = supabase({ dir: "supabase" });
|
|
78
|
+
const parts = stack.group.services({ name: "sb", key: (part: string) => (part === "gateway" ? "sb" : `sb-${part}`) });
|
|
79
|
+
const fn = parts.functions as { image: { context?: string; exclude?: readonly string[] }; volumes?: readonly { source?: string; reachExempt?: boolean }[] };
|
|
80
|
+
expect(fn.image.context).toBe(".");
|
|
81
|
+
expect(fn.image.exclude?.slice(0, 9)).toEqual(["**", "!lib/db", "!lib/db/**", "!packages/shared/src", "!packages/shared/src/**", "!packages/util", "!packages/util/**", "!supabase/functions", "!supabase/functions/**"]);
|
|
82
|
+
expect(fn.image.exclude?.at(-1)).toBe("**/.venv/**");
|
|
83
|
+
expect(fn.volumes?.[0]).toMatchObject({ source: root, reachExempt: true });
|
|
84
|
+
expect((parts.db as { inputs?: readonly string[] }).inputs).toEqual(["supabase"]);
|
|
85
|
+
} finally {
|
|
86
|
+
if (prev === undefined) delete process.env.SPECTEST_WORKSPACE;
|
|
87
|
+
else process.env.SPECTEST_WORKSPACE = prev;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// What the edge functions can load from the project — the pure part.
|
|
2
|
+
//
|
|
3
|
+
// The edge runtime serves each function from the project root, mounted
|
|
4
|
+
// whole so that a monorepo function's `../../packages/shared/mod.ts`
|
|
5
|
+
// resolves where it sits. Whole is right for the runtime and wrong for
|
|
6
|
+
// the reach: with the root in a build context and a volume, every file of
|
|
7
|
+
// the project could "reach" the functions, and a change to `docs/` could
|
|
8
|
+
// never skip a run. So the component works out the **module graph** the
|
|
9
|
+
// functions can load, statically, and hands the build context and the
|
|
10
|
+
// reach exactly that: the functions directory, and every directory a
|
|
11
|
+
// relative import or a local import-map entry leads to, followed
|
|
12
|
+
// transitively. Directories rather than files, so a sibling module a
|
|
13
|
+
// dynamic `import()` picks at run time comes along.
|
|
14
|
+
//
|
|
15
|
+
// What it cannot see: a computed specifier that leaves the directories
|
|
16
|
+
// found (`import(\`../../${x}/mod.ts\`)`), and a `Deno.readTextFile` of a
|
|
17
|
+
// path outside them. Both are rare in a function, and both are the same
|
|
18
|
+
// class of hole as a test reading the workspace through `node:fs`. A tree
|
|
19
|
+
// too large to walk within the budget is answered with the whole project,
|
|
20
|
+
// the safe side.
|
|
21
|
+
|
|
22
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
23
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
24
|
+
|
|
25
|
+
/** Files the walk parses for imports. */
|
|
26
|
+
const SOURCE_EXT = /\.(?:[mc]?[jt]sx?)$/;
|
|
27
|
+
/** Names never walked into. */
|
|
28
|
+
const SKIP = new Set(["node_modules", ".git", ".spectest", "dist", "build", ".next"]);
|
|
29
|
+
/** The walk gives up past this many files and answers with the root. */
|
|
30
|
+
const FILE_BUDGET = 20_000;
|
|
31
|
+
|
|
32
|
+
const IMPORT_RE =
|
|
33
|
+
/\b(?:import|export)\b[^;'"`]*?\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]|\bimport\(\s*['"]([^'"]+)['"]\s*\)/g;
|
|
34
|
+
|
|
35
|
+
/** Specifiers a source text imports, as written. */
|
|
36
|
+
export function importSpecifiers(text: string): string[] {
|
|
37
|
+
const out: string[] = [];
|
|
38
|
+
for (const m of text.matchAll(IMPORT_RE)) out.push(m[1] ?? m[2] ?? m[3] ?? "");
|
|
39
|
+
return out.filter((s) => s.length > 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Local targets an import map (`deno.json` `imports`, `import_map.json`)
|
|
43
|
+
* names, as written: relative values, and root-relative `/…` ones. */
|
|
44
|
+
export function importMapLocalTargets(text: string): string[] {
|
|
45
|
+
let parsed: unknown;
|
|
46
|
+
try {
|
|
47
|
+
parsed = JSON.parse(text);
|
|
48
|
+
} catch {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
const imports = (parsed as { imports?: unknown })?.imports;
|
|
52
|
+
if (typeof imports !== "object" || imports === null) return [];
|
|
53
|
+
return Object.values(imports as Record<string, unknown>).filter(
|
|
54
|
+
(v): v is string => typeof v === "string" && (v.startsWith("./") || v.startsWith("../") || v.startsWith("/")),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toPosix(p: string): string {
|
|
59
|
+
return sep === "/" ? p : p.split(sep).join("/");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The directories, project-relative and sorted, that the functions under
|
|
64
|
+
* `fnRelPath` can load modules from: `fnRelPath` itself, and every
|
|
65
|
+
* directory a relative import or a local import-map entry leads to,
|
|
66
|
+
* transitively. `["."]` when the walk runs out of budget. A directory
|
|
67
|
+
* nested under another in the answer is dropped.
|
|
68
|
+
*/
|
|
69
|
+
export function functionsReach(root: string, fnRelPath: string): string[] {
|
|
70
|
+
const rootAbs = resolve(root);
|
|
71
|
+
const dirs = new Set<string>();
|
|
72
|
+
const queue: string[] = [resolve(rootAbs, fnRelPath)];
|
|
73
|
+
let budget = FILE_BUDGET;
|
|
74
|
+
|
|
75
|
+
const inRoot = (p: string): boolean => p === rootAbs || p.startsWith(rootAbs + sep);
|
|
76
|
+
const enqueue = (target: string): void => {
|
|
77
|
+
if (!inRoot(target) || !existsSync(target)) return;
|
|
78
|
+
let st;
|
|
79
|
+
try {
|
|
80
|
+
st = statSync(target);
|
|
81
|
+
} catch {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const dir = st.isDirectory() ? target : dirname(target);
|
|
85
|
+
if (![...dirs].some((d) => dir === d || dir.startsWith(d + sep))) queue.push(dir);
|
|
86
|
+
};
|
|
87
|
+
const follow = (fromDir: string, spec: string): void => {
|
|
88
|
+
if (spec.startsWith("./") || spec.startsWith("../")) enqueue(resolve(fromDir, spec));
|
|
89
|
+
else if (spec.startsWith("/")) enqueue(resolve(rootAbs, spec.slice(1)));
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
while (queue.length > 0) {
|
|
93
|
+
const dir = queue.shift()!;
|
|
94
|
+
if ([...dirs].some((d) => dir === d || dir.startsWith(d + sep))) continue;
|
|
95
|
+
if (!existsSync(dir)) continue;
|
|
96
|
+
dirs.add(dir);
|
|
97
|
+
// Every file below it, parsed for what it imports.
|
|
98
|
+
const stack: string[] = [dir];
|
|
99
|
+
while (stack.length > 0) {
|
|
100
|
+
const d = stack.pop()!;
|
|
101
|
+
let entries;
|
|
102
|
+
try {
|
|
103
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
for (const e of entries) {
|
|
108
|
+
const p = join(d, e.name);
|
|
109
|
+
if (e.isDirectory()) {
|
|
110
|
+
if (!SKIP.has(e.name)) stack.push(p);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!e.isFile()) continue;
|
|
114
|
+
if (--budget < 0) return ["."];
|
|
115
|
+
if (e.name === "deno.json" || e.name === "import_map.json") {
|
|
116
|
+
for (const t of importMapLocalTargets(safeRead(p))) follow(d, t.endsWith("/") ? t.slice(0, -1) : t);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (!SOURCE_EXT.test(e.name)) continue;
|
|
120
|
+
for (const spec of importSpecifiers(safeRead(p))) follow(d, spec);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Minimal, project-relative, sorted.
|
|
126
|
+
const rel = [...dirs].map((d) => (d === rootAbs ? "." : toPosix(relative(rootAbs, d))));
|
|
127
|
+
const minimal = rel.filter((d) => !rel.some((o) => o !== d && (o === "." || d.startsWith(o + "/"))));
|
|
128
|
+
return [...new Set(minimal)].sort();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function safeRead(p: string): string {
|
|
132
|
+
try {
|
|
133
|
+
return readFileSync(p, "utf8");
|
|
134
|
+
} catch {
|
|
135
|
+
return "";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The `exclude` list that narrows a root build context to `dirs`: drop
|
|
140
|
+
* everything, then re-include each directory and its subtree, then drop
|
|
141
|
+
* again what no context should carry (a re-included `packages/shared`
|
|
142
|
+
* would otherwise bring its `node_modules` along). */
|
|
143
|
+
export function excludeAllBut(dirs: readonly string[]): string[] {
|
|
144
|
+
if (dirs.includes(".")) return [];
|
|
145
|
+
return ["**", ...dirs.flatMap((d) => [`!${d}`, `!${d}/**`]), ...REEXCLUDE];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Re-excluded after the re-includes: the names the default ignore drops. */
|
|
149
|
+
const REEXCLUDE = ["**/node_modules/**", "**/.git/**", "**/.spectest/**", "**/__pycache__/**", "**/.venv/**"];
|
|
150
|
+
|
|
151
|
+
// Keep `isAbsolute` referenced for callers that pass absolute fnRelPath by
|
|
152
|
+
// mistake: the walk resolves it against the root either way.
|
|
153
|
+
void isAbsolute;
|
|
@@ -51,6 +51,7 @@
|
|
|
51
51
|
|
|
52
52
|
import { createHmac } from "node:crypto";
|
|
53
53
|
import { existsSync, readFileSync } from "node:fs";
|
|
54
|
+
import { excludeAllBut, functionsReach } from "./supabase-functions-reach.js";
|
|
54
55
|
import { readdir, stat } from "node:fs/promises";
|
|
55
56
|
import { dirname, join } from "node:path";
|
|
56
57
|
|
|
@@ -166,6 +167,9 @@ function providerCredentials(
|
|
|
166
167
|
* cache rides into the image as a layer — so at run time the worker resolves
|
|
167
168
|
* those URLs locally and never reaches for the network.
|
|
168
169
|
*
|
|
170
|
+
* The context is the module graph the functions can load (the functions
|
|
171
|
+
* directory and what its imports lead to, `functionsReach`), copied whole
|
|
172
|
+
* so a monorepo function's `../../packages/…` resolves at bundle time too.
|
|
169
173
|
* One synthetic entrypoint imports every function, so the whole set is one
|
|
170
174
|
* graph rather than one per function. Bundling is best-effort: a function that
|
|
171
175
|
* fails to bundle (a type error, an import that only resolves at run time)
|
|
@@ -175,13 +179,13 @@ function providerCredentials(
|
|
|
175
179
|
function functionsDockerfile(fnRelPath: string): string {
|
|
176
180
|
return `FROM ${IMAGES.functions}
|
|
177
181
|
ENV DENO_DIR=${DENO_DIR}
|
|
178
|
-
COPY
|
|
182
|
+
COPY . /warm/repo
|
|
179
183
|
RUN set -eu; \
|
|
180
184
|
: > /warm/all.ts; \
|
|
181
|
-
for d in /warm/
|
|
185
|
+
for d in /warm/repo/${fnRelPath}/*/; do \
|
|
182
186
|
[ -f "$d/index.ts" ] || continue; \
|
|
183
187
|
n=$(basename "$d"); \
|
|
184
|
-
printf 'import "./
|
|
188
|
+
printf 'import "./repo/${fnRelPath}/%s/index.ts";\\n' "$n" >> /warm/all.ts; \
|
|
185
189
|
done; \
|
|
186
190
|
edge-runtime bundle --entrypoint /warm/all.ts --output /tmp/warm.eszip --quiet || true; \
|
|
187
191
|
rm -rf /tmp/warm.eszip /warm
|
|
@@ -1684,6 +1688,10 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1684
1688
|
? `${REPO_MOUNT}/${fnDir.slice(root.length + 1)}`
|
|
1685
1689
|
: `${REPO_MOUNT}/supabase/functions`;
|
|
1686
1690
|
const migrationsPath = inDir("migrations");
|
|
1691
|
+
// The directories the functions can load modules from: the functions
|
|
1692
|
+
// directory and what its imports lead to (`supabase-functions-reach.ts`).
|
|
1693
|
+
// The build context and the reach are narrowed to exactly these.
|
|
1694
|
+
const fnReachDirs = withFunctions ? functionsReach(root, fnRelPath) : [];
|
|
1687
1695
|
|
|
1688
1696
|
// ── config file (config.toml / config.json) ───────────────────────────
|
|
1689
1697
|
// The CLI's own project config, mapped onto the stack. Read at build time:
|
|
@@ -1890,8 +1898,20 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
1890
1898
|
const parts: ServicesMap = {};
|
|
1891
1899
|
|
|
1892
1900
|
// ── db ──────────────────────────────────────────────────────────
|
|
1901
|
+
// What the stack reads from the project: the Supabase folder
|
|
1902
|
+
// (config.toml, migrations, seed, functions) and the mail templates,
|
|
1903
|
+
// which may sit anywhere in the repo. Declared as inputs so a change
|
|
1904
|
+
// to any of them is never taken as one that can affect no test; the
|
|
1905
|
+
// migrations volume carries the folder too, but only when there is
|
|
1906
|
+
// a migrations directory, and a config-only project must count as
|
|
1907
|
+
// well.
|
|
1908
|
+
const inputs: string[] = [
|
|
1909
|
+
...(opts.dir !== undefined && !opts.dir.startsWith("/") ? [opts.dir] : []),
|
|
1910
|
+
...templateBodies.map(([, spec]) => spec.path).filter((t) => !t.startsWith("/")),
|
|
1911
|
+
];
|
|
1893
1912
|
const dbDef: ServiceDefinition = {
|
|
1894
1913
|
image: { type: "registry", reference: opts.db?.image ?? IMAGES.db },
|
|
1914
|
+
...(inputs.length > 0 ? { inputs } : {}),
|
|
1895
1915
|
// `args` (not `command`): keep the image's docker-entrypoint.sh so it
|
|
1896
1916
|
// runs initdb + its baked init-scripts before starting.
|
|
1897
1917
|
args: [
|
|
@@ -2211,6 +2231,12 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
2211
2231
|
image: {
|
|
2212
2232
|
type: "dockerfile",
|
|
2213
2233
|
content: functionsDockerfile(fnRelPath),
|
|
2234
|
+
// The root, narrowed to the module graph: only the functions
|
|
2235
|
+
// directory and what its imports lead to enter the context, so
|
|
2236
|
+
// a change anywhere else neither uploads nor counts as reaching
|
|
2237
|
+
// the functions.
|
|
2238
|
+
context: ".",
|
|
2239
|
+
exclude: excludeAllBut(fnReachDirs),
|
|
2214
2240
|
},
|
|
2215
2241
|
// The **project root**, mounted with the functions directory in its
|
|
2216
2242
|
// real position underneath. Mounting only `supabase/functions`
|
|
@@ -2219,8 +2245,10 @@ export function supabase(opts: SupabaseOptions = {}): SupabaseStack {
|
|
|
2219
2245
|
// (`../../../../packages/…`), and that only resolves if the
|
|
2220
2246
|
// directory sits where it sits in the repo. Read-only: the runtime
|
|
2221
2247
|
// only reads sources, and a function writing into its own directory
|
|
2222
|
-
// would be writing into the user's checkout.
|
|
2223
|
-
|
|
2248
|
+
// would be writing into the user's checkout. For the reach the
|
|
2249
|
+
// mount is exempt: what the functions can load is the module graph
|
|
2250
|
+
// above, which the build context already states.
|
|
2251
|
+
volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true, reachExempt: true }],
|
|
2224
2252
|
// Our router (see FUNCTIONS_MAIN_TS) sits outside that mount, so
|
|
2225
2253
|
// the project never has to carry a `main/` function of ours.
|
|
2226
2254
|
files: [{ path: "/home/deno/main/index.ts", content: FUNCTIONS_MAIN_TS }],
|
package/src/daemon.ts
CHANGED
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
walkProjectFiles,
|
|
49
49
|
ENVIRONMENT_SERVICE,
|
|
50
50
|
REACH_REPORT,
|
|
51
|
+
READS_REPORT,
|
|
51
52
|
type CoverageBundleRef,
|
|
52
53
|
type CoverageReport,
|
|
53
54
|
type ServiceCoverageCapture,
|
|
@@ -66,8 +67,9 @@ import {
|
|
|
66
67
|
type CoverageInventoryContext,
|
|
67
68
|
} from "./coverage.js";
|
|
68
69
|
import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
|
|
69
|
-
import { acquirePersistentBrowser, mobileKey } from "./browser.js";
|
|
70
|
+
import { acquirePersistentBrowser, acquirePersistentDesktop, mobileKey } from "./browser.js";
|
|
70
71
|
import { isMobileApp, openPersistentMobile } from "./mobile.js";
|
|
72
|
+
import { isDesktopApp, type Desktop, type DesktopApp, type DesktopOptions, type DesktopChrome } from "./desktop.js";
|
|
71
73
|
// Pure ingress hostname matching, ported out of this file (see
|
|
72
74
|
// harness/hostmatch.ts). Keeping ONE implementation is the point: the
|
|
73
75
|
// exact-then-longest-suffix rule and the one-label certificate rule are
|
|
@@ -97,11 +99,11 @@ import {
|
|
|
97
99
|
sanitizeSegment,
|
|
98
100
|
} from "./harness/volume-paths.js";
|
|
99
101
|
import { pollUntilReady } from "./harness/ready-poll.js";
|
|
100
|
-
import { runWrapperRules } from "./harness/wrapper-rules.js";
|
|
102
|
+
import { fsUsageDiagnostics, runWrapperRules } from "./harness/wrapper-rules.js";
|
|
101
103
|
import type { WrapperDiagnostic } from "./harness/wrapper-rules.js";
|
|
102
104
|
import { cpus, tmpdir } from "node:os";
|
|
103
105
|
import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
|
|
104
|
-
import { projectReach,
|
|
106
|
+
import { compactReach, projectReach, renderReachRules, renderReadsLcov, type ReachRules, type ReachSource } from "./harness/reach.js";
|
|
105
107
|
import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
|
|
106
108
|
import {
|
|
107
109
|
bindInstrumentationScope,
|
|
@@ -4237,7 +4239,7 @@ async function writeCoverageInventory(svc: NamedService): Promise<string | undef
|
|
|
4237
4239
|
* bring-up capture. The resolution's own path lookups are bookkeeping,
|
|
4238
4240
|
* not reads: they are taken out of the read set again afterwards.
|
|
4239
4241
|
*/
|
|
4240
|
-
async function computeProjectReach(all: NamedService[]): Promise<
|
|
4242
|
+
async function computeProjectReach(all: NamedService[]): Promise<ReachRules> {
|
|
4241
4243
|
const before = takeProjectReads();
|
|
4242
4244
|
try {
|
|
4243
4245
|
return await computeProjectReachInner(all);
|
|
@@ -4248,12 +4250,14 @@ async function computeProjectReach(all: NamedService[]): Promise<string[]> {
|
|
|
4248
4250
|
}
|
|
4249
4251
|
}
|
|
4250
4252
|
|
|
4251
|
-
async function computeProjectReachInner(all: NamedService[]): Promise<
|
|
4253
|
+
async function computeProjectReachInner(all: NamedService[]): Promise<ReachRules> {
|
|
4252
4254
|
const files = await walkProjectFiles(WORKSPACE, [".git"]);
|
|
4253
4255
|
const sources: ReachSource[] = [];
|
|
4254
4256
|
for (const svc of all) {
|
|
4255
4257
|
const src: ReachSource = {
|
|
4256
|
-
|
|
4258
|
+
service: svc.name,
|
|
4259
|
+
volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
|
|
4260
|
+
...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
|
|
4257
4261
|
};
|
|
4258
4262
|
if (svc.image.type === "dockerfile") {
|
|
4259
4263
|
try {
|
|
@@ -4270,29 +4274,35 @@ async function computeProjectReachInner(all: NamedService[]): Promise<string[]>
|
|
|
4270
4274
|
}
|
|
4271
4275
|
sources.push(src);
|
|
4272
4276
|
}
|
|
4273
|
-
|
|
4277
|
+
const reach = projectReach(files, sources);
|
|
4278
|
+
return { version: 1, sources, prefixes: compactReach(files, reach) };
|
|
4274
4279
|
}
|
|
4275
4280
|
|
|
4276
4281
|
/**
|
|
4277
4282
|
* The synthetic `__environment__` capture: what the environment itself
|
|
4278
|
-
* knows — the project's reach (bring-up only) and the project
|
|
4279
|
-
* harness read through the SDK since the previous capture, as
|
|
4280
|
-
* lcov records. Present
|
|
4281
|
-
* the
|
|
4283
|
+
* knows — the project's reach as rules (bring-up only) and the project
|
|
4284
|
+
* files the harness read through the SDK since the previous capture, as
|
|
4285
|
+
* whole lcov records. Present on every capture of every project, coverage
|
|
4286
|
+
* adapters or not: the reach is a property of the environment config,
|
|
4287
|
+
* and it is what lets the control plane tell that a change to `site/**`
|
|
4288
|
+
* can affect no test at all. When services opted in, the two ride the
|
|
4289
|
+
* same bundle as their reports.
|
|
4282
4290
|
*/
|
|
4283
4291
|
async function captureEnvironmentCoverage(all: NamedService[], baseline: boolean): Promise<ServiceCoverageCapture> {
|
|
4284
4292
|
const reports: CoverageReport[] = [];
|
|
4285
4293
|
if (baseline) {
|
|
4286
4294
|
try {
|
|
4287
|
-
const
|
|
4288
|
-
reports.push({ name: REACH_REPORT, content:
|
|
4289
|
-
console.log(
|
|
4295
|
+
const rules = await computeProjectReach(all);
|
|
4296
|
+
reports.push({ name: REACH_REPORT, content: renderReachRules(rules) });
|
|
4297
|
+
console.log(
|
|
4298
|
+
`[coverage] project reach: ${rules.sources.length} source(s), ${rules.prefixes.length} prefix(es) some build or volume can carry`,
|
|
4299
|
+
);
|
|
4290
4300
|
} catch (err) {
|
|
4291
4301
|
console.warn(`[coverage] project reach not computed: ${err instanceof Error ? err.message : String(err)}`);
|
|
4292
4302
|
}
|
|
4293
4303
|
}
|
|
4294
4304
|
const reads = takeProjectReads();
|
|
4295
|
-
reports.push({ name:
|
|
4305
|
+
reports.push({ name: READS_REPORT, content: renderReadsLcov(reads) });
|
|
4296
4306
|
return { service: ENVIRONMENT_SERVICE, reports };
|
|
4297
4307
|
}
|
|
4298
4308
|
|
|
@@ -4304,7 +4314,8 @@ async function captureServiceCoverage(baseline = false): Promise<ServiceCoverage
|
|
|
4304
4314
|
for (const [name, s] of RUNTIME_SERVICES) byName.set(name, s);
|
|
4305
4315
|
const all = [...byName.values()];
|
|
4306
4316
|
const services = all.filter((s) => s.coverage !== undefined);
|
|
4307
|
-
|
|
4317
|
+
// The environment's own capture is unconditional (see above); the
|
|
4318
|
+
// services' come from the ones that opted in, none included.
|
|
4308
4319
|
const environment = captureEnvironmentCoverage(all, baseline);
|
|
4309
4320
|
const captures = await Promise.all(
|
|
4310
4321
|
services.map(async (svc): Promise<ServiceCoverageCapture> => {
|
|
@@ -4343,10 +4354,11 @@ interface BrowserSessionRecord {
|
|
|
4343
4354
|
openedAtMs: number;
|
|
4344
4355
|
closedAtMs?: number;
|
|
4345
4356
|
initialUrl?: string;
|
|
4346
|
-
/** Replay frame: `"browser"` (
|
|
4347
|
-
* (
|
|
4357
|
+
/** Replay frame: `"browser"` (default), `"mobile"` (phone bezel), or
|
|
4358
|
+
* `"desktop"` (native app window). Drives the dashboard's chrome; the rrweb events are
|
|
4348
4359
|
* identical either way. */
|
|
4349
|
-
frame?: "browser" | "mobile";
|
|
4360
|
+
frame?: "browser" | "mobile" | "desktop";
|
|
4361
|
+
desktopChrome?: DesktopChrome;
|
|
4350
4362
|
/** Safe-area insets that were CDP-emulated during capture (mobile
|
|
4351
4363
|
* sessions on a Chromium that supports the override). The dashboard
|
|
4352
4364
|
* substitutes these for `env(safe-area-inset-*)` in the replayed CSS —
|
|
@@ -4454,7 +4466,7 @@ function browserArgs(
|
|
|
4454
4466
|
function newBrowserSession(
|
|
4455
4467
|
testStart: number,
|
|
4456
4468
|
idScope: string,
|
|
4457
|
-
frame: "browser" | "mobile" = "browser",
|
|
4469
|
+
frame: "browser" | "mobile" | "desktop" = "browser",
|
|
4458
4470
|
artifacts?: ReturnType<typeof newArtifactCollector>,
|
|
4459
4471
|
name = "",
|
|
4460
4472
|
): {
|
|
@@ -4477,6 +4489,7 @@ function newBrowserSession(
|
|
|
4477
4489
|
record,
|
|
4478
4490
|
recorder: {
|
|
4479
4491
|
sessionId: record.sessionId,
|
|
4492
|
+
frame,
|
|
4480
4493
|
...(name ? { sessionName: name } : {}),
|
|
4481
4494
|
recordStep(step) {
|
|
4482
4495
|
if (closed) return;
|
|
@@ -4498,6 +4511,37 @@ function newBrowserSession(
|
|
|
4498
4511
|
};
|
|
4499
4512
|
}
|
|
4500
4513
|
|
|
4514
|
+
/** Same desktop recording path for test runs and interactive evals. */
|
|
4515
|
+
function desktopOpener(
|
|
4516
|
+
start: number,
|
|
4517
|
+
scope: string,
|
|
4518
|
+
sessions: Array<ReturnType<typeof newBrowserSession>>,
|
|
4519
|
+
detaches: Array<() => Promise<void>>,
|
|
4520
|
+
artifacts?: ReturnType<typeof newArtifactCollector>,
|
|
4521
|
+
) {
|
|
4522
|
+
const memo = new Map<string, Desktop>();
|
|
4523
|
+
return async (app: DesktopApp, options: DesktopOptions = {}): Promise<Desktop> => {
|
|
4524
|
+
if (!isDesktopApp(app)) throw new Error("ctx.desktop(app): pass ctx.svc.app from an electron() service");
|
|
4525
|
+
const window = options.window ?? 0;
|
|
4526
|
+
const key = JSON.stringify([app.endpoint, window]);
|
|
4527
|
+
const existing = memo.get(key);
|
|
4528
|
+
if (existing) return existing;
|
|
4529
|
+
const session = newBrowserSession(start, scope, "desktop", artifacts, window === 0 ? app.name : `${app.name} · window ${window + 1}`);
|
|
4530
|
+
const { browser, detach, desktopChrome } = await acquirePersistentDesktop(app.endpoint, window, session.recorder);
|
|
4531
|
+
session.record.desktopChrome = desktopChrome;
|
|
4532
|
+
session.record.initialUrl = browser.url();
|
|
4533
|
+
sessions.push(session);
|
|
4534
|
+
detaches.push(async () => { await detach(); session.markClosed(); });
|
|
4535
|
+
const close = browser.close.bind(browser);
|
|
4536
|
+
browser.close = async () => {
|
|
4537
|
+
await close();
|
|
4538
|
+
if (memo.get(key) === browser) memo.delete(key);
|
|
4539
|
+
};
|
|
4540
|
+
memo.set(key, browser);
|
|
4541
|
+
return browser;
|
|
4542
|
+
};
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4501
4545
|
/** Per-Terminal asciicast session shipped to the control plane. */
|
|
4502
4546
|
interface TerminalSessionRecord {
|
|
4503
4547
|
sessionId: string;
|
|
@@ -5590,6 +5634,7 @@ async function runOne(testCase: TestCase<unknown>): Promise<RunResult> {
|
|
|
5590
5634
|
openTerminal: recordedOpenTerminal,
|
|
5591
5635
|
browser: trackedOpenBrowser,
|
|
5592
5636
|
mobile: trackedOpenMobile,
|
|
5637
|
+
desktop: desktopOpener(start, testCase.id, sessions, browserDetaches),
|
|
5593
5638
|
mcp: openMcp,
|
|
5594
5639
|
testName: testCase.name,
|
|
5595
5640
|
parent,
|
|
@@ -6145,6 +6190,7 @@ async function evalCode(
|
|
|
6145
6190
|
openTerminal: evalOpenTerminal,
|
|
6146
6191
|
browser: trackedOpenBrowser,
|
|
6147
6192
|
mobile: trackedOpenMobile,
|
|
6193
|
+
desktop: desktopOpener(start, "eval", sessions, browserDetaches, artifactCollector),
|
|
6148
6194
|
mcp: openMcp,
|
|
6149
6195
|
testName: "eval",
|
|
6150
6196
|
parent: undefined,
|
|
@@ -6415,7 +6461,7 @@ async function runTypecheck(): Promise<TypecheckReport> {
|
|
|
6415
6461
|
return { status: "failed", errors: [], totalErrors: 0, durationMs, detail: "typecheck timed out" };
|
|
6416
6462
|
}
|
|
6417
6463
|
const { errors, total, suppressed } = parseTscOutput(res.stdout + res.stderr);
|
|
6418
|
-
const wrapper = await runWrapperRulesReport(config);
|
|
6464
|
+
const wrapper = [...(await runWrapperRulesReport(config)), ...(await rawFsReport())];
|
|
6419
6465
|
const strip = ({ file, line, column, code, message }: WrapperDiagnostic): TypecheckError => ({
|
|
6420
6466
|
file,
|
|
6421
6467
|
line,
|
|
@@ -6453,6 +6499,29 @@ async function runTypecheck(): Promise<TypecheckReport> {
|
|
|
6453
6499
|
};
|
|
6454
6500
|
}
|
|
6455
6501
|
|
|
6502
|
+
/**
|
|
6503
|
+
* The raw-fs rule (`SPECTEST2003`, advisory) over the test tree: a test
|
|
6504
|
+
* that opens a project file through `node:fs` hides a dependence from the
|
|
6505
|
+
* subset selector. Textual, so it needs no compiler; best-effort.
|
|
6506
|
+
*/
|
|
6507
|
+
async function rawFsReport(): Promise<WrapperDiagnostic[]> {
|
|
6508
|
+
const root = path.join(APP_DIR, "tests");
|
|
6509
|
+
if (!existsSync(root)) return [];
|
|
6510
|
+
try {
|
|
6511
|
+
const files = await walkProjectFiles(root, ["node_modules"]);
|
|
6512
|
+
const out: WrapperDiagnostic[] = [];
|
|
6513
|
+
for (const rel of files) {
|
|
6514
|
+
if (!/\.(ts|tsx|js|jsx|mts|cts)$/.test(rel)) continue;
|
|
6515
|
+
const text = await fs.readFile(path.join(root, rel), "utf8");
|
|
6516
|
+
out.push(...fsUsageDiagnostics(path.join("tests", rel), text));
|
|
6517
|
+
}
|
|
6518
|
+
return out;
|
|
6519
|
+
} catch (err) {
|
|
6520
|
+
console.warn(`[typecheck] raw fs rule threw: ${(err as Error)?.message ?? err}`);
|
|
6521
|
+
return [];
|
|
6522
|
+
}
|
|
6523
|
+
}
|
|
6524
|
+
|
|
6456
6525
|
/**
|
|
6457
6526
|
* The blocking rules, run against the BAKED compiler whatever the project
|
|
6458
6527
|
* pins. Best-effort in every direction: an install that predates the API, a
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test';
|
|
2
|
+
import { runInNewContext } from 'node:vm';
|
|
3
|
+
import { DESKTOP_INPUT_SYNC_SCRIPT } from './desktop-recorder.js';
|
|
4
|
+
|
|
5
|
+
function recorder(type = 'textarea', blocked = false) {
|
|
6
|
+
const element = { type, value: 'draft', checked: false, hasAttribute: (_name: string) => false, closest: () => blocked ? {} : null };
|
|
7
|
+
const window: any = { __spectestRec: { mirror: { getId: () => 7 } }, __spectestRrwebEvents: [] };
|
|
8
|
+
runInNewContext(DESKTOP_INPUT_SYNC_SCRIPT, { window, document: { querySelectorAll: () => [element] } });
|
|
9
|
+
return { element, window, events: window.__spectestRrwebEvents as any[] };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test('late attachment captures a controlled input clearing without a DOM input event', () => {
|
|
13
|
+
const { element, window, events } = recorder();
|
|
14
|
+
expect(events).toHaveLength(0);
|
|
15
|
+
element.value = '';
|
|
16
|
+
window.__spectestSyncInputs(123);
|
|
17
|
+
expect(JSON.parse(JSON.stringify(events))).toEqual([
|
|
18
|
+
{ type: 3, data: { source: 5, id: 7, text: '', isChecked: false }, timestamp: 123 },
|
|
19
|
+
]);
|
|
20
|
+
window.__spectestSyncInputs(124);
|
|
21
|
+
expect(events).toHaveLength(1);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('rrweb input events need no duplicate correction; a snapshot does not hide later changes', () => {
|
|
25
|
+
const { element, window, events } = recorder();
|
|
26
|
+
element.value = 'typed';
|
|
27
|
+
events.push({ type: 3, data: { source: 5, id: 7, text: 'typed', isChecked: false } });
|
|
28
|
+
window.__spectestSyncInputs();
|
|
29
|
+
expect(events).toHaveLength(1);
|
|
30
|
+
events.length = 0;
|
|
31
|
+
events.push({ type: 2, data: {} });
|
|
32
|
+
element.value = '';
|
|
33
|
+
window.__spectestSyncInputs();
|
|
34
|
+
expect(events).toHaveLength(2);
|
|
35
|
+
expect(events[1].data.text).toBe('');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('correction respects password masking, blocked controls and file inputs', () => {
|
|
39
|
+
const password = recorder('password');
|
|
40
|
+
password.element.value = 'secret-value';
|
|
41
|
+
password.window.__spectestSyncInputs();
|
|
42
|
+
expect(password.events[0].data.text).toBe('************');
|
|
43
|
+
expect(JSON.stringify(password.events)).not.toContain('secret-value');
|
|
44
|
+
const revealed = recorder('text');
|
|
45
|
+
revealed.element.hasAttribute = name => name === 'data-rr-is-password';
|
|
46
|
+
revealed.element.value = 'revealed-secret';
|
|
47
|
+
revealed.window.__spectestSyncInputs();
|
|
48
|
+
expect(revealed.events[0].data.text).toBe('***************');
|
|
49
|
+
expect(JSON.stringify(revealed.events)).not.toContain('revealed-secret');
|
|
50
|
+
for (const fixture of [recorder('textarea', true), recorder('file')]) {
|
|
51
|
+
fixture.element.value = 'private';
|
|
52
|
+
fixture.window.__spectestSyncInputs();
|
|
53
|
+
expect(fixture.events).toHaveLength(0);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// A late recorder cannot interpose on setters React cached during mounting.
|
|
2
|
+
// Reconcile controls at action boundaries, without changing the app's DOM or
|
|
3
|
+
// descriptors. Normal rrweb Input events win; synthesize only missing changes.
|
|
4
|
+
export const DESKTOP_INPUT_SYNC_SCRIPT = String.raw`(() => {
|
|
5
|
+
if (window.__spectestSyncInputs) return;
|
|
6
|
+
const known = new WeakMap();
|
|
7
|
+
window.__spectestSyncInputs = (timestamp = Date.now()) => {
|
|
8
|
+
const rec = window.__spectestRec;
|
|
9
|
+
const events = window.__spectestRrwebEvents;
|
|
10
|
+
if (!rec?.mirror || !events) return;
|
|
11
|
+
const inputs = new Map();
|
|
12
|
+
for (const event of events) {
|
|
13
|
+
if (event.type === 3 && event.data?.source === 5) inputs.set(event.data.id, event.data);
|
|
14
|
+
}
|
|
15
|
+
for (const element of document.querySelectorAll('input, textarea, select')) {
|
|
16
|
+
const type = element.hasAttribute('data-rr-is-password') ? 'password' : element.type?.toLowerCase();
|
|
17
|
+
if (type === 'file' || element.closest('.rr-block, .rr-ignore')) continue;
|
|
18
|
+
const id = rec.mirror.getId(element);
|
|
19
|
+
if (!(id > 0)) continue;
|
|
20
|
+
const text = type === 'password' ? '*'.repeat(element.value.length) : element.value;
|
|
21
|
+
const isChecked = (type === 'checkbox' || type === 'radio') ? element.checked : false;
|
|
22
|
+
const current = { text, isChecked };
|
|
23
|
+
const previous = inputs.get(id) ?? known.get(element);
|
|
24
|
+
known.set(element, current);
|
|
25
|
+
if (!previous || (previous.text === text && previous.isChecked === isChecked)) continue;
|
|
26
|
+
events.push({ type: 3, data: { source: 5, id, text, isChecked }, timestamp });
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
window.__spectestSyncInputs();
|
|
30
|
+
})();`;
|