@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
@@ -1,7 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { BuildImage, ComponentImageBuild, ServiceConfig } from "../index.js";
3
+ import { validateChangeRules, type ChangeRule } from "../change-rules.js";
3
4
 
4
- export type KubernetesImage = string | (BuildImage & { reference: string; coverage?: import("../coverage.js").ServiceCoverage });
5
+ export type KubernetesImage = string | (BuildImage & {
6
+ reference: string;
7
+ coverage?: import("../coverage.js").ServiceCoverage;
8
+ /** Override change-selection defaults for this image's own build context. */
9
+ changeRules?: readonly ChangeRule[] | false;
10
+ });
5
11
 
6
12
  /** containerd/CRI use fully qualified names, including the default tag. */
7
13
  export function canonicalImageReference(reference: string): string {
@@ -51,8 +57,13 @@ export function kubernetesImages(images: readonly KubernetesImage[]): { pulls: s
51
57
  seen.set(reference, identity);
52
58
  if (typeof image === "string") pulls.push(image);
53
59
  else {
54
- const { coverage: _coverage, ...build } = image;
55
- builds.push({ reference, image: image.coverage === undefined ? image : build });
60
+ const { coverage, changeRules, ...build } = image;
61
+ if (changeRules !== undefined && changeRules !== false) validateChangeRules(changeRules);
62
+ builds.push({ reference,
63
+ image: coverage === undefined && changeRules === undefined ? image : build,
64
+ ...(coverage === undefined ? {} : { coverage }),
65
+ ...(changeRules === undefined ? {} : { changeRules }),
66
+ });
56
67
  }
57
68
  }
58
69
  return { pulls, builds };
@@ -74,6 +85,12 @@ export function componentBuildTasks<T extends ServiceConfig & { name: string }>(
74
85
  owner: svc.name,
75
86
  reference: build.reference,
76
87
  image: build.image,
88
+ ...(build.coverage === undefined ? {} : { coverage: build.coverage }),
89
+ // An image may specialize the cluster's explicit overrides. Each task
90
+ // keeps its own context and adapter source root; policies never leak to
91
+ // another image merely because the same cluster runs both.
92
+ ...(build.changeRules !== undefined || svc.changeRules !== undefined
93
+ ? { changeRules: build.changeRules ?? svc.changeRules } : {}),
77
94
  })));
78
95
  }
79
96
 
@@ -3,12 +3,14 @@
3
3
  import { promises as fs } from "node:fs";
4
4
  import path from "node:path";
5
5
  import { attachCoverageSourceOptions, coverageAdapters, coverageReportsMode, resolveCoverageSourceOptions,
6
- type CoverageCaptureContext, type CoverageInventoryContext, type ServiceCoverage } from "../coverage.js";
6
+ inventoryProjectFiles, type CoverageCaptureContext, type CoverageInventoryContext, type ServiceCoverage } from "../coverage.js";
7
+ import type { ChangeRule } from "../change-rules.js";
7
8
  import { applyCoverageDelta, readCoverageDir, type ServiceCoverageCapture } from "./coverage.js";
8
9
 
9
10
  export async function captureCoverageTarget(options: {
10
11
  context: CoverageCaptureContext & CoverageInventoryContext;
11
12
  coverage: ServiceCoverage;
13
+ changeRules?: readonly ChangeRule[] | false;
12
14
  root: string;
13
15
  baseline: boolean;
14
16
  inventoryAvailable?: boolean;
@@ -39,7 +41,8 @@ export async function captureCoverageTarget(options: {
39
41
  }
40
42
  if (baseline && options.inventoryAvailable !== false) {
41
43
  const units = new Set<string>();
42
- for (const adapter of adapters) for (const unit of await adapter.inventory?.(context) ?? []) units.add(unit);
44
+ const inventoryContext = {...context, projectFiles: async () => inventoryProjectFiles({coverage,changeRules:options.changeRules}, await context.projectFiles())};
45
+ for (const adapter of adapters) for (const unit of await adapter.inventory?.(inventoryContext) ?? []) units.add(unit);
43
46
  await context.writeReport("inventory.units", [...units].sort().join("\n"));
44
47
  }
45
48
  ctx.signal.throwIfAborted();
@@ -1,5 +1,6 @@
1
+ import { evaluateChangePolicy } from "../change-rules.js";
1
2
  import { describe, expect, test } from "bun:test";
2
- import { compactReach, compileDockerignore, isExcluded, projectReach, reachContains, renderReadsLcov, sourceOf } from "./reach.js";
3
+ import { compactReach, environmentReachSource, compileDockerignore, isExcluded, projectReach, reachContains, renderReadsLcov, sourceOf } from "./reach.js";
3
4
 
4
5
  describe("dockerignore semantics", () => {
5
6
  const ex = (text: string, rel: string) => isExcluded(compileDockerignore(text), rel);
@@ -87,7 +88,7 @@ describe("reach rules", () => {
87
88
  { service: "db", volumeSources: ["data", "/var/run/docker.sock"] },
88
89
  ];
89
90
  const reach = projectReach(files, sources);
90
- const rules = { version: 1 as const, sources, prefixes: compactReach(files, reach) };
91
+ const rules = { version: 2 as const, sources, prefixes: compactReach(files, reach) };
91
92
 
92
93
  test("compactReach folds a wholly reached directory into one prefix and keeps the odd file", () => {
93
94
  // packages/api holds README.md, which the ignore drops, so the
@@ -125,7 +126,7 @@ describe("reach rules", () => {
125
126
  expect(sourceOf([sources[1]!], "var/run/docker.sock")).toBeUndefined();
126
127
  // The Dockerfile named by path, and the one at the context root.
127
128
  expect(sourceOf(sources, "packages/api/Dockerfile")?.service).toBe("api");
128
- expect(reachContains({ version: 1, sources: [{ context: "packages/x", ignore: "**" }], prefixes: [] }, "packages/x/Dockerfile")).toBe(true);
129
+ expect(reachContains({ version: 2, sources: [{ context: "packages/x", ignore: "**" }], prefixes: [] }, "packages/x/Dockerfile")).toBe(true);
129
130
  });
130
131
 
131
132
  test("a service's declared inputs count: a lambda's source directory, a config file", () => {
@@ -134,14 +135,47 @@ describe("reach rules", () => {
134
135
  "lambda/add/index.js",
135
136
  "supabase/config.toml",
136
137
  ]);
137
- const rules = { version: 1 as const, sources: srcs, prefixes: [] };
138
+ const rules = { version: 2 as const, sources: srcs, prefixes: [] };
138
139
  expect(reachContains(rules, "lambda/add/new.js")).toBe(true);
139
140
  expect(sourceOf(srcs, "lambda/add/new.js")?.service).toBe("aws");
140
141
  expect(reachContains(rules, "lambda/other/index.js")).toBe(false);
141
142
  });
142
143
 
144
+ test("repository-root inputs and volumes reach existing and newly added files", () => {
145
+ for (const field of ["inputs", "volumeSources"] as const) {
146
+ for (const root of [".", "./"]) {
147
+ const source = { service: "root-consumer", [field]: [root] };
148
+ expect(projectReach(files, [source])).toEqual([...files].sort());
149
+ const rules = { version: 2 as const, sources: [source], prefixes: [] };
150
+ for (const path of ["new.ts", "new/directory/source.ts"]) {
151
+ expect(reachContains(rules, path)).toBe(true);
152
+ expect(sourceOf([source], path)?.service).toBe("root-consumer");
153
+ }
154
+ }
155
+ const absolute = { [field]: ["/", "/var/run/docker.sock"] };
156
+ expect(projectReach(files, [absolute])).toEqual([]);
157
+ expect(sourceOf([absolute], "var/run/docker.sock")).toBeUndefined();
158
+ expect(reachContains({ version: 2, sources: [absolute], prefixes: [] }, "new.ts")).toBe(false);
159
+ }
160
+ });
161
+
143
162
  test("the whole project as one prefix", () => {
144
- expect(reachContains({ version: 1, sources: [], prefixes: ["."] }, "anything/at/all")).toBe(true);
145
- expect(reachContains({ version: 1, sources: [], prefixes: [] }, "anything/at/all")).toBe(false);
163
+ expect(reachContains({ version: 2, sources: [], prefixes: ["."] }, "anything/at/all")).toBe(true);
164
+ expect(reachContains({ version: 2, sources: [], prefixes: [] }, "anything/at/all")).toBe(false);
146
165
  });
147
166
  });
167
+
168
+
169
+ test("environment policy owns Spectest Markdown outside narrow container contexts", () => {
170
+ const environment = environmentReachSource("__environment__");
171
+ const sources = [environment, {service:"app",context:"src"}];
172
+ const rules = {version:2 as const,sources,prefixes:[]};
173
+ for (const file of ["spectest/README.md", "spectest/tests/notes.md"]) {
174
+ expect(reachContains(rules,file)).toBe(true);
175
+ expect(sourceOf(sources,file)?.service).toBe("__environment__");
176
+ expect(evaluateChangePolicy(environment.changePolicy!,file)).toBe("ignore");
177
+ }
178
+ expect(evaluateChangePolicy(environment.changePolicy!,"spectest/index.ts")).toBe("full");
179
+ expect(evaluateChangePolicy(environment.changePolicy!,"spectest/tests/helper.ts")).toBe("coverage");
180
+ expect(reachContains(rules,"unrelated/file.ts")).toBe(false);
181
+ });
@@ -1,3 +1,5 @@
1
+ import { resolveChangePolicy } from "../change-rules.js";
2
+
1
3
  // What a change to a project file can reach — the pure part.
2
4
  //
3
5
  // A project file affects a test only through one of three doors: it is in
@@ -114,6 +116,8 @@ export function isExcluded(patterns: Pattern[], rel: string): boolean {
114
116
  export interface ReachSource {
115
117
  /** The service the source belongs to (for the shadow page's verdicts). */
116
118
  service?: string;
119
+ /** Frozen source-change policy, including adapter defaults and service overrides. */
120
+ changePolicy?: import("../change-rules.js").ChangePolicy;
117
121
  /** Build context directory, project-relative (`.` for the root); absent
118
122
  * for a registry image. */
119
123
  context?: string;
@@ -138,6 +142,12 @@ function normRel(p: string): string {
138
142
  return s === "." ? "" : s;
139
143
  }
140
144
 
145
+ /** Environment/test sources remain owned even when every container has a narrow
146
+ * build context. Their common policy ignores nearby documentation. */
147
+ export function environmentReachSource(service: string): ReachSource {
148
+ return {service,inputs:["spectest"],changePolicy:resolveChangePolicy({})};
149
+ }
150
+
141
151
  /**
142
152
  * The project's reach: every project file (`files`, project-relative)
143
153
  * some service's build context includes or some volume mounts, plus the
@@ -165,7 +175,7 @@ export function projectReach(files: readonly string[], sources: readonly ReachSo
165
175
  if (v.startsWith("/")) continue;
166
176
  const src = normRel(v);
167
177
  for (const f of files) {
168
- if (f === src || f.startsWith(`${src}/`)) out.add(f);
178
+ if (src === "" || f === src || f.startsWith(`${src}/`)) out.add(f);
169
179
  }
170
180
  }
171
181
  }
@@ -174,7 +184,7 @@ export function projectReach(files: readonly string[], sources: readonly ReachSo
174
184
 
175
185
  /** The `reach.json` report. Mirrors `reach::ReachRules` on the server. */
176
186
  export interface ReachRules {
177
- version: 1;
187
+ version: 2;
178
188
  /** Every service's contribution, as rules. */
179
189
  sources: ReachSource[];
180
190
  /** The current file reach, compacted: `dir/` for a directory every
@@ -259,7 +269,7 @@ export function sourceOf(sources: readonly ReachSource[], path: string): ReachSo
259
269
  for (const v of [...(s.volumeSources ?? []), ...(s.inputs ?? [])]) {
260
270
  if (v.startsWith("/")) continue;
261
271
  const src = normRel(v);
262
- if (p === src || p.startsWith(`${src}/`)) return s;
272
+ if (src === "" || p === src || p.startsWith(`${src}/`)) return s;
263
273
  }
264
274
  }
265
275
  return undefined;
@@ -5,7 +5,10 @@ test("shadow image namespaces isolate experiments and hash untrusted service nam
5
5
  const a = shadowImage(config, "../../other,compression=gzip");
6
6
  expect(a).toMatch(/^registry.example.com\/shadow\/experiment-1\/[a-f0-9]{32}:result$/);
7
7
  expect(shadowImage({ ...config, experiment: "experiment-2" }, "api")).not.toBe(shadowImage(config, "api"));
8
- expect(shadowOutput(a)).toContain("compression=zstd");
8
+ expect(shadowOutput(a).split(",")).toEqual([
9
+ "type=image", `name=${a}`, "push=true", "oci-mediatypes=true",
10
+ "compression=zstd", "compression-level=1", "force-compression=true",
11
+ ]);
9
12
  });
10
13
  test("configuration rejects exporter and endpoint injection", () => {
11
14
  expect(validateShadowBuilder(config)).toEqual(config);
@@ -27,3 +30,11 @@ test("ordinary SDK execution never needs remote builder configuration", async ()
27
30
  else process.env.SPECTEST_SHADOW_BUILD = previous;
28
31
  }
29
32
  });
33
+
34
+ // Switching formats must be an explicit controller-selected shadow artifact.
35
+ test("Nydus export requires the explicit shadow format and rejects unknown modes", () => {
36
+ const nydus = validateShadowBuilder({ ...config, imageFormat: "nydus-v6" });
37
+ expect(shadowOutput(shadowImage(nydus, "api"), nydus)).toContain("compression=nydus,force-compression=true");
38
+ expect(() => validateShadowBuilder({ ...config, imageFormat: "unknown" })).toThrow();
39
+ expect(shadowOutput(shadowImage(config, "api"), config)).toContain("compression=zstd");
40
+ });
@@ -7,12 +7,14 @@ export interface ShadowBuilder {
7
7
  builder: string;
8
8
  registry: string;
9
9
  experiment: string;
10
+ imageFormat?: "nydus-v6";
10
11
  }
11
12
  export function validateShadowBuilder(value: unknown): ShadowBuilder {
12
13
  const c = value as ShadowBuilder;
13
14
  if (!c || c.version !== 1 || !/^shadow-[a-z0-9-]+$/.test(c.builder)
14
15
  || !/^[a-z0-9.-]+(?::[0-9]+)?$/.test(c.registry)
15
- || !/^[a-z0-9-]{1,80}$/.test(c.experiment)) throw new Error("invalid shadow builder configuration");
16
+ || !/^[a-z0-9-]{1,80}$/.test(c.experiment)
17
+ || (c.imageFormat !== undefined && c.imageFormat !== "nydus-v6")) throw new Error("invalid shadow builder configuration");
16
18
  return c;
17
19
  }
18
20
  export async function shadowBuilder(): Promise<ShadowBuilder | undefined> {
@@ -24,8 +26,13 @@ export function shadowImage(c: ShadowBuilder, service: string): string {
24
26
  const id = createHash("sha256").update(service).digest("hex").slice(0, 32);
25
27
  return `${c.registry}/shadow/${c.experiment}/${id}:result`;
26
28
  }
27
- export function shadowOutput(reference: string): string {
28
- return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd`;
29
+ // Convert inherited gzip layers too; the persistent builder caches the conversion.
30
+ // Level 1 favors export latency over a small additional size reduction.
31
+ export function shadowOutput(reference: string, config?: ShadowBuilder): string {
32
+ if (config?.imageFormat === "nydus-v6") {
33
+ return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=nydus,force-compression=true`;
34
+ }
35
+ return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd,compression-level=1,force-compression=true`;
29
36
  }
30
37
  export function imageDigest(metadata: unknown): string {
31
38
  const digest = (metadata as Record<string, unknown>)?.["containerimage.digest"];
package/src/index.ts CHANGED
@@ -176,6 +176,8 @@ export {
176
176
  type ServiceCoverage,
177
177
  } from "./coverage.js";
178
178
  import { applyCoverageAdapters, coverageAdapters, validateCoverage, type ServiceCoverage } from "./coverage.js";
179
+ export { type ChangeRule, type ChangeAction, COMMON_CHANGE_RULES } from "./change-rules.js";
180
+ import type { ChangeRule } from "./change-rules.js";
179
181
  import { resolveExistingProjectPath } from "./project-files.js";
180
182
  export { ca } from "./environment-ca.js";
181
183
 
@@ -365,6 +367,9 @@ export interface ServiceConfig {
365
367
  * `spectest docs /services/coverage`.
366
368
  */
367
369
  coverage?: ServiceCoverage;
370
+ /** Ordered source-change overrides, independent of coverage collection.
371
+ * Last matching rule wins. false disables inherited defaults. */
372
+ changeRules?: readonly ChangeRule[] | false;
368
373
  /**
369
374
  * Files seeded into the container's filesystem **before it starts**.
370
375
  * Each entry's `content` is written to a VM-host staging path and
@@ -1138,6 +1143,9 @@ function expandServiceGroups(input: InputServicesMap): ServicesMap {
1138
1143
  // Rewrite relative dependsOn to final keys; a spread keeps function
1139
1144
  // fields (helpers/setup) and the enumerable-symbol ingress decls.
1140
1145
  const final: ServiceConfig = { ...svc, ...(part === entry.primary ? primaryOverrides : {}) };
1146
+ // A group-level selection override applies to every generated owner.
1147
+ if (primaryOverrides.changeRules !== undefined) final.changeRules = primaryOverrides.changeRules === false
1148
+ ? false : [...(svc.changeRules || []), ...primaryOverrides.changeRules];
1141
1149
  // Component-owned nested containers keep their collection when callers
1142
1150
  // add ordinary service coverage (for example browser coverage on k3s).
1143
1151
  const collectors = coverageAdapters(svc.coverage).filter(adapter => adapter.collect);
@@ -1321,6 +1329,9 @@ export function normalizeServiceImage(image: ServiceImage): NormalizedServiceIma
1321
1329
 
1322
1330
  /** @internal Serializes build-only work with the owning component. */
1323
1331
  export interface ComponentImageBuild {
1332
+ /** Coverage and selection policy of the built application image. */
1333
+ coverage?: ServiceCoverage;
1334
+ changeRules?: readonly ChangeRule[] | false;
1324
1335
  reference: string;
1325
1336
  image: BuildImage;
1326
1337
  }
@@ -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();