@specific.dev/spectest 0.131.20 → 0.132.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 (55) hide show
  1. package/dist/browser.js +4 -2
  2. package/dist/change-rules.d.ts +45 -0
  3. package/dist/change-rules.js +179 -0
  4. package/dist/components/k3s-coverage.js +4 -4
  5. package/dist/components/supabase-coverage.js +12 -0
  6. package/dist/components/supabase.js +25 -12
  7. package/dist/coverage-bun.js +2 -1
  8. package/dist/coverage-next.d.ts +5 -0
  9. package/dist/coverage-next.js +27 -3
  10. package/dist/coverage-rust.d.ts +2 -0
  11. package/dist/coverage-rust.js +20 -0
  12. package/dist/coverage.d.ts +27 -3
  13. package/dist/coverage.js +68 -15
  14. package/dist/daemon.js +13 -10
  15. package/dist/harness/component-images.d.ts +5 -0
  16. package/dist/harness/component-images.js +15 -2
  17. package/dist/harness/coverage-target.d.ts +2 -0
  18. package/dist/harness/coverage-target.js +3 -2
  19. package/dist/harness/reach.d.ts +6 -1
  20. package/dist/harness/reach.js +8 -34
  21. package/dist/harness/shadow-builder.d.ts +2 -1
  22. package/dist/harness/shadow-builder.js +9 -3
  23. package/dist/index.d.ts +8 -0
  24. package/dist/index.js +5 -0
  25. package/dist/vendor/rrweb-record.min.js +2 -17
  26. package/package.json +1 -1
  27. package/src/browser-coverage-navigation.test.ts +10 -2
  28. package/src/browser-image-loading.test.ts +51 -0
  29. package/src/browser.ts +4 -2
  30. package/src/change-rules.fixtures.json +35 -0
  31. package/src/change-rules.test.ts +125 -0
  32. package/src/change-rules.ts +147 -0
  33. package/src/components/k3s-coverage.test.ts +32 -0
  34. package/src/components/k3s-coverage.ts +4 -4
  35. package/src/components/supabase-coverage.ts +12 -0
  36. package/src/components/supabase-dir.test.ts +26 -0
  37. package/src/components/supabase-functions-reach.test.ts +41 -1
  38. package/src/components/supabase-migrations.test.ts +1 -1
  39. package/src/components/supabase.ts +26 -12
  40. package/src/coverage-bun.ts +2 -1
  41. package/src/coverage-next.ts +29 -3
  42. package/src/coverage-rust.test.ts +34 -0
  43. package/src/coverage-rust.ts +22 -0
  44. package/src/coverage.test.ts +2 -1
  45. package/src/coverage.ts +76 -7
  46. package/src/daemon.ts +14 -9
  47. package/src/harness/component-images.test.ts +42 -0
  48. package/src/harness/component-images.ts +20 -3
  49. package/src/harness/coverage-target.ts +5 -2
  50. package/src/harness/reach.test.ts +40 -6
  51. package/src/harness/reach.ts +13 -3
  52. package/src/harness/shadow-builder.test.ts +12 -1
  53. package/src/harness/shadow-builder.ts +10 -3
  54. package/src/index.ts +11 -0
  55. package/src/vendor/rrweb-record.min.js +2 -17
@@ -0,0 +1,51 @@
1
+ import { expect, test } from "bun:test";
2
+ import { readRaw } from "./inspect.js";
3
+ import { openBrowser } from "./browser.js";
4
+
5
+ const available = !!(Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"));
6
+ const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64");
7
+
8
+ for (const late of [false, true]) {
9
+ test.skipIf(!available)(`SDK recording preserves cookie-protected cross-origin images (${late ? "late" : "initial"})`, async () => {
10
+ const imageRequests: string[] = [];
11
+ const images = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(request) {
12
+ // The SDK also fetches assets for replay. Count actual <img> requests
13
+ // separately so the regression catches recorder-induced image reloads.
14
+ if (request.headers.get("sec-fetch-dest") === "image") imageRequests.push(request.headers.get("cookie") ?? "");
15
+ if (!request.headers.get("cookie")?.includes("image_session=allowed")) return new Response("", { status: 401 });
16
+ return new Response(png, { headers: { "content-type": "image/png", "cache-control": "no-store" } });
17
+ } });
18
+ const page = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch() {
19
+ return new Response(`<!doctype html><body><script>
20
+ document.cookie = 'image_session=allowed; path=/';
21
+ window.loads = 0; window.errors = 0; window.changes = [];
22
+ window.addImage = () => {
23
+ const img = document.createElement('img'); img.id = 'subject';
24
+ new MutationObserver(records => window.changes.push(...records.map(r => r.attributeName)))
25
+ .observe(img, {attributes:true, attributeFilter:['crossorigin']});
26
+ img.onload = () => window.loads++; img.onerror = () => window.errors++;
27
+ img.src = ${JSON.stringify(images.url.href)}; document.body.append(img);
28
+ };
29
+ ${late ? "" : "window.addImage();"}
30
+ </script>`, { headers: { "content-type": "text/html" } });
31
+ } });
32
+ const browser = await openBrowser({ recorder: { sessionId: `image-${late}`, recordStep() {} } });
33
+ try {
34
+ await browser.goto(page.url.href);
35
+ if (late) await browser.evaluate("Insert image after recording starts", "window.addImage()");
36
+ // Wait through load and recorder callbacks, including asynchronous retries.
37
+ await browser.evaluate("Allow image capture to finish", "new Promise(resolve => setTimeout(resolve, 500))");
38
+ const state = await browser.evaluate("Inspect live image", `({
39
+ attr: document.querySelector('#subject').getAttribute('crossorigin'),
40
+ width: document.querySelector('#subject').naturalWidth,
41
+ loads: window.loads, errors: window.errors, changes: window.changes,
42
+ recording: !!window.__spectestRrwebInit
43
+ })`);
44
+ expect(readRaw(state)).toEqual({ attr: null, width: 1, loads: 1, errors: 0, changes: [], recording: true });
45
+ expect(imageRequests).toEqual(["image_session=allowed"]);
46
+ } finally {
47
+ await browser.close();
48
+ page.stop(true); images.stop(true);
49
+ }
50
+ }, 15000);
51
+ }
package/src/browser.ts CHANGED
@@ -672,7 +672,8 @@ async function applySafeAreaInsets(
672
672
 
673
673
  // Vendored `@rrweb/record` UMD bundle (rrweb 2.x), BUILT FROM OUR FORK by
674
674
  // `scripts/rrweb-record/build.sh` (specific-dev/rrweb, branch `spectest` =
675
- // the `rrweb@2.1.0` tag + upstream PR 1802). Never copy the npm dist over
675
+ // the `rrweb@2.1.0` tag + upstream PR 1802 + non-mutating image capture).
676
+ // Never copy the npm dist over
676
677
  // it: upstream's `@rrweb/utils` lists `querySelector` as an accessor, so its
677
678
  // pristine-prototype test always fails and `record()` appends an <iframe>
678
679
  // to every document — measured at ~1 s per navigation (see the top-frame
@@ -766,7 +767,8 @@ const RRWEB_BOOTSTRAP = `
766
767
  // CSS (what next/font emits), so it's a no-op for most apps; the
767
768
  // url()-based fonts are handled by the inliner below instead. All
768
769
  // degrade gracefully — an asset that can't be read is skipped,
769
- // never fatal to recording.
770
+ // never fatal to recording. Our fork never changes a live image's
771
+ // crossorigin attribute to retry a failed canvas read.
770
772
  inlineStylesheet: true,
771
773
  collectFonts: true,
772
774
  inlineImages: true,
@@ -0,0 +1,35 @@
1
+ [
2
+ {"pattern":"**/*.ts","path":"helper.ts","matches":true},
3
+ {"pattern":"**/*.ts","path":"a/b/helper.ts","matches":true},
4
+ {"pattern":"*.ts","path":"a/helper.ts","matches":false},
5
+ {"pattern":"src/**","path":"src/a/b.ts","matches":true},
6
+ {"pattern":"src","path":"src/a.ts","matches":false},
7
+ {"pattern":"app/\\[id\\]/page.tsx","path":"app/[id]/page.tsx","matches":true},
8
+ {"pattern":"app/[[]id]/page.tsx","path":"app/[id]/page.tsx","matches":true},
9
+ {"pattern":"app/[id]/page.tsx","path":"app/[id]/page.tsx","matches":false},
10
+ {"pattern":"app/[id]/page.tsx","path":"app/i/page.tsx","matches":true},
11
+ {"pattern":"app/(shop)/\\[...slug\\]/page.tsx","path":"app/(shop)/[...slug]/page.tsx","matches":true},
12
+ {"pattern":"a?b.ts","path":"a😀b.ts","matches":true},
13
+ {"pattern":"a?b.ts","path":"a/b.ts","matches":false},
14
+ {"pattern":"[!a].ts","path":"b.ts","matches":true},
15
+ {"pattern":"[!a].ts","path":"a.ts","matches":false},
16
+ {"pattern":"[!a].ts","path":"/.ts","matches":false},
17
+ {"pattern":"[a-c].ts","path":"b.ts","matches":true},
18
+ {"pattern":"[a-c].ts","path":"d.ts","matches":false},
19
+ {"pattern":"[😀-🙏].ts","path":"😂.ts","matches":true},
20
+ {"pattern":"**/*.md","path":"a/line\nbreak.md","matches":true},
21
+ {"pattern":"file.ts","path":"file.ts\n","matches":false},
22
+ {"pattern":"!odd#name:foo+.ts","path":"!odd#name:foo+.ts","matches":true},
23
+ {"pattern":" leading space.ts","path":" leading space.ts","matches":true},
24
+ {"pattern":"file\\*.ts","path":"file*.ts","matches":true},
25
+ {"pattern":"*.{ts,js}","path":"a.ts","matches":false},
26
+ {"pattern":"a/[z-a]","invalid":true},
27
+ {"pattern":"a/[.-0]","invalid":true},
28
+ {"pattern":"a/[abc","invalid":true},
29
+ {"pattern":"a/[]","invalid":true},
30
+ {"pattern":"a/\\","invalid":true},
31
+ {"pattern":"foo**bar.ts","invalid":true},
32
+ {"pattern":"../secret","invalid":true},
33
+ {"pattern":"/absolute","invalid":true},
34
+ {"pattern":"a//b","invalid":true}
35
+ ]
@@ -0,0 +1,125 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import fixtures from "./change-rules.fixtures.json";
3
+ import { compileChangeGlob, matchesChangeGlob, evaluateChangePolicy, resolveChangePolicy, COMMON_CHANGE_RULES, NODE_CHANGE_RULES, NEXT_CHANGE_RULES, type ChangeRule } from "./change-rules.js";
4
+ import { node, next, bun, browser, rust, command, applyCoverageAdapters, serviceChangePolicy, defineAdapter, resolveCoverageSourceOptions, inventoryProjectFiles } from "./coverage.js";
5
+ const image = {type: "registry" as const, reference: "node:24"};
6
+ const action = (adapter: ReturnType<typeof node>, path: string) => evaluateChangePolicy(serviceChangePolicy({coverage:{adapters:[adapter]}}), path);
7
+ describe("portable change glob", () => {
8
+ for (const fixture of fixtures) test(JSON.stringify(fixture.pattern) + " " + (fixture.path ?? "invalid"), () => {
9
+ if (fixture.invalid) expect(() => compileChangeGlob(fixture.pattern)).toThrow();
10
+ else expect(matchesChangeGlob(fixture.pattern, fixture.path!)).toBe(fixture.matches!);
11
+ });
12
+ });
13
+ describe("change rule defaults and overrides", () => {
14
+ test("documentation defaults work without coverage, with false opt-out", () => {
15
+ const svc = applyCoverageAdapters("db", {image});
16
+ expect(svc).toEqual({image});
17
+ for (const path of ["AGENTS.md", "nested/CLAUDE.md", "notes.MD", "a/readme.markdown"]) expect(evaluateChangePolicy(serviceChangePolicy(svc), path)).toBe("ignore");
18
+ expect(evaluateChangePolicy(serviceChangePolicy({changeRules:false}), "AGENTS.md")).toBeUndefined();
19
+ expect(evaluateChangePolicy(serviceChangePolicy(svc), "app.mdx")).toBeUndefined();
20
+ });
21
+ test("JS test patterns, configuration and roots", () => {
22
+ for (const adapter of [node(), bun(), browser(), next()]) {
23
+ for (const path of ["src/math.test.ts", "src/math.spec.tsx", "src/math_test.mjs", "src/math_spec.cts", "src/a.test-d.ts", "src/a.cy.tsx", "__tests__/helper.json", "__mocks__/fetch.ts", "__snapshots__/a.snap", "tests/helper.ts", "jest.config.ts", "vitest.config.mjs"]) expect(action(adapter, path)).toBe("ignore");
24
+ for (const path of ["src/a.ts", "src/a.jsx", "src/a.mts"]) expect(action(adapter, path)).toBe("coverage");
25
+ for (const path of ["migrations/001.ts", "webpack.config.ts", "tsconfig.json", ".env.local"]) expect(action(adapter, path)).toBe("full");
26
+ }
27
+ expect(action(node({sourceRoot:"packages/api"}), "packages/api/test/a.ts")).toBe("ignore");
28
+ expect(action(node({sourceRoot:"packages/api"}), "packages/other/a.ts")).toBeUndefined();
29
+ expect(action(bun(), "bunfig.toml")).toBe("full");
30
+ });
31
+ test("Next specializations survive any adapter ordering", () => {
32
+ for (const adapters of [[next(),node(),browser()],[browser(),node(),next()]]) {
33
+ const policy = serviceChangePolicy({coverage:{adapters}});
34
+ for (const path of ["app/layout.tsx", "app/error.tsx", "proxy.ts", "instrumentation-client.ts", "mdx-components.tsx", "pages/_app.tsx", "next.config.mjs", "app/content.mdx"]) expect(evaluateChangePolicy(policy,path)).toBe("full");
35
+ for (const path of ["app/hello/page.tsx", "app/api/new/route.ts", "pages/a.test.tsx"]) expect(evaluateChangePolicy(policy,path)).toBe("coverage");
36
+ }
37
+ });
38
+ test("Rust defaults distinguish tests, build inputs and production", () => {
39
+ for (const path of ["tests/integration.rs", "benches/perf.rs"]) expect(action(rust(),path)).toBe("ignore");
40
+ for (const path of ["src/lib.rs", "src/tests.rs", "examples/server.rs"]) expect(action(rust(),path)).toBe("coverage");
41
+ for (const path of ["Cargo.toml", "Cargo.lock", "build.rs", ".cargo/config.toml", "rust-toolchain.toml", "src/bin/server.rs"]) expect(action(rust(),path)).toBe("full");
42
+ });
43
+ test("service rules are ordered final overrides and adapter options replace defaults", () => {
44
+ const rules: ChangeRule[] = [{pattern:"**/*.ts",action:"ignore"}, {pattern:"migrations/**",action:"full"}, {pattern:String.raw`migrations/\[seed\].ts`,action:"coverage"}];
45
+ const svc = applyCoverageAdapters("api", {image,coverage:{adapters:[node()]},changeRules:rules});
46
+ const twice = applyCoverageAdapters("api", svc);
47
+ for (const configured of [svc,twice]) {
48
+ const policy = serviceChangePolicy(configured);
49
+ expect(evaluateChangePolicy(policy,"a.ts")).toBe("ignore");
50
+ expect(evaluateChangePolicy(policy,"migrations/a.ts")).toBe("full");
51
+ expect(evaluateChangePolicy(policy,"migrations/[seed].ts")).toBe("coverage");
52
+ expect(JSON.parse(JSON.stringify(configured)).changeRules).toEqual(rules);
53
+ expect(JSON.parse(JSON.stringify(policy))).toEqual(policy);
54
+ }
55
+ for (const factory of [node,next,browser,bun,rust]) {
56
+ expect(action(factory({changeRules:false}),"src/a.ts")).toBeUndefined();
57
+ expect(action(factory({changeRules:[{pattern:"**/*.go",action:"coverage"}]}),"src/a.go")).toBe("coverage");
58
+ }
59
+ expect(Object.isFrozen(NODE_CHANGE_RULES)).toBe(true);
60
+ expect(Object.isFrozen(NEXT_CHANGE_RULES)).toBe(true);
61
+ expect(Object.isFrozen(COMMON_CHANGE_RULES)).toBe(true);
62
+ });
63
+ test("dynamic source roots need explicit policies; wrappers retain independent groups", () => {
64
+ const dynamic = defineAdapter({name:"dynamic",ownsReport:()=>true,sourceOptions:async()=>({sourceRoot:"runtime"}),changeRules:[{pattern:"**/*.ts",action:"coverage" as const}]});
65
+ expect(()=>serviceChangePolicy({coverage:{adapters:[dynamic]}})).toThrow();
66
+ const policy = serviceChangePolicy({coverage:{adapters:[{...dynamic,changeRules:dynamic.changeRules!.map(r=>({...r,root:"src"}))}]}});
67
+ expect(evaluateChangePolicy(policy,"src/a.ts")).toBe("coverage");
68
+ const nested = resolveChangePolicy({},[{changeRuleGroups:[[{root:"app",pattern:"**/*.ts",action:"coverage"}],[{root:"app",pattern:"shared.ts",action:"full"}]]}]);
69
+ expect(evaluateChangePolicy(nested,"app/shared.ts")).toBe("full");
70
+ expect(action(command("collect"),"src/a.ts")).toBeUndefined();
71
+ });
72
+ test("Spectest execution files stay active while nearby Markdown is ignored", () => {
73
+ const policy = serviceChangePolicy({coverage:{adapters:[node()]}});
74
+ expect(evaluateChangePolicy(policy,"spectest/helper.test.ts")).toBe("full");
75
+ expect(evaluateChangePolicy(policy,"spectest/tests/__mocks__/helper.ts")).toBe("coverage");
76
+ expect(evaluateChangePolicy(policy,"spectest/tests/AGENTS.md")).toBe("ignore");
77
+ expect(evaluateChangePolicy(policy,"migrations/README.md")).toBe("ignore");
78
+ expect(action(rust(),"src/bin/README.md")).toBe("ignore");
79
+ });
80
+ test("Next capture freezes configured route extensions and rejects divergent runtimes", async () => {
81
+ const adapter = next();
82
+ const svc = {coverage:{adapters:[adapter]}};
83
+ const missing = serviceChangePolicy(svc,{requireResolved:true});
84
+ expect(evaluateChangePolicy(missing,"pages/README.md")).toBe("full");
85
+ expect(evaluateChangePolicy(missing,"src/a.test.ts")).toBe("full");
86
+ const nested = next({sourceRoot:"apps/web"});
87
+ const unknownNested = serviceChangePolicy({coverage:{adapters:[nested]}},{requireResolved:true});
88
+ expect(evaluateChangePolicy(unknownNested,"apps/web/a.ts")).toBe("full");
89
+ expect(evaluateChangePolicy(unknownNested,"other/a.ts")).toBeUndefined();
90
+ const context = (extensions: string[], appRoot = "") => ({service:"web",signal:new AbortController().signal,
91
+ projectFiles:async()=>[],readProjectFile:async()=>null,
92
+ exec:async()=>({stdout:JSON.stringify({generated:null,serverMaps:1,browserMaps:1,pageExtensions:extensions,appRoot}),stderr:""})});
93
+ await resolveCoverageSourceOptions([adapter],context(["tsx","md"]));
94
+ const policy = serviceChangePolicy(svc,{requireResolved:true});
95
+ expect(evaluateChangePolicy(policy,"pages/a.md")).toBe("coverage");
96
+ expect(evaluateChangePolicy(policy,"pages/a.test.tsx")).toBe("coverage");
97
+ expect(evaluateChangePolicy(policy,"pages/a.test.ts")).toBe("ignore");
98
+ expect(evaluateChangePolicy(policy,"app/new/page.md")).toBe("coverage");
99
+ expect(evaluateChangePolicy(policy,"app/new/README.md")).toBe("ignore");
100
+ expect(evaluateChangePolicy(policy,"migrations/README.md")).toBe("ignore");
101
+ expect(evaluateChangePolicy(policy,"pages/_app.tsx")).toBe("full");
102
+ await expect(resolveCoverageSourceOptions([adapter],{...context(["ts"]),service:"other"})).rejects.toThrow("inconsistent change rules");
103
+ const ordinary = next();
104
+ await resolveCoverageSourceOptions([ordinary],context(["tsx"],"web"));
105
+ const ordinaryPolicy = serviceChangePolicy({coverage:{adapters:[ordinary]}},{requireResolved:true});
106
+ expect(evaluateChangePolicy(ordinaryPolicy,"web/pages/a.test.tsx")).toBe("coverage");
107
+ expect(evaluateChangePolicy(ordinaryPolicy,"web/pages/README.md")).toBe("ignore");
108
+ });
109
+ test("ignored fixture manifests cannot change a Node inventory, explicit overrides can", async () => {
110
+ const adapter = node();
111
+ const svc = {coverage:{adapters:[adapter]}};
112
+ const files = ["package.json","__tests__/fixture/package.json"];
113
+ const inventory = (version: string, changeRules?: ChangeRule[]) => adapter.inventory!({service:"app",signal:new AbortController().signal,
114
+ projectFiles:async()=>inventoryProjectFiles({...svc,changeRules},files),
115
+ readProjectFile:async file=>JSON.stringify({name:file,dependencies:{dependency:file.startsWith("__tests__") ? version : "1"}}),
116
+ exec:async()=>({stdout:"",stderr:""})});
117
+ expect(await inventory("1")).toEqual(await inventory("2"));
118
+ const override: ChangeRule[] = [{pattern:"__tests__/**",action:"coverage"}];
119
+ expect(await inventory("1",override)).not.toEqual(await inventory("2",override));
120
+ });
121
+ test("malformed declarations cannot silently broaden selection", () => {
122
+ for (const rule of [{pattern:"**/*.ts",action:"wat"},{pattern:"../file",action:"ignore"},{pattern:"**",action:"ignore",root:"/absolute"},{pattern:"**",action:"ignore",extra:true}]) expect(()=>serviceChangePolicy({changeRules:[rule] as any})).toThrow();
123
+ expect(()=>serviceChangePolicy({changeRules:null as any})).toThrow();
124
+ });
125
+ });
@@ -0,0 +1,147 @@
1
+ /** Portable source-change policy, evaluated identically by the SDK and controller.
2
+ * Globs match whole root-relative paths: *, ?, [], [!], ** and backslash escapes.
3
+ * Braces/extglobs have no special meaning. Directory rules explicitly use /**. */
4
+ export type ChangeAction = "coverage" | "full" | "ignore";
5
+ export interface ChangeRule { root?: string; pattern: string; action: ChangeAction }
6
+ export interface ResolvedChangeRule extends ChangeRule { root: string }
7
+ export interface ChangePolicy { defaults: readonly (readonly ResolvedChangeRule[])[]; overrides: readonly ResolvedChangeRule[] }
8
+ export interface ChangeRuleAdapter {
9
+ changeRules?: readonly ChangeRule[];
10
+ /** Wrappers preserve independent nested adapter policies, including roots. */
11
+ changeRuleGroups?: readonly (readonly ChangeRule[])[];
12
+ sourceOptions?: { sourceRoot?: string } | ((...args: any[]) => unknown);
13
+ }
14
+ export interface ChangeRuleService {
15
+ /** Explicit rules override all defaults. false disables defaults. */
16
+ changeRules?: readonly ChangeRule[] | false;
17
+ coverage?: boolean | { sourceRoot?: string };
18
+ }
19
+ const literal = (c: string) => /[\\^$.*+?()[\]{}|/]/u.test(c) ? `\\${c}` : c;
20
+ const classLiteral = (c: string) => ["\\", "[", "]", "^", "-"].includes(c) ? `\\${c}` : c;
21
+ export function compileChangeGlob(pattern: string): RegExp {
22
+ if (!pattern || pattern.includes("\0") || pattern.startsWith("/") || pattern.split("/").some(p => p === "" || p === "." || p === "..")) throw new Error("change-rule pattern must be a nonempty relative glob without traversal");
23
+ const chars = [...pattern]; let out = "";
24
+ for (let i = 0; i < chars.length; i++) {
25
+ const c = chars[i]!;
26
+ if (c === "\\") {
27
+ const next = chars[++i];
28
+ if (next === undefined || next === "/") throw new Error("invalid glob escape");
29
+ out += literal(next);
30
+ } else if (c === "*") {
31
+ if (chars[i + 1] === "*") {
32
+ i++;
33
+ if ((i > 1 && chars[i - 2] !== "/") || (i + 1 < chars.length && chars[i + 1] !== "/")) throw new Error("** must occupy a whole path segment");
34
+ if (chars[i + 1] === "/") { i++; out += "(?:[\\s\\S]*/)?"; } else out += "[\\s\\S]*";
35
+ } else out += "[^/]*";
36
+ } else if (c === "?") out += "[^/]";
37
+ else if (c === "[") {
38
+ let negate = false; if (chars[i + 1] === "!") { negate = true; i++; }
39
+ const members: { c: string; escaped: boolean }[] = [];
40
+ let closed = false;
41
+ while (++i < chars.length) {
42
+ let ch = chars[i]!;
43
+ if (ch === "]") { closed = true; break; }
44
+ let escaped = false;
45
+ if (ch === "\\") { ch = chars[++i]!; escaped = true; if (ch === undefined) throw new Error("invalid class escape"); }
46
+ if (ch === "/") throw new Error("glob classes cannot match separators");
47
+ members.push({ c: ch, escaped });
48
+ }
49
+ if (!closed || !members.length) throw new Error("unterminated or empty glob class");
50
+ let body = "";
51
+ for (let j = 0; j < members.length; j++) {
52
+ const m = members[j]!;
53
+ if (members[j + 1]?.c === "-" && !members[j + 1]!.escaped && members[j + 2]) {
54
+ const end = members[j + 2]!;
55
+ if (m.c.codePointAt(0)! > end.c.codePointAt(0)! || (m.c.codePointAt(0)! <= 47 && end.c.codePointAt(0)! >= 47)) throw new Error("invalid glob range");
56
+ body += `${classLiteral(m.c)}-${classLiteral(end.c)}`; j += 2;
57
+ } else body += classLiteral(m.c);
58
+ }
59
+ // Prefix slash in a negated class so it remains segment-scoped.
60
+ out += `[${negate ? "^/" : ""}${body}]`;
61
+ } else out += literal(c);
62
+ }
63
+ return new RegExp(`^(?:${out})$(?![\\s\\S])`, "u");
64
+ }
65
+ export function matchesChangeGlob(pattern: string, path: string): boolean { return compileChangeGlob(pattern).test(path); }
66
+ export function validateChangeRules(value: unknown): asserts value is readonly ChangeRule[] {
67
+ if (!Array.isArray(value)) throw new Error("changeRules must be an array or false");
68
+ for (const rule of value) {
69
+ if (!rule || typeof rule !== "object" || Array.isArray(rule) || !Object.keys(rule).every(k => ["root", "pattern", "action"].includes(k)) || typeof rule.pattern !== "string" || !["coverage", "full", "ignore"].includes(rule.action)) throw new Error("invalid change rule");
70
+ if (rule.root !== undefined && (typeof rule.root !== "string" || (rule.root !== "." && (!rule.root || rule.root.includes("\\") || rule.root.includes("\0") || rule.root.split("/").some((p: string) => !p || p === "." || p === ".."))))) throw new Error("change-rule root must be repository-relative");
71
+ compileChangeGlob(rule.pattern);
72
+ }
73
+ }
74
+ const freeze = (rules: ChangeRule[]): readonly ChangeRule[] => Object.freeze(rules.map(r => Object.freeze(r)));
75
+ export const COMMON_CHANGE_RULES = freeze(["**/*.md", "**/*.markdown", "**/*.MD", "**/*.MARKDOWN"].map(pattern => ({ pattern, action: "ignore" })));
76
+ const extensions = ["js", "jsx", "mjs", "cjs", "ts", "tsx", "mts", "cts"];
77
+ const full = (pattern: string): ChangeRule => ({pattern, action: "full"});
78
+ const ignore = (pattern: string): ChangeRule => ({pattern, action: "ignore"});
79
+ const coverage = (pattern: string): ChangeRule => ({pattern, action: "coverage"});
80
+ export const JAVASCRIPT_CHANGE_RULES = freeze([
81
+ ...extensions.map(ext => coverage(`**/*.${ext}`)),
82
+ full("**/migrations/**"),
83
+ ...extensions.flatMap(ext => [full(`**/*.config.${ext}`), full(`**/.babelrc.${ext}`), full(`**/.pnp.${ext}`), full(`**/.pnp.loader.${ext}`)]),
84
+ ...["**/tsconfig*.json", "**/jsconfig*.json", "**/.babelrc", "**/.babelrc.json", "**/.swcrc", "**/.env", "**/.env.*"].map(full),
85
+ ...extensions.flatMap(ext => [ignore(`**/test.${ext}`), ignore(`**/spec.${ext}`)]),
86
+ ...extensions.flatMap(ext => [".test", ".spec", "_test", "_spec", ".test-d", ".spec-d", ".cy"].map(suffix => ignore(`**/*${suffix}.${ext}`))),
87
+ ...["**/__tests__/**", "**/__mocks__/**", "**/__snapshots__/**", "test/**", "tests/**", "e2e/**", "cypress/**", "playwright/**"].map(ignore),
88
+ ...[...extensions, "json"].flatMap(ext => ["jest", "vitest", "playwright", "cypress"].map(tool => ignore(`**/${tool}.config.${ext}`))),
89
+ ...COMMON_CHANGE_RULES,
90
+ ]);
91
+ export const NODE_CHANGE_RULES = JAVASCRIPT_CHANGE_RULES;
92
+ export const BROWSER_CHANGE_RULES = JAVASCRIPT_CHANGE_RULES;
93
+ export const BUN_CHANGE_RULES = freeze([...NODE_CHANGE_RULES, full("**/bunfig.toml")]);
94
+ const sharedNext = ["layout", "template", "error", "global-error", "loading", "not-found", "default", "forbidden", "unauthorized", "middleware", "proxy", "instrumentation", "instrumentation-client", "mdx-components", "_app", "_document", "_error"];
95
+ export const NEXT_CHANGE_RULES = freeze([
96
+ ...JAVASCRIPT_CHANGE_RULES,
97
+ // Pages-router files are routes even if their names resemble unit tests.
98
+ ...["pages", "src/pages"].flatMap(root => extensions.map(ext => coverage(`${root}/**/*.${ext}`))),
99
+ ...["app", "src/app"].flatMap(root => extensions.flatMap(ext => [coverage(`${root}/**/page.${ext}`), coverage(`${root}/**/route.${ext}`)])),
100
+ full("**/*.mdx"), full("**/*.MDX"),
101
+ ...extensions.flatMap(ext => sharedNext.map(name => full(`**/${name}.${ext}`))),
102
+ ]);
103
+ export const RUST_CHANGE_RULES = freeze([
104
+ coverage("**/*.rs"), ignore("tests/**"), ignore("benches/**"),
105
+ ...["**/Cargo.toml", "**/Cargo.lock", "**/.cargo/config", "**/.cargo/config.toml", "**/rust-toolchain", "**/rust-toolchain.toml", "**/build.rs", "src/bin/**"].map(full),
106
+ ...COMMON_CHANGE_RULES,
107
+ ]);
108
+ export function resolveChangePolicy(service: ChangeRuleService, adapters: readonly ChangeRuleAdapter[] = []): ChangePolicy {
109
+ if (service.changeRules !== undefined && service.changeRules !== false) validateChangeRules(service.changeRules);
110
+ const serviceRoot = typeof service.coverage === "object" ? service.coverage.sourceRoot ?? "." : ".";
111
+ const resolve = (rules: readonly ChangeRule[], root: string): ResolvedChangeRule[] => {
112
+ validateChangeRules(rules); const out = rules.map(r => ({ ...r, root: r.root ?? root })); validateChangeRules(out); return out;
113
+ };
114
+ const defaults: ResolvedChangeRule[][] = service.changeRules === false ? [] : [resolve([{pattern:"spectest/**",action:"full"}, {pattern:"spectest/tests/**",action:"coverage"}, ...COMMON_CHANGE_RULES], ".")];
115
+ if (service.changeRules !== false) for (const adapter of adapters) {
116
+ const groups = [...(adapter.changeRules ? [adapter.changeRules] : []), ...(adapter.changeRuleGroups ?? [])];
117
+ const root = typeof adapter.sourceOptions === "object" ? adapter.sourceOptions.sourceRoot ?? serviceRoot : serviceRoot;
118
+ for (const group of groups) {
119
+ if (typeof adapter.sourceOptions === "function" && group.some(r => r.root === undefined)) throw new Error("dynamic sourceOptions requires explicit change-rule roots");
120
+ defaults.push(resolve(group, root));
121
+ }
122
+ }
123
+ return { defaults, overrides: resolve(service.changeRules || [], serviceRoot) };
124
+ }
125
+ /** Compile once for scans over a source tree; regexes carry no mutable flags. */
126
+ export function compileChangePolicy(policy: ChangePolicy): (path: string) => ChangeAction | undefined {
127
+ const compile = (rules: readonly ResolvedChangeRule[]) => rules.map(r => ({...r, regex:compileChangeGlob(r.pattern)}));
128
+ const defaults = policy.defaults.map(compile), overrides = compile(policy.overrides);
129
+ const groupAction = (rules: ReturnType<typeof compile>, path: string) => {
130
+ let result: ChangeAction | undefined;
131
+ for (const r of rules) {
132
+ const rel = r.root === "." ? path : path === r.root ? "" : path.startsWith(r.root + "/") ? path.slice(r.root.length + 1) : undefined;
133
+ if (rel !== undefined && r.regex.test(rel)) result = r.action;
134
+ }
135
+ return result;
136
+ };
137
+ const rank = {ignore: 1, coverage: 2, full: 3};
138
+ return path => {
139
+ const override = groupAction(overrides, path); if (override) return override;
140
+ let result: ChangeAction | undefined;
141
+ for (const rules of defaults) { const action = groupAction(rules,path); if (action && (!result || rank[action] > rank[result])) result = action; }
142
+ return result;
143
+ };
144
+ }
145
+ export function evaluateChangePolicy(policy: ChangePolicy, path: string): ChangeAction | undefined {
146
+ return compileChangePolicy(policy)(path);
147
+ }
@@ -158,3 +158,35 @@ test("collection bounds parallel targets and shares lazy discovery for command a
158
158
  expect(commands.filter(command => command.includes("capture-probe"))).toHaveLength(11);
159
159
  } finally { await fs.rm(root, { recursive: true, force: true }); }
160
160
  }, 10000);
161
+
162
+ test("nested inventory uses cluster overrides unless an image supplies its own", async () => {
163
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "k3s-policy-inventory-"));
164
+ try {
165
+ const id = "c".repeat(16);
166
+ const dir = path.join(root, ".pods", id);
167
+ await fs.mkdir(dir, { recursive: true });
168
+ await fs.writeFile(path.join(dir, ".instance.json"), JSON.stringify({ id, image: image.reference, container: "api", namespace: "app", root: "/app" }));
169
+ await fs.writeFile(path.join(dir, "app.lcov"), "TN:\n");
170
+ const adapter: CoverageAdapter = { name: "manifest", async inventory(ctx) {
171
+ return (await ctx.projectFiles()).map(file => `manifest:${file}`);
172
+ } };
173
+ const pod = { metadata: { name: "api", annotations: { "spectest.dev/coverage": JSON.stringify({ instances: [id] }) } },
174
+ status: { containerStatuses: [{ name: "api", state: { running: {} } }] } };
175
+ const ctx = { service: "k8s", reportDir: root, signal: AbortSignal.timeout(5000),
176
+ changeRules: [{ pattern: "**/fixtures/**", action: "ignore" as const }],
177
+ projectFiles: async () => ["app/package.json", "app/fixtures/package.json"],
178
+ readProjectFile: async () => null, writeReport: async () => {},
179
+ exec: async () => ({ stdout: JSON.stringify({ items: [pod] }), stderr: "" }),
180
+ };
181
+ const capture = async (restoreFixture: boolean) => {
182
+ const bridge = k3sCoverage([{ ...image, coverage: { adapters: [adapter] },
183
+ ...(restoreFixture ? { changeRules: [{ pattern: "**/fixtures/**", action: "full" as const }] } : {}),
184
+ }]);
185
+ const [result] = await bridge.adapter().collect!(ctx, true);
186
+ expect(result.error).toBeUndefined();
187
+ return result.reports.find(report => report.name === "inventory.units")?.content;
188
+ };
189
+ expect(await capture(false)).toBe("manifest:app/package.json");
190
+ expect(await capture(true)).toBe("manifest:app/fixtures/package.json\nmanifest:app/package.json");
191
+ } finally { await fs.rm(root, { recursive: true, force: true }); }
192
+ });
@@ -33,12 +33,12 @@ export interface K3sCoverageDependencies {
33
33
  /** One component-owned bridge; runtime adapters remain unaware of Kubernetes. */
34
34
  export function k3sCoverage(images: readonly KubernetesImage[]) {
35
35
  const declarations = images.flatMap(image => typeof image === "string" || image.coverage === undefined ? [] : [{
36
- reference: canonicalImageReference(image.reference), coverage: image.coverage,
36
+ reference: canonicalImageReference(image.reference), coverage: image.coverage, changeRules: image.changeRules,
37
37
  }]);
38
38
  for (const image of declarations) {
39
39
  const adapters = coverageAdapters(image.coverage);
40
40
  if (adapters.some(adapter => adapter.load || adapter.collect)) throw new Error("k3s image coverage accepts container adapters; put browser coverage on the cluster service");
41
- applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage });
41
+ applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage, changeRules: image.changeRules });
42
42
  }
43
43
  const metadata = new Map<string, ImageInfo>();
44
44
  const service = (cluster: string, reference: string) => `${cluster}/image-${hash(reference)}`;
@@ -136,7 +136,7 @@ export function k3sCoverage(images: readonly KubernetesImage[]) {
136
136
  // adapter's functions would otherwise disappear from JSON.stringify.
137
137
  toJSON() { return { adapter: "k3s-containers", images: declarations.map(image => ({
138
138
  reference: image.reference,
139
- config: applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage }),
139
+ config: applyCoverageAdapters(image.reference, { image: { type: "registry", reference: image.reference }, coverage: image.coverage, changeRules: image.changeRules }),
140
140
  })) }; },
141
141
  async collect(ctx, baseline) {
142
142
  const root = path.join(ctx.reportDir, ".pods");
@@ -176,7 +176,7 @@ export function k3sCoverage(images: readonly KubernetesImage[]) {
176
176
  }
177
177
  }
178
178
  const captured = await captureCoverageTarget({
179
- coverage: declaration.coverage, root: instance.root, baseline,
179
+ coverage: declaration.coverage, changeRules: declaration.changeRules ?? ctx.changeRules, root: instance.root, baseline,
180
180
  inventoryAvailable: baseline && adapters.some(a => a.inventory) ? !!await livePod() : false,
181
181
  counterKey: `${service(ctx.service, instance.image)}/${id}`,
182
182
  context: { ...ctx, service: service(ctx.service, instance.image), reportDir: dir,
@@ -2,6 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { createHash } from "node:crypto";
4
4
  import type { CoverageAdapter } from "../coverage.js";
5
+ import { COMMON_CHANGE_RULES, NODE_CHANGE_RULES, type ChangeRule } from "../change-rules.js";
5
6
  import { rawFetch } from "../harness/raw-fetch.js";
6
7
 
7
8
  // Drain all work even on failure: no writes may outlive the capture boundary.
@@ -21,6 +22,17 @@ export function supabaseFunctionsCoverage(functionRoot: string): CoverageAdapter
21
22
  name: "supabase-functions",
22
23
  reports: "delta",
23
24
  sourceOptions: { sourceRoot: "." },
25
+ // Ordinary modules follow callers, including shared modules outside the
26
+ // functions directory. Concrete reach still limits which paths this
27
+ // service owns. Directory-discovered entrypoints remain in the inventory.
28
+ changeRules: [
29
+ ...["**/deno.json", "**/deno.jsonc", "**/deno.lock", "**/import_map.json"].map(pattern => ({ pattern, action: "full" as const })),
30
+ ...NODE_CHANGE_RULES,
31
+ ...["config.toml", "config.json"].map(pattern => ({ root: path.posix.dirname(functionRoot), pattern, action: "full" as const })),
32
+ // Migration directory conventions in the JS preset must not make
33
+ // incidental Markdown defeat the shared documentation default.
34
+ ...COMMON_CHANGE_RULES,
35
+ ] satisfies ChangeRule[],
24
36
  ownsReport: name => name.startsWith("edge-") || name.startsWith("edge-scripts/"),
25
37
  async inventory(ctx) {
26
38
  // Functions are discovered by directory, not by an import in user code.
@@ -11,6 +11,9 @@ import { join } from "node:path";
11
11
  import { google } from "./google.js";
12
12
  import { supabase } from "./supabase.js";
13
13
  import type { ServiceConfig, ServicesMap } from "../index.js";
14
+ import { serviceChangePolicy } from "../coverage.js";
15
+ import { evaluateChangePolicy } from "../change-rules.js";
16
+ import { reachContains } from "../harness/reach.js";
14
17
 
15
18
  let root: string;
16
19
  const prevWorkspace = process.env.SPECTEST_WORKSPACE;
@@ -93,6 +96,29 @@ function expand(stack: ReturnType<typeof supabase>, name = "supabase"): Services
93
96
  const svc = (parts: ServicesMap, key: string): ServiceConfig => parts[key] as ServiceConfig;
94
97
 
95
98
  describe("supabase({ dir })", () => {
99
+ test("database reach follows an alternate directory while preserving runtime Markdown templates", () => {
100
+ mkdirSync(join(root, "custom/backend/migrations"), { recursive: true });
101
+ writeFileSync(join(root, "custom/invite[en].md"), "Welcome {{ .Token }}");
102
+ const parts = expand(supabase({
103
+ dir: "custom/backend",
104
+ configOverrides: { auth: { email: { template: { invite: { content_path: "custom/invite[en].md" } } } } },
105
+ }));
106
+ const db = svc(parts, "db");
107
+ expect(db.inputs).toEqual([
108
+ "custom/backend/config.toml", "custom/backend/config.json", "custom/backend/migrations", "custom/backend/seed.sql", "custom/invite[en].md",
109
+ ]);
110
+ expect(db.volumes?.[0]).toMatchObject({ source: join(root, "custom/backend"), reachExempt: true });
111
+ const reach = { version: 1, sources: [{ inputs: [...db.inputs!] }], prefixes: [] };
112
+ expect(reachContains(reach, "custom/backend/migrations/new.sql")).toBe(true);
113
+ expect(reachContains(reach, "custom/backend/functions/new/index.ts")).toBe(false);
114
+ expect(reachContains(reach, "supabase/config.toml")).toBe(false);
115
+ const policy = serviceChangePolicy(db);
116
+ expect(evaluateChangePolicy(policy, "custom/backend/migrations/notes.md")).toBe("ignore");
117
+ expect(evaluateChangePolicy(policy, "custom/invite[en].md")).toBe("full");
118
+ expect(evaluateChangePolicy(policy, "custom/invitee.md")).toBe("ignore");
119
+ expect(evaluateChangePolicy(policy, "custom/backend/config.json")).toBe("full");
120
+ });
121
+
96
122
  test("overrides hook addresses without moving migrations or leaking env references", () => {
97
123
  const stack = supabase({
98
124
  dir: "supabase",
@@ -6,6 +6,10 @@ import { join } from "node:path";
6
6
  import { excludeAllBut, functionsReach, importMapLocalTargets, importSpecifiers } from "./supabase-functions-reach.js";
7
7
  import { serviceDockerignore } from "../harness/build-context.js";
8
8
  import { projectReach, reachContains } from "../harness/reach.js";
9
+ import { inventoryProjectFiles, serviceChangePolicy } from "../coverage.js";
10
+ import { evaluateChangePolicy } from "../change-rules.js";
11
+ import type { ServiceConfig } from "../index.js";
12
+ import { supabaseFunctionsCoverage } from "./supabase-coverage.js";
9
13
 
10
14
  let root: string;
11
15
 
@@ -71,6 +75,31 @@ describe("parsers", () => {
71
75
  });
72
76
 
73
77
  describe("supabase() wiring", () => {
78
+ test("ignored test fixtures do not change inventory while actual function config and discovery do", async () => {
79
+ const adapter = supabaseFunctionsCoverage("supabase/functions");
80
+ const service = { coverage: { adapters: [adapter] } };
81
+ const files: Record<string, string> = {
82
+ "supabase/functions/hello/index.ts": "Deno.serve(() => new Response());",
83
+ "supabase/functions/hello/deno.json": '{"imports":{}}',
84
+ "supabase/functions/__tests__/fixture/deno.json": '{"imports":{}}',
85
+ "supabase/functions/__tests__/fixture/package.json": '{"dependencies":{}}',
86
+ };
87
+ const inventory = () => adapter.inventory!({ service: "edge", signal: AbortSignal.timeout(1000),
88
+ projectFiles: async () => inventoryProjectFiles(service, Object.keys(files)),
89
+ readProjectFile: async p => files[p] ?? null,
90
+ exec: async () => { throw new Error("inventory should only read project metadata"); },
91
+ });
92
+ const baseline = await inventory();
93
+ files["supabase/functions/__tests__/fixture/deno.json"] = '{"imports":{"x":"./fixture.ts"}}';
94
+ files["supabase/functions/__tests__/fixture/package.json"] = '{"dependencies":{"x":"2"}}';
95
+ expect(await inventory()).toEqual(baseline);
96
+ files["supabase/functions/hello/deno.json"] = '{"imports":{"x":"./helper.ts"}}';
97
+ const configured = await inventory();
98
+ expect(configured).not.toEqual(baseline);
99
+ files["supabase/functions/new/index.ts"] = "Deno.serve(() => new Response());";
100
+ expect(await inventory()).not.toEqual(configured);
101
+ });
102
+
74
103
  test("the functions image context is the module graph, and the root mount is reach-exempt", async () => {
75
104
  const prev = process.env.SPECTEST_WORKSPACE;
76
105
  process.env.SPECTEST_WORKSPACE = root;
@@ -83,7 +112,18 @@ describe("supabase() wiring", () => {
83
112
  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/**"]);
84
113
  expect(fn.image.exclude?.at(-1)).toBe("**/.venv/**");
85
114
  expect(fn.volumes?.[0]).toMatchObject({ source: root, reachExempt: true });
86
- expect((parts.db as { inputs?: readonly string[] }).inputs).toEqual(["supabase"]);
115
+ const db = parts.db as ServiceConfig;
116
+ expect(db.inputs).toEqual(["supabase/config.toml", "supabase/config.json", "supabase/migrations", "supabase/seed.sql"]);
117
+ const dbRules = serviceChangePolicy(db);
118
+ expect(evaluateChangePolicy(dbRules, "supabase/migrations/002.sql")).toBe("full");
119
+ expect(evaluateChangePolicy(dbRules, "supabase/config.json")).toBe("full");
120
+ expect(evaluateChangePolicy(dbRules, "supabase/seed.sql")).toBe("full");
121
+ expect(evaluateChangePolicy(dbRules, "supabase/migrations/AGENTS.md")).toBe("ignore");
122
+ expect(projectReach(["supabase/functions/hello/index.ts", "supabase/functions/hello/index_test.ts"], [{ inputs: [...db.inputs!] }])).toEqual([]);
123
+ const fnRules = serviceChangePolicy(parts.functions as ServiceConfig);
124
+ expect(evaluateChangePolicy(fnRules, "packages/shared/src/new-helper.ts")).toBe("coverage");
125
+ expect(evaluateChangePolicy(fnRules, "supabase/functions/hello/index_test.ts")).toBe("ignore");
126
+ expect(evaluateChangePolicy(fnRules, "supabase/functions/deno.json")).toBe("full");
87
127
  const sources = [
88
128
  { context: fn.image.context, ignore: serviceDockerignore(null, fn.image.exclude) },
89
129
  { inputs: [...(parts.db as { inputs: string[] }).inputs] },
@@ -41,7 +41,7 @@ test("hundreds of sorted migrations and a seed use one exec without reading SQL
41
41
  for (let i = 558; i >= 0; i--) writeFileSync(join(root, "migrations", `${String(i).padStart(4, "0")}.sql`), "select 1");
42
42
  writeFileSync(join(root, "migrations", "ignored.txt"), "not SQL");
43
43
  const services = parts(root);
44
- expect(services.db!.volumes).toContainEqual({ source: root, target: "/spectest/supabase", readOnly: true });
44
+ expect(services.db!.volumes).toContainEqual({ source: root, target: "/spectest/supabase", readOnly: true, reachExempt: true });
45
45
  const calls: { service: string; command: string[]; opts?: ExecOpts }[] = [];
46
46
  await services.migrations!.setup!({
47
47
  exec: async (service: string, command: string[], opts?: ExecOpts) => {