@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
package/dist/coverage.js CHANGED
@@ -28,6 +28,8 @@
28
28
  // coverage: { adapters: [coverage.node(), coverage.browser()] }
29
29
  import { createHash } from "node:crypto";
30
30
  import { JAVASCRIPT_GENERATED_SOURCES } from "./coverage-javascript.js";
31
+ import { NODE_CHANGE_RULES, BROWSER_CHANGE_RULES, resolveChangePolicy, validateChangeRules, compileChangePolicy } from "./change-rules.js";
32
+ export * from "./change-rules.js";
31
33
  import { harvestAllBrowserCoverage, takeBrowserCoverageReport } from "./browser-coverage.js";
32
34
  import { COVERAGE_CONTAINER_DIR } from "./harness/coverage.js";
33
35
  export { COVERAGE_CONTAINER_DIR };
@@ -89,15 +91,25 @@ export function validateCoverage(service, cov) {
89
91
  (a.explains === undefined ||
90
92
  (Array.isArray(a.explains) &&
91
93
  a.explains.every((e) => typeof e === "string"))) &&
92
- ["configure", "load", "capture", "collect", "inventory", "ownsReport"].every((k) => a[k] === undefined ||
94
+ ["configure", "load", "capture", "collect", "inventory", "ownsReport", "resolveChangeRules"].every((k) => a[k] === undefined ||
93
95
  typeof a[k] === "function");
94
96
  const sourceOptions = a?.sourceOptions;
97
+ const changeRules = a?.changeRules;
98
+ const groups = a?.changeRuleGroups;
99
+ if (changeRules !== undefined)
100
+ validateChangeRules(changeRules);
101
+ if (groups !== undefined) {
102
+ if (!Array.isArray(groups))
103
+ throw new Error("invalid changeRuleGroups");
104
+ for (const group of groups)
105
+ validateChangeRules(group);
106
+ }
95
107
  const defaults = a?.defaultExcludeSources;
96
108
  const defaultShape = defaults === undefined || typeof a.ownsReport === "function";
97
109
  const sourceShape = sourceOptions === undefined || (typeof a.ownsReport === "function" &&
98
110
  (typeof sourceOptions === "function" || (typeof sourceOptions === "object" && sourceOptions !== null && !Array.isArray(sourceOptions))));
99
111
  if (!ok || !sourceShape || !defaultShape) {
100
- throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, explains?, configure?, load?, capture?, inventory? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
112
+ throw new Error(`service "${service}" has an invalid coverage adapter — an adapter is \`{ name, reports?, explains?, changeRules?, configure?, load?, capture?, inventory? }\` (see \`defineAdapter\` in @specific.dev/spectest/coverage)`);
101
113
  }
102
114
  if (sourceOptions !== undefined && typeof sourceOptions !== "function")
103
115
  validateCoverageSourceOptions(service, sourceOptions);
@@ -122,6 +134,7 @@ export function coverageAdapters(cov) {
122
134
  export function applyCoverageAdapters(key, svc) {
123
135
  validateCoverage(key, svc.coverage);
124
136
  const adapters = coverageAdapters(svc.coverage);
137
+ serviceChangePolicy(svc); // Validate even uncovered services; never enables collection.
125
138
  if (adapters.length === 0)
126
139
  return svc;
127
140
  let out = svc;
@@ -144,6 +157,43 @@ export function applyCoverageAdapters(key, svc) {
144
157
  };
145
158
  return { ...out, coverage: wire };
146
159
  }
160
+ /** Resolve service policy without requiring coverage to be enabled. */
161
+ const RESOLVED_CHANGE_RULES = new Map();
162
+ export async function resolveAdapterChangeRules(adapter, ctx) {
163
+ if (!adapter.resolveChangeRules)
164
+ return;
165
+ const rules = await adapter.resolveChangeRules(ctx);
166
+ validateChangeRules(rules);
167
+ const before = RESOLVED_CHANGE_RULES.get(adapter.resolveChangeRules);
168
+ if (before && JSON.stringify(before) !== JSON.stringify(rules))
169
+ throw new Error(`adapter ${adapter.name} resolved inconsistent change rules across runtimes`);
170
+ RESOLVED_CHANGE_RULES.set(adapter.resolveChangeRules, rules.map(rule => ({ ...rule })));
171
+ }
172
+ export function serviceChangePolicy(svc, options = {}) {
173
+ const adapters = coverageAdapters(svc.coverage).map(adapter => {
174
+ if (!adapter.resolveChangeRules || svc.changeRules === false)
175
+ return adapter;
176
+ const rules = RESOLVED_CHANGE_RULES.get(adapter.resolveChangeRules);
177
+ if (!rules && options.requireResolved) {
178
+ // An unused declared image has no runtime metadata. Archive a conservative
179
+ // fallback, so its source cannot gain ignore/coverage exceptions.
180
+ const fallbackRoot = typeof adapter.sourceOptions === "object" ? adapter.sourceOptions.sourceRoot : undefined;
181
+ const serviceRoot = typeof svc.coverage === "object" ? svc.coverage.sourceRoot : undefined;
182
+ const roots = new Set([...(adapter.changeRules ?? []), ...(adapter.changeRuleGroups ?? []).flat()].map(rule => rule.root ?? fallbackRoot ?? serviceRoot ?? "."));
183
+ if (!roots.size)
184
+ roots.add(fallbackRoot ?? serviceRoot ?? ".");
185
+ return { ...adapter, changeRuleGroups: [], changeRules: [...roots].map(root => ({ root, pattern: "**", action: "full" })) };
186
+ }
187
+ return rules ? { ...adapter, changeRules: rules } : adapter;
188
+ });
189
+ return resolveChangePolicy(svc, adapters);
190
+ }
191
+ /** Inventory fingerprints must not turn ignored fixture manifests into changes.
192
+ * Installed runtime dependency inventories remain independent of project files. */
193
+ export function inventoryProjectFiles(svc, files) {
194
+ const action = compileChangePolicy(serviceChangePolicy(svc, { requireResolved: true }));
195
+ return files.filter(file => action(file) !== "ignore");
196
+ }
147
197
  function validateCoverageSourceOptions(service, options) {
148
198
  if (!options || typeof options !== "object" || Array.isArray(options) ||
149
199
  !Object.keys(options).every(k => k === "sourceRoot" || k === "excludeSources")) {
@@ -154,6 +204,9 @@ function validateCoverageSourceOptions(service, options) {
154
204
  /** Resolve adapter policies once per capture. Invalid/overlapping ownership is
155
205
  * an error, so one adapter cannot silently reinterpret another's reports. */
156
206
  export async function resolveCoverageSourceOptions(adapters, ctx, fallback) {
207
+ await Promise.all(adapters.map(a => resolveAdapterChangeRules(a, {
208
+ ...ctx, sourceRoot: (typeof a.sourceOptions === "object" ? a.sourceOptions.sourceRoot : undefined) ?? fallback?.sourceRoot ?? ".",
209
+ })));
157
210
  return Promise.all(adapters.filter(a => a.sourceOptions !== undefined || a.defaultExcludeSources !== undefined || a.ownsReport).map(async (a) => {
158
211
  if (!a.ownsReport)
159
212
  throw new Error(`coverage adapter "${a.name}" needs ownsReport with sourceOptions`);
@@ -186,7 +239,7 @@ function withWireName(a) {
186
239
  if (Object.prototype.hasOwnProperty.call(a, "toJSON"))
187
240
  return a;
188
241
  return Object.assign(Object.create(Object.getPrototypeOf(a)), a, {
189
- toJSON: () => ({ adapter: a.name, ...(a.defaultExcludeSources ? { defaultExcludeSources: a.defaultExcludeSources } : {}), ...(a.sourceOptions && typeof a.sourceOptions !== "function" ? { sourceOptions: a.sourceOptions } : {}) }),
242
+ toJSON: () => ({ adapter: a.name, ...(a.changeRules ? { changeRules: a.changeRules } : {}), ...(a.changeRuleGroups ? { changeRuleGroups: a.changeRuleGroups } : {}), ...(a.defaultExcludeSources ? { defaultExcludeSources: a.defaultExcludeSources } : {}), ...(a.sourceOptions && typeof a.sourceOptions !== "function" ? { sourceOptions: a.sourceOptions } : {}) }),
190
243
  });
191
244
  }
192
245
  /** `env` with `value` appended to `name` (space-separated), or set. */
@@ -792,6 +845,12 @@ for (;;) {
792
845
  process.stdout.write(Array.from(out).sort().join("\\n") + (out.size ? "\\n" : ""));
793
846
  `;
794
847
  export function node(options = {}) {
848
+ const policy = options.changeRules === undefined ? NODE_CHANGE_RULES : options.changeRules;
849
+ if (policy !== false)
850
+ validateChangeRules(policy);
851
+ if (options.sourceRoot !== undefined || options.excludeSources !== undefined) {
852
+ validateCoverageSourceOptions("node", { sourceRoot: options.sourceRoot, excludeSources: options.excludeSources });
853
+ }
795
854
  if (options.generated !== undefined && (!Array.isArray(options.generated) || options.generated.some(p => typeof p !== "string"))) {
796
855
  throw new Error("coverage.node generated must be a list of relative files");
797
856
  }
@@ -802,6 +861,7 @@ export function node(options = {}) {
802
861
  }
803
862
  return {
804
863
  name: "node",
864
+ changeRules: policy === false ? [] : policy.map(rule => ({ ...rule })),
805
865
  defaultExcludeSources: JAVASCRIPT_GENERATED_SOURCES,
806
866
  ...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: { sourceRoot: options.sourceRoot, excludeSources: options.excludeSources } } : {}),
807
867
  ownsReport: name => isNodeDumpName(name) || name === NODE_COVERAGE_EMPTY_REPORT,
@@ -997,22 +1057,15 @@ export async function collectNodeReports(dir) {
997
1057
  });
998
1058
  return stats;
999
1059
  }
1000
- // ── browser ───────────────────────────────────────────────────────
1001
- /**
1002
- * Coverage for the frontend a service serves. The code runs in the guest
1003
- * browser — spectest's own process — so no report can be written by the
1004
- * container; spectest collects V8 coverage for every script the browser
1005
- * loads from this service's origins, maps it back to source through the
1006
- * served source map, and writes one lcov per capture (`browser.lcov`)
1007
- * holding what ran since the previous capture: every live session is
1008
- * harvested first, and only this service's totals are taken and cleared. Nothing ran ⇒
1009
- * an lcov with no records, which is a report saying so.
1010
- */
1011
1060
  export function browser(options = {}) {
1061
+ const rules = options.changeRules === undefined ? BROWSER_CHANGE_RULES : options.changeRules;
1062
+ if (rules !== false)
1063
+ validateChangeRules(rules);
1012
1064
  return {
1013
1065
  name: "browser",
1066
+ changeRules: rules === false ? [] : rules.map(rule => ({ ...rule })),
1014
1067
  defaultExcludeSources: JAVASCRIPT_GENERATED_SOURCES,
1015
- ...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: options } : {}),
1068
+ ...(options.sourceRoot !== undefined || options.excludeSources !== undefined ? { sourceOptions: { sourceRoot: options.sourceRoot, excludeSources: options.excludeSources } } : {}),
1016
1069
  ownsReport: name => name === "browser.lcov",
1017
1070
  reports: "delta",
1018
1071
  load(ctx) {
package/dist/daemon.js CHANGED
@@ -37,7 +37,7 @@ import { ENVIRONMENT_CA_PATH as CA_PATH } from "./environment-ca.js";
37
37
  import { assert, expect, expectRaw, lowerIngress, dnsName as makeDnsDecl, isWildcard, validateServiceImage, normalizeServiceImage, proxy as makeProxyDecl, } from "./index.js";
38
38
  import { COVERAGE_CONTAINER_DIR, coverageBundleRef, coverageHostDir, encodeCoverageBundle, isEmptyReport, readCoverageDir, applyCoverageDelta, walkProjectFiles, ENVIRONMENT_SERVICE, REACH_REPORT, READS_REPORT, } from "./harness/coverage.js";
39
39
  import { configureBrowserCoverage } from "./browser-coverage.js";
40
- import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, resolveCoverageSourceOptions, attachCoverageSourceOptions, coverageReportsMode, validateCoverage, } from "./coverage.js";
40
+ import { INVENTORY_REPORT, NODE_COVERAGE_MARKERS_SUBDIR, NODE_COVERAGE_SCRIPTS_SUBDIR, applyCoverageAdapters, coverageAdapters, serviceChangePolicy, inventoryProjectFiles, resolveCoverageSourceOptions, attachCoverageSourceOptions, coverageReportsMode, validateCoverage, } from "./coverage.js";
41
41
  import { serviceForHost as hostToService } from "./harness/browser-coverage.js";
42
42
  import { acquirePersistentBrowser, acquirePersistentDesktop, mobileKey } from "./browser.js";
43
43
  import { isMobileApp, openPersistentMobile } from "./mobile.js";
@@ -62,7 +62,7 @@ import { runTypecheck } from "./harness/typecheck.js";
62
62
  import { evaluateChecked } from "./harness/eval-typecheck.js";
63
63
  import { cpus } from "node:os";
64
64
  import { APP_DIR, WORKSPACE, resolveExistingProjectPath, resolveProjectPath, takeProjectReads } from "./project-files.js";
65
- import { compactReach, projectReach, renderReachRules, renderReadsLcov } from "./harness/reach.js";
65
+ import { compactReach, environmentReachSource, projectReach, renderReachRules, renderReadsLcov } from "./harness/reach.js";
66
66
  import { installFetchWrapper, isTransportError } from "./harness/fetch.js";
67
67
  import { bindInstrumentationScope, createInstrumentationScope, runInstrumented, runUninstrumented, } from "./harness/instrumentation-scope.js";
68
68
  import { ServiceDns, ServiceOperations } from "./harness/service-dns.js";
@@ -1058,7 +1058,7 @@ async function runServiceBuild(name, image, tag) {
1058
1058
  if (remote) {
1059
1059
  buildArgs = ["buildx", "build", "--builder", remote.builder,
1060
1060
  "--progress=plain", "--metadata-file", metadataPath,
1061
- "--output", shadowOutput(remoteReference), ...argFlags,
1061
+ "--output", shadowOutput(remoteReference, remote), ...argFlags,
1062
1062
  "-f", dfPath, contextDir];
1063
1063
  }
1064
1064
  else if (useLocal) {
@@ -1150,7 +1150,7 @@ async function runServiceBuild(name, image, tag) {
1150
1150
  const immutable = `${remoteReference.split(":result")[0]}@${digest}`;
1151
1151
  const pullStarted = Date.now();
1152
1152
  const pulled = await shxStream("docker", ["pull", immutable], undefined, undefined, () => { });
1153
- await shadowTiming(name, "pull-unpack", pullStarted, { digest, ok: pulled.code === 0, lazy: false });
1153
+ await shadowTiming(name, "pull-unpack", pullStarted, { digest, ok: pulled.code === 0, lazy: remote.imageFormat === "nydus-v6", imageFormat: remote.imageFormat ?? "oci-zstd" });
1154
1154
  if (pulled.code !== 0)
1155
1155
  return { ok: false, log: pulled.stderr };
1156
1156
  const tagged = await docker(["tag", immutable, tag]);
@@ -3196,7 +3196,7 @@ async function writeCoverageInventory(svc) {
3196
3196
  }
3197
3197
  return { stdout: r.stdout, stderr: r.stderr };
3198
3198
  },
3199
- projectFiles: () => walkProjectFiles(WORKSPACE),
3199
+ projectFiles: async () => inventoryProjectFiles(svc, await walkProjectFiles(WORKSPACE)),
3200
3200
  async readProjectFile(p) {
3201
3201
  // Not recorded as a read: an inventory fingerprinting every
3202
3202
  // manifest is bookkeeping, not the suite depending on the files.
@@ -3253,11 +3253,13 @@ async function computeProjectReach(all) {
3253
3253
  }
3254
3254
  async function computeProjectReachInner(all) {
3255
3255
  const files = await walkProjectFiles(WORKSPACE, [".git"]);
3256
- const sources = [];
3256
+ const sources = [environmentReachSource(ENVIRONMENT_SERVICE)];
3257
3257
  const owners = new Map(componentBuildTasks(all).map(t => [t.name, t.owner]));
3258
3258
  for (const svc of imageTasks(all)) {
3259
+ const changePolicy = serviceChangePolicy(svc, { requireResolved: true });
3259
3260
  const src = {
3260
3261
  service: owners.get(svc.name) ?? svc.name,
3262
+ changePolicy,
3261
3263
  volumeSources: (svc.volumes ?? []).flatMap((v) => (v.source && !v.reachExempt ? [v.source] : [])),
3262
3264
  ...(svc.inputs && svc.inputs.length > 0 ? { inputs: [...svc.inputs] } : {}),
3263
3265
  };
@@ -3279,7 +3281,7 @@ async function computeProjectReachInner(all) {
3279
3281
  sources.push(src);
3280
3282
  }
3281
3283
  const reach = projectReach(files, sources);
3282
- return { version: 1, sources, prefixes: compactReach(files, reach) };
3284
+ return { version: 2, sources, prefixes: compactReach(files, reach) };
3283
3285
  }
3284
3286
  /**
3285
3287
  * The synthetic `__environment__` capture: what the environment itself
@@ -3324,7 +3326,6 @@ async function captureServiceCoverage(baseline = false) {
3324
3326
  });
3325
3327
  // The environment's own capture is unconditional (see above); the
3326
3328
  // services' come from the ones that opted in, none included.
3327
- const environment = captureEnvironmentCoverage(all, baseline);
3328
3329
  const captures = await Promise.all(services.map(async (svc) => {
3329
3330
  let policies;
3330
3331
  try {
@@ -3386,6 +3387,7 @@ async function captureServiceCoverage(baseline = false) {
3386
3387
  try {
3387
3388
  return await adapter.collect({
3388
3389
  ...coverageCaptureContext(svc, signal, new Set()),
3390
+ changeRules: svc.changeRules,
3389
3391
  projectFiles: () => walkProjectFiles(WORKSPACE),
3390
3392
  async readProjectFile(p) {
3391
3393
  try {
@@ -3401,7 +3403,8 @@ async function captureServiceCoverage(baseline = false) {
3401
3403
  return [{ service: `${svc.name}/${adapter.name}`, reports: [], error: String(error) }];
3402
3404
  }
3403
3405
  })));
3404
- return [...captures, ...components.flat(), await environment];
3406
+ // Freeze build-dependent policy only after all direct and nested adapters resolved it.
3407
+ return [...captures, ...components.flat(), await captureEnvironmentCoverage(all, baseline)];
3405
3408
  }
3406
3409
  /**
3407
3410
  * Mint a session id. `idScope` (the running test's case id; `"eval"`
@@ -5109,7 +5112,7 @@ export function harnessMethods(state) {
5109
5112
  return {
5110
5113
  health: async () => ({
5111
5114
  ok: true,
5112
- capabilities: ["prepared-images-v1", "shadow-builder-v1"],
5115
+ capabilities: ["prepared-images-v1", "shadow-builder-v1", "shadow-nydus-v1"],
5113
5116
  // Optional discovery: old SDKs omit this field and are never sent the
5114
5117
  // new method. Check the loaded project, including after warm restore.
5115
5118
  setupInteractive: typeof loaded?.project.interactive?.seed === "function",
@@ -1,7 +1,10 @@
1
1
  import type { BuildImage, ComponentImageBuild, ServiceConfig } from "../index.js";
2
+ import { type ChangeRule } from "../change-rules.js";
2
3
  export type KubernetesImage = string | (BuildImage & {
3
4
  reference: string;
4
5
  coverage?: import("../coverage.js").ServiceCoverage;
6
+ /** Override change-selection defaults for this image's own build context. */
7
+ changeRules?: readonly ChangeRule[] | false;
5
8
  });
6
9
  /** containerd/CRI use fully qualified names, including the default tag. */
7
10
  export declare function canonicalImageReference(reference: string): string;
@@ -20,6 +23,8 @@ export declare function isComponentImageTask(name: string): boolean;
20
23
  export declare function componentBuildTasks<T extends ServiceConfig & {
21
24
  name: string;
22
25
  }>(services: readonly T[]): {
26
+ changeRules?: false | readonly ChangeRule[] | undefined;
27
+ coverage?: import("../coverage.js").ServiceCoverage | undefined;
23
28
  name: string;
24
29
  owner: string;
25
30
  reference: string;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { validateChangeRules } from "../change-rules.js";
2
3
  /** containerd/CRI use fully qualified names, including the default tag. */
3
4
  export function canonicalImageReference(reference) {
4
5
  if (typeof reference !== "string" || !reference || reference.startsWith("-") || /[\s\x00]/.test(reference)) {
@@ -52,8 +53,14 @@ export function kubernetesImages(images) {
52
53
  if (typeof image === "string")
53
54
  pulls.push(image);
54
55
  else {
55
- const { coverage: _coverage, ...build } = image;
56
- builds.push({ reference, image: image.coverage === undefined ? image : build });
56
+ const { coverage, changeRules, ...build } = image;
57
+ if (changeRules !== undefined && changeRules !== false)
58
+ validateChangeRules(changeRules);
59
+ builds.push({ reference,
60
+ image: coverage === undefined && changeRules === undefined ? image : build,
61
+ ...(coverage === undefined ? {} : { coverage }),
62
+ ...(changeRules === undefined ? {} : { changeRules }),
63
+ });
57
64
  }
58
65
  }
59
66
  return { pulls, builds };
@@ -72,6 +79,12 @@ export function componentBuildTasks(services) {
72
79
  owner: svc.name,
73
80
  reference: build.reference,
74
81
  image: build.image,
82
+ ...(build.coverage === undefined ? {} : { coverage: build.coverage }),
83
+ // An image may specialize the cluster's explicit overrides. Each task
84
+ // keeps its own context and adapter source root; policies never leak to
85
+ // another image merely because the same cluster runs both.
86
+ ...(build.changeRules !== undefined || svc.changeRules !== undefined
87
+ ? { changeRules: build.changeRules ?? svc.changeRules } : {}),
75
88
  })));
76
89
  }
77
90
  /** Private handoff to a component's setup hook. Builds stay generic; the
@@ -1,8 +1,10 @@
1
1
  import { type CoverageCaptureContext, type CoverageInventoryContext, type ServiceCoverage } from "../coverage.js";
2
+ import type { ChangeRule } from "../change-rules.js";
2
3
  import { type ServiceCoverageCapture } from "./coverage.js";
3
4
  export declare function captureCoverageTarget(options: {
4
5
  context: CoverageCaptureContext & CoverageInventoryContext;
5
6
  coverage: ServiceCoverage;
7
+ changeRules?: readonly ChangeRule[] | false;
6
8
  root: string;
7
9
  baseline: boolean;
8
10
  inventoryAvailable?: boolean;
@@ -2,7 +2,7 @@
2
2
  // Counter baselines belong to an instance; source identity belongs to its image.
3
3
  import { promises as fs } from "node:fs";
4
4
  import path from "node:path";
5
- import { attachCoverageSourceOptions, coverageAdapters, coverageReportsMode, resolveCoverageSourceOptions } from "../coverage.js";
5
+ import { attachCoverageSourceOptions, coverageAdapters, coverageReportsMode, resolveCoverageSourceOptions, inventoryProjectFiles } from "../coverage.js";
6
6
  import { applyCoverageDelta, readCoverageDir } from "./coverage.js";
7
7
  export async function captureCoverageTarget(options) {
8
8
  const { context: ctx, coverage, root, baseline } = options;
@@ -34,8 +34,9 @@ export async function captureCoverageTarget(options) {
34
34
  }
35
35
  if (baseline && options.inventoryAvailable !== false) {
36
36
  const units = new Set();
37
+ const inventoryContext = { ...context, projectFiles: async () => inventoryProjectFiles({ coverage, changeRules: options.changeRules }, await context.projectFiles()) };
37
38
  for (const adapter of adapters)
38
- for (const unit of await adapter.inventory?.(context) ?? [])
39
+ for (const unit of await adapter.inventory?.(inventoryContext) ?? [])
39
40
  units.add(unit);
40
41
  await context.writeReport("inventory.units", [...units].sort().join("\n"));
41
42
  }
@@ -14,6 +14,8 @@ export declare function isExcluded(patterns: Pattern[], rel: string): boolean;
14
14
  export interface ReachSource {
15
15
  /** The service the source belongs to (for the shadow page's verdicts). */
16
16
  service?: string;
17
+ /** Frozen source-change policy, including adapter defaults and service overrides. */
18
+ changePolicy?: import("../change-rules.js").ChangePolicy;
17
19
  /** Build context directory, project-relative (`.` for the root); absent
18
20
  * for a registry image. */
19
21
  context?: string;
@@ -30,6 +32,9 @@ export interface ReachSource {
30
32
  * directory covers its subtree. */
31
33
  inputs?: string[];
32
34
  }
35
+ /** Environment/test sources remain owned even when every container has a narrow
36
+ * build context. Their common policy ignores nearby documentation. */
37
+ export declare function environmentReachSource(service: string): ReachSource;
33
38
  /**
34
39
  * The project's reach: every project file (`files`, project-relative)
35
40
  * some service's build context includes or some volume mounts, plus the
@@ -38,7 +43,7 @@ export interface ReachSource {
38
43
  export declare function projectReach(files: readonly string[], sources: readonly ReachSource[]): string[];
39
44
  /** The `reach.json` report. Mirrors `reach::ReachRules` on the server. */
40
45
  export interface ReachRules {
41
- version: 1;
46
+ version: 2;
42
47
  /** Every service's contribution, as rules. */
43
48
  sources: ReachSource[];
44
49
  /** The current file reach, compacted: `dir/` for a directory every
@@ -1,35 +1,4 @@
1
- // What a change to a project file can reach — the pure part.
2
- //
3
- // A project file affects a test only through one of three doors: it is in
4
- // a service's **build context** (so an image may carry it), it is the
5
- // **source of a volume** a service mounts, or a test **reads** it through
6
- // the SDK. The first two are known from the environment config before any
7
- // test runs; the harness ships them with the bring-up capture as
8
- // **rules** (`reach.json`, under the synthetic `__environment__`
9
- // service): per service its context directory, the composed
10
- // `.dockerignore` text, the Dockerfile it reads and the volume sources it
11
- // mounts. Rules, not a file list: the control plane evaluates any changed
12
- // path against them, a file that did not exist at the base included, and
13
- // a project of ten thousand files ships a few lines. Beside the rules
14
- // rides a **compacted** view of the current file reach — a directory
15
- // every file of which is in the reach as one `dir/` prefix, the odd file
16
- // alone — which the control plane uses as a fast path and the shadow page
17
- // as the summary. The subset selector then treats a changed file outside
18
- // the reach as one that cannot affect the suite: `site/**` behind a
19
- // `.dockerignore`, `.github/**` outside every context. The third door is
20
- // recorded as it happens (`project-files.ts`, `takeProjectReads`).
21
- //
22
- // The rules are shipped for every project, coverage adapters or not: the
23
- // reach is a property of the environment config alone.
24
- //
25
- // The context of a build is what `docker build` would send: every file
26
- // under the context directory minus what the composed `.dockerignore`
27
- // (the project's own rules plus the service's `exclude`) matches, with
28
- // Docker's semantics — a pattern is matched against the path relative to
29
- // the context root, `*` and `?` stay inside one segment, `**` spans
30
- // segments, `!` re-includes, and a pattern that matches a directory
31
- // excludes everything below it. When in doubt the answer is "reachable":
32
- // over-approximating the reach only costs a full run.
1
+ import { resolveChangePolicy } from "../change-rules.js";
33
2
  /** Docker's `.dockerignore` pattern → a regex over a `/`-separated path,
34
3
  * after moby's `patternmatcher`. */
35
4
  function compilePattern(raw) {
@@ -116,6 +85,11 @@ function normRel(p) {
116
85
  s = s.replace(/^\/+/, "").replace(/\/+$/, "");
117
86
  return s === "." ? "" : s;
118
87
  }
88
+ /** Environment/test sources remain owned even when every container has a narrow
89
+ * build context. Their common policy ignores nearby documentation. */
90
+ export function environmentReachSource(service) {
91
+ return { service, inputs: ["spectest"], changePolicy: resolveChangePolicy({}) };
92
+ }
119
93
  /**
120
94
  * The project's reach: every project file (`files`, project-relative)
121
95
  * some service's build context includes or some volume mounts, plus the
@@ -147,7 +121,7 @@ export function projectReach(files, sources) {
147
121
  continue;
148
122
  const src = normRel(v);
149
123
  for (const f of files) {
150
- if (f === src || f.startsWith(`${src}/`))
124
+ if (src === "" || f === src || f.startsWith(`${src}/`))
151
125
  out.add(f);
152
126
  }
153
127
  }
@@ -240,7 +214,7 @@ export function sourceOf(sources, path) {
240
214
  if (v.startsWith("/"))
241
215
  continue;
242
216
  const src = normRel(v);
243
- if (p === src || p.startsWith(`${src}/`))
217
+ if (src === "" || p === src || p.startsWith(`${src}/`))
244
218
  return s;
245
219
  }
246
220
  }
@@ -3,10 +3,11 @@ export interface ShadowBuilder {
3
3
  builder: string;
4
4
  registry: string;
5
5
  experiment: string;
6
+ imageFormat?: "nydus-v6";
6
7
  }
7
8
  export declare function validateShadowBuilder(value: unknown): ShadowBuilder;
8
9
  export declare function shadowBuilder(): Promise<ShadowBuilder | undefined>;
9
10
  export declare function shadowImage(c: ShadowBuilder, service: string): string;
10
- export declare function shadowOutput(reference: string): string;
11
+ export declare function shadowOutput(reference: string, config?: ShadowBuilder): string;
11
12
  export declare function imageDigest(metadata: unknown): string;
12
13
  export declare function shadowTiming(service: string, phase: string, started: number, extra?: {}): Promise<void>;
@@ -5,7 +5,8 @@ export function validateShadowBuilder(value) {
5
5
  const c = value;
6
6
  if (!c || c.version !== 1 || !/^shadow-[a-z0-9-]+$/.test(c.builder)
7
7
  || !/^[a-z0-9.-]+(?::[0-9]+)?$/.test(c.registry)
8
- || !/^[a-z0-9-]{1,80}$/.test(c.experiment))
8
+ || !/^[a-z0-9-]{1,80}$/.test(c.experiment)
9
+ || (c.imageFormat !== undefined && c.imageFormat !== "nydus-v6"))
9
10
  throw new Error("invalid shadow builder configuration");
10
11
  return c;
11
12
  }
@@ -19,8 +20,13 @@ export function shadowImage(c, service) {
19
20
  const id = createHash("sha256").update(service).digest("hex").slice(0, 32);
20
21
  return `${c.registry}/shadow/${c.experiment}/${id}:result`;
21
22
  }
22
- export function shadowOutput(reference) {
23
- return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd`;
23
+ // Convert inherited gzip layers too; the persistent builder caches the conversion.
24
+ // Level 1 favors export latency over a small additional size reduction.
25
+ export function shadowOutput(reference, config) {
26
+ if (config?.imageFormat === "nydus-v6") {
27
+ return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=nydus,force-compression=true`;
28
+ }
29
+ return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd,compression-level=1,force-compression=true`;
24
30
  }
25
31
  export function imageDigest(metadata) {
26
32
  const digest = metadata?.["containerimage.digest"];
package/dist/index.d.ts CHANGED
@@ -38,6 +38,8 @@ export type { Terminal, TerminalOpts, TerminalResult, TerminalWaitOptions, Termi
38
38
  import type { Terminal, TerminalOpts, TerminalResult } from "./terminal.js";
39
39
  export { type CoverageAdapter, type CoverageCaptureContext, type CoverageConfigureInfo, type CoverageSourceOptions, type CoverageSourceContext, type CoverageLoadContext, type ServiceCoverage, } from "./coverage.js";
40
40
  import { type ServiceCoverage } from "./coverage.js";
41
+ export { type ChangeRule, type ChangeAction, COMMON_CHANGE_RULES } from "./change-rules.js";
42
+ import type { ChangeRule } from "./change-rules.js";
41
43
  export { ca } from "./environment-ca.js";
42
44
  export { certificate, dnsName, proxy, provides, lowerIngress, isWildcard, SELF_SERVICE_TOKEN, } from "./ingress.js";
43
45
  export type { CorsMode, CertificateDecl, DnsDecl, ProxyDecl, IngressDecl, DnsTarget, LoweredIngress, } from "./ingress.js";
@@ -197,6 +199,9 @@ export interface ServiceConfig {
197
199
  * `spectest docs /services/coverage`.
198
200
  */
199
201
  coverage?: ServiceCoverage;
202
+ /** Ordered source-change overrides, independent of coverage collection.
203
+ * Last matching rule wins. false disables inherited defaults. */
204
+ changeRules?: readonly ChangeRule[] | false;
200
205
  /**
201
206
  * Files seeded into the container's filesystem **before it starts**.
202
207
  * Each entry's `content` is written to a VM-host staging path and
@@ -880,6 +885,9 @@ export type ServiceImage = string | BuildImage | NormalizedServiceImage;
880
885
  export declare function normalizeServiceImage(image: ServiceImage): NormalizedServiceImage;
881
886
  /** @internal Serializes build-only work with the owning component. */
882
887
  export interface ComponentImageBuild {
888
+ /** Coverage and selection policy of the built application image. */
889
+ coverage?: ServiceCoverage;
890
+ changeRules?: readonly ChangeRule[] | false;
883
891
  reference: string;
884
892
  image: BuildImage;
885
893
  }
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ import { isLocator, getLocatorProbe, isBrowserSession, getBrowserProbe, } from "
42
42
  import { escapeInvisible, textDifferenceNote, } from "./text-match.js";
43
43
  import { describeUrlPattern } from "./url-match.js";
44
44
  import { applyCoverageAdapters, coverageAdapters, validateCoverage } from "./coverage.js";
45
+ export { COMMON_CHANGE_RULES } from "./change-rules.js";
45
46
  import { resolveExistingProjectPath } from "./project-files.js";
46
47
  export { ca } from "./environment-ca.js";
47
48
  // Low-level ingress primitives + the framework lowering that the friendly
@@ -185,6 +186,10 @@ function expandServiceGroups(input) {
185
186
  // Rewrite relative dependsOn to final keys; a spread keeps function
186
187
  // fields (helpers/setup) and the enumerable-symbol ingress decls.
187
188
  const final = { ...svc, ...(part === entry.primary ? primaryOverrides : {}) };
189
+ // A group-level selection override applies to every generated owner.
190
+ if (primaryOverrides.changeRules !== undefined)
191
+ final.changeRules = primaryOverrides.changeRules === false
192
+ ? false : [...(svc.changeRules || []), ...primaryOverrides.changeRules];
188
193
  // Component-owned nested containers keep their collection when callers
189
194
  // add ordinary service coverage (for example browser coverage on k3s).
190
195
  const collectors = coverageAdapters(svc.coverage).filter(adapter => adapter.collect);
@@ -1163,8 +1163,6 @@ function serializeElementNode(n, options) {
1163
1163
  canvasCtx = canvasService.getContext("2d");
1164
1164
  }
1165
1165
  const image = n;
1166
- const imageSrc = image.currentSrc || image.getAttribute("src") || "<unknown-src>";
1167
- const priorCrossOrigin = image.crossOrigin;
1168
1166
  const recordInlineImage = () => {
1169
1167
  image.removeEventListener("load", recordInlineImage);
1170
1168
  try {
@@ -1175,21 +1173,8 @@ function serializeElementNode(n, options) {
1175
1173
  dataURLOptions.type,
1176
1174
  dataURLOptions.quality
1177
1175
  );
1178
- } catch (err) {
1179
- if (image.crossOrigin !== "anonymous") {
1180
- image.crossOrigin = "anonymous";
1181
- if (image.complete && image.naturalWidth !== 0)
1182
- recordInlineImage();
1183
- else image.addEventListener("load", recordInlineImage);
1184
- return;
1185
- } else {
1186
- console.warn(
1187
- `Cannot inline img src=${imageSrc}! Error: ${err}`
1188
- );
1189
- }
1190
- }
1191
- if (image.crossOrigin === "anonymous") {
1192
- priorCrossOrigin ? attributes.crossOrigin = priorCrossOrigin : image.removeAttribute("crossorigin");
1176
+ } catch (e) {
1177
+ return;
1193
1178
  }
1194
1179
  };
1195
1180
  if (image.complete && image.naturalWidth !== 0) recordInlineImage();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.20",
3
+ "version": "0.132.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,10 +14,13 @@ test.skipIf(!existsSync(executablePath))("cross-site script ID reuse cannot attr
14
14
  const map = Buffer.from(JSON.stringify({ version: 3, sources: ["src/customer.ts"], names: [], mappings: "AAAA" })).toString("base64");
15
15
  const server = Bun.serve({ hostname: "0.0.0.0", port: 0, fetch(request) {
16
16
  const url = new URL(request.url);
17
+ if (url.pathname === "/padding.js") return new Response("void 0;", { headers: { "content-type": "application/javascript" } });
17
18
  if (url.pathname === emptyScript) return new Response("", { headers: { "content-type": "application/javascript" } });
18
19
  if (url.pathname === "/app.js") return new Response(`globalThis.appRan = true;\n//# sourceMappingURL=data:application/json;base64,${map}`, { headers: { "content-type": "application/javascript" } });
19
20
  return new Response(url.hostname === "127.0.0.1"
20
- ? `<script src="${emptyScript}"></script><script src="/app.js"></script><p>Application</p>`
21
+ // Advance the application's script IDs past browser-injected scripts.
22
+ // Padding is served outside the covered service and has no source map.
23
+ ? `${Array.from({ length: 32 }, (_, i) => `<script src="http://127.0.0.2:${url.port}/padding.js?${i}"></script>`).join("")}<script src="${emptyScript}"></script><script src="/app.js"></script><p>Application</p>`
21
24
  : "<p>External login</p>", { headers: { "content-type": "text/html", "cache-control": "no-store" } });
22
25
  } });
23
26
  const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox", "--site-per-process"] });
@@ -36,7 +39,12 @@ test.skipIf(!existsSync(executablePath))("cross-site script ID reuse cannot attr
36
39
  // Keep collected application counts across navigation until service capture.
37
40
  for (const host of ["127.0.0.1", "127.0.0.2", "127.0.0.1", "127.0.0.2"]) {
38
41
  await page.goto(`http://${host}:${server.port}/`);
39
- await page.evaluate("(() => { const value = 1; return value + 1; })()");
42
+ // V8's first evaluation ID varies across browser versions and runners.
43
+ // Generate distinct anonymous scripts until the old named ID is reused.
44
+ for (let i = 0; i < 128; i++) {
45
+ await cdp.send("Runtime.evaluate", { expression: `(() => { const value = ${i}; return value + 1; })()` });
46
+ if (host === "127.0.0.1" || anonymousReuse) break;
47
+ }
40
48
  await harvestBrowserCoverage(cdp);
41
49
  }
42
50
  expect(anonymousReuse).toBe(true); // Prove the regression's trigger happened.