@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.
Files changed (49) hide show
  1. package/dist/browser.d.ts +7 -0
  2. package/dist/browser.js +102 -10
  3. package/dist/components/aws.d.ts +8 -7
  4. package/dist/components/aws.js +6 -0
  5. package/dist/components/electron-chrome.d.ts +1 -0
  6. package/dist/components/electron-chrome.js +76 -0
  7. package/dist/components/electron.d.ts +46 -0
  8. package/dist/components/electron.js +99 -0
  9. package/dist/components/index.d.ts +1 -0
  10. package/dist/components/index.js +1 -0
  11. package/dist/components/supabase-functions-reach.d.ts +18 -0
  12. package/dist/components/supabase-functions-reach.js +155 -0
  13. package/dist/components/supabase.js +33 -5
  14. package/dist/daemon.js +79 -17
  15. package/dist/desktop-recorder.d.ts +1 -0
  16. package/dist/desktop-recorder.js +30 -0
  17. package/dist/desktop.d.ts +42 -0
  18. package/dist/desktop.js +10 -0
  19. package/dist/harness/coverage.d.ts +9 -3
  20. package/dist/harness/coverage.js +21 -3
  21. package/dist/harness/reach.d.ts +33 -2
  22. package/dist/harness/reach.js +111 -9
  23. package/dist/harness/wrapper-rules.d.ts +13 -0
  24. package/dist/harness/wrapper-rules.js +37 -0
  25. package/dist/index.d.ts +26 -0
  26. package/dist/index.js +5 -0
  27. package/dist/recorder.d.ts +2 -0
  28. package/package.json +1 -1
  29. package/src/browser.ts +106 -10
  30. package/src/components/aws.ts +6 -0
  31. package/src/components/electron-chrome.ts +76 -0
  32. package/src/components/electron.test.ts +38 -0
  33. package/src/components/electron.ts +115 -0
  34. package/src/components/index.ts +1 -0
  35. package/src/components/supabase-functions-reach.test.ts +90 -0
  36. package/src/components/supabase-functions-reach.ts +153 -0
  37. package/src/components/supabase.ts +33 -5
  38. package/src/daemon.ts +90 -21
  39. package/src/desktop-recorder.test.ts +55 -0
  40. package/src/desktop-recorder.ts +30 -0
  41. package/src/desktop.ts +43 -0
  42. package/src/harness/coverage.ts +20 -3
  43. package/src/harness/reach.test.ts +79 -1
  44. package/src/harness/reach.ts +118 -9
  45. package/src/harness/wrapper-rules.test.ts +26 -0
  46. package/src/harness/wrapper-rules.ts +39 -0
  47. package/src/index.ts +33 -0
  48. package/src/inputs.test.ts +20 -0
  49. 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 ${fnRelPath} /warm/functions
182
+ COPY . /warm/repo
179
183
  RUN set -eu; \
180
184
  : > /warm/all.ts; \
181
- for d in /warm/functions/*/; do \
185
+ for d in /warm/repo/${fnRelPath}/*/; do \
182
186
  [ -f "$d/index.ts" ] || continue; \
183
187
  n=$(basename "$d"); \
184
- printf 'import "./functions/%s/index.ts";\\n' "$n" >> /warm/all.ts; \
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
- volumes: [{ source: projectRootPath(), target: REPO_MOUNT, readOnly: true }],
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, renderReach, renderReadsLcov, type ReachSource } from "./harness/reach.js";
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<string[]> {
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<string[]> {
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
- volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source ? [v.source] : [])),
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
- return projectReach(files, sources);
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 files the
4279
- * harness read through the SDK since the previous capture, as whole
4280
- * lcov records. Present whenever some service opted in, so the two ride
4281
- * the same bundle as the services' reports.
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 reach = await computeProjectReach(all);
4288
- reports.push({ name: REACH_REPORT, content: renderReach(reach) });
4289
- console.log(`[coverage] project reach: ${reach.length} file(s) some build or volume can carry`);
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: "reads.lcov", content: renderReadsLcov(reads) });
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
- if (services.length === 0) return [];
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"` (desktop window, default) or `"mobile"`
4347
- * (phone bezel). Drives the dashboard's chrome; the rrweb events are
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
+ })();`;