@specific.dev/spectest 0.131.21 → 0.132.2

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 (71) hide show
  1. package/dist/browser-coverage.d.ts +3 -0
  2. package/dist/browser-coverage.js +84 -31
  3. package/dist/browser.d.ts +4 -0
  4. package/dist/browser.js +80 -22
  5. package/dist/change-rules.d.ts +45 -0
  6. package/dist/change-rules.js +179 -0
  7. package/dist/components/k3s-coverage.js +4 -4
  8. package/dist/components/supabase-coverage.js +12 -0
  9. package/dist/components/supabase.js +25 -12
  10. package/dist/coverage-bun.js +2 -1
  11. package/dist/coverage-next.d.ts +5 -0
  12. package/dist/coverage-next.js +27 -3
  13. package/dist/coverage-rust.d.ts +2 -0
  14. package/dist/coverage-rust.js +20 -0
  15. package/dist/coverage.d.ts +27 -3
  16. package/dist/coverage.js +68 -15
  17. package/dist/daemon.js +13 -10
  18. package/dist/harness/component-images.d.ts +5 -0
  19. package/dist/harness/component-images.js +15 -2
  20. package/dist/harness/coverage-target.d.ts +2 -0
  21. package/dist/harness/coverage-target.js +3 -2
  22. package/dist/harness/reach.d.ts +6 -1
  23. package/dist/harness/reach.js +8 -34
  24. package/dist/harness/shadow-builder.d.ts +2 -1
  25. package/dist/harness/shadow-builder.js +6 -2
  26. package/dist/index.d.ts +8 -0
  27. package/dist/index.js +5 -0
  28. package/package.json +1 -1
  29. package/src/browser-clipboard.test.ts +177 -0
  30. package/src/browser-coverage-deadline.test.ts +2 -0
  31. package/src/browser-coverage-identity.test.ts +114 -2
  32. package/src/browser-coverage-navigation.test.ts +1 -2
  33. package/src/browser-coverage-sdk.test.ts +91 -0
  34. package/src/browser-coverage.ts +95 -30
  35. package/src/browser-executable.test.ts +7 -5
  36. package/src/browser-finalization.test.ts +1 -1
  37. package/src/browser-image-loading.test.ts +1 -1
  38. package/src/browser-navigation.test.ts +1 -1
  39. package/src/browser-permissions.test.ts +143 -0
  40. package/src/browser-replay-transport.test.ts +60 -0
  41. package/src/browser-screenshot.test.ts +1 -2
  42. package/src/browser-timezone.test.ts +1 -1
  43. package/src/browser.ts +79 -20
  44. package/src/change-rules.fixtures.json +35 -0
  45. package/src/change-rules.test.ts +125 -0
  46. package/src/change-rules.ts +147 -0
  47. package/src/components/k3s-coverage.test.ts +32 -0
  48. package/src/components/k3s-coverage.ts +4 -4
  49. package/src/components/supabase-coverage.ts +12 -0
  50. package/src/components/supabase-dir.test.ts +26 -0
  51. package/src/components/supabase-functions-reach.test.ts +41 -1
  52. package/src/components/supabase-migrations.test.ts +1 -1
  53. package/src/components/supabase.ts +26 -12
  54. package/src/coverage-bun.ts +2 -1
  55. package/src/coverage-next.ts +29 -3
  56. package/src/coverage-rust.test.ts +34 -0
  57. package/src/coverage-rust.ts +22 -0
  58. package/src/coverage.test.ts +2 -1
  59. package/src/coverage.ts +76 -7
  60. package/src/daemon.ts +14 -9
  61. package/src/harness/component-images.test.ts +42 -0
  62. package/src/harness/component-images.ts +20 -3
  63. package/src/harness/coverage-target.ts +5 -2
  64. package/src/harness/reach.test.ts +40 -6
  65. package/src/harness/reach.ts +13 -3
  66. package/src/harness/shadow-builder.test.ts +8 -0
  67. package/src/harness/shadow-builder.ts +7 -2
  68. package/src/index.ts +11 -0
  69. package/src/microphone-fixture.test.ts +106 -15
  70. package/src/playwright-assertions.test.ts +3 -3
  71. package/src/recorder.ts +1 -1
@@ -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;
@@ -30,3 +30,11 @@ test("ordinary SDK execution never needs remote builder configuration", async ()
30
30
  else process.env.SPECTEST_SHADOW_BUILD = previous;
31
31
  }
32
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> {
@@ -26,7 +28,10 @@ export function shadowImage(c: ShadowBuilder, service: string): string {
26
28
  }
27
29
  // Convert inherited gzip layers too; the persistent builder caches the conversion.
28
30
  // Level 1 favors export latency over a small additional size reduction.
29
- export function shadowOutput(reference: string): string {
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
+ }
30
35
  return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd,compression-level=1,force-compression=true`;
31
36
  }
32
37
  export function imageDigest(metadata: unknown): string {
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
  }
@@ -49,44 +49,93 @@ test("missing and invalid audio fail before Chromium launches", async () => {
49
49
  await expect(prepareMicrophoneFixture({ path: "" })).rejects.toThrow("microphone.path");
50
50
  });
51
51
 
52
- async function capture(browser: MobileBackend): Promise<void> {
53
- await browser.silentRead(page => page.evaluate(async () => {
52
+ async function capture(browser: MobileBackend, sampleRate?: number): Promise<void> {
53
+ const actualRate = await browser.silentRead(page => page.evaluate(async sampleRate => {
54
54
  const stream = await navigator.mediaDevices.getUserMedia({ audio: {
55
55
  echoCancellation: false, noiseSuppression: false, autoGainControl: false,
56
56
  } });
57
- const context = new AudioContext();
57
+ // Analyse native microphone samples without depending on the host's audio
58
+ // output daemon. A stalled speaker sink can report a running context while
59
+ // its clock barely advances; no sound needs to be played by this test.
60
+ const context = new AudioContext({ sampleRate, sinkId: { type: "none" } } as AudioContextOptions);
61
+ const sink = (context as AudioContext & { sinkId: string | { type: string } }).sinkId;
62
+ if (typeof sink !== "object" || sink.type !== "none") throw new Error("native silent audio sink unavailable");
58
63
  const analyser = context.createAnalyser();
59
64
  analyser.fftSize = 4096;
65
+ analyser.smoothingTimeConstant = 0;
60
66
  context.createMediaStreamSource(stream).connect(analyser);
61
67
  await context.resume();
62
68
  (globalThis as any).__mic = { stream, context, analyser };
63
- }));
69
+ return context.sampleRate;
70
+ }, sampleRate));
71
+ if (sampleRate !== undefined) expect(actualRate).toBe(sampleRate);
64
72
  }
65
73
 
66
- async function peak(browser: MobileBackend): Promise<number> {
74
+ async function peak(browser: MobileBackend): Promise<{ frequency: number; binWidth: number }> {
67
75
  return browser.silentRead(page => page.evaluate(async () => {
68
76
  const { context, analyser } = (globalThis as any).__mic;
69
77
  const bins = new Float32Array(analyser.frequencyBinCount);
78
+ const windowSeconds = analyser.fftSize / context.sampleRate;
79
+ let audibleAt: number | undefined;
80
+ let sampledAt = -Infinity;
81
+ let previousBin = -1;
82
+ let stableWindows = 0;
70
83
  for (let i = 0; i < 100; i++) {
71
84
  analyser.getFloatFrequencyData(bins);
72
85
  const max = Math.max(...bins);
73
- if (max > -40) return bins.indexOf(max) * context.sampleRate / analyser.fftSize;
86
+ if (max > -40) {
87
+ audibleAt ??= context.currentTime;
88
+ // The first audible FFT still contains startup silence and can put
89
+ // even a pure tone in the adjacent bin, especially at 44.1 kHz. Fill
90
+ // the complete window, then measure three independent stable windows.
91
+ // This condition does not know the expected tone: a stable wrong
92
+ // fixture still returns its actual frequency and fails the assertion.
93
+ if (context.currentTime - audibleAt >= windowSeconds &&
94
+ context.currentTime - sampledAt >= windowSeconds) {
95
+ sampledAt = context.currentTime;
96
+ const bin = bins.indexOf(max);
97
+ stableWindows = bin === previousBin ? stableWindows + 1 : 1;
98
+ previousBin = bin;
99
+ if (stableWindows === 3) {
100
+ const binWidth = context.sampleRate / analyser.fftSize;
101
+ return { frequency: bin * binWidth, binWidth };
102
+ }
103
+ }
104
+ } else {
105
+ audibleAt = undefined;
106
+ stableWindows = 0;
107
+ }
74
108
  await new Promise(resolve => setTimeout(resolve, 20));
75
109
  }
76
- throw new Error("microphone produced no audible fixture");
110
+ throw new Error(`microphone produced no stable audible fixture: ${JSON.stringify({
111
+ state: context.state, time: context.currentTime, rate: context.sampleRate, max: Math.max(...bins),
112
+ previousBin, stableWindows,
113
+ tracks: (globalThis as any).__mic.stream.getTracks().map((t: MediaStreamTrack) => ({
114
+ state: t.readyState, muted: t.muted, enabled: t.enabled, settings: t.getSettings(),
115
+ })),
116
+ })}`);
77
117
  }));
78
118
  }
79
119
 
80
- describe.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("native Chromium microphone", () => {
120
+ async function expectFrequency(browser: MobileBackend, frequency: number): Promise<void> {
121
+ const actual = await peak(browser);
122
+ // FFT peaks are quantized to the native context's bins. At 48 kHz, the
123
+ // nearest 4096-point bin to 440 Hz is 445.3125 Hz; a fixed 5 Hz tolerance
124
+ // incorrectly rejects that valid signal. Half a bin preserves the actual
125
+ // measurement resolution and easily distinguishes the two fixture tones.
126
+ expect(Math.abs(actual.frequency - frequency)).toBeLessThanOrEqual(actual.binWidth / 2);
127
+ }
128
+
129
+ describe.skipIf(!Bun.which("chromium") && !Bun.which("chromium-browser") && !Bun.which("google-chrome") && !Bun.which("chrome"))("native Chromium microphone", () => {
81
130
  let server: ReturnType<typeof Bun.serve>;
82
131
  beforeAll(() => { server = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch: () => new Response("<html>microphone</html>", { headers: { "content-type": "text/html" } }) }); });
83
132
  afterAll(() => server.stop(true));
84
133
 
85
- test("captures the fixture through native tracks and MediaRecorder", async () => {
134
+ test.each([44100, 48000])("captures the fixture at %i Hz through native tracks and MediaRecorder", async sampleRate => {
86
135
  const browser = await openMobileBackend({ url: server.url.href, microphone: { path: join(dir, "440%name.wav"), loop: true } });
87
136
  try {
88
- await capture(browser);
89
- expect(await peak(browser)).toBeCloseTo(440, -1);
137
+ await capture(browser, sampleRate);
138
+ await expectFrequency(browser, 440);
90
139
  const result = await browser.silentRead(page => page.evaluate(async () => {
91
140
  const { stream } = (globalThis as any).__mic;
92
141
  const recorder = new MediaRecorder(stream);
@@ -112,12 +161,12 @@ describe.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-head
112
161
  const b = await acquirePersistentBrowser("audio-b", { url: server.url.href, microphone: { path: join(dir, "880.wav"), loop: true } });
113
162
  try {
114
163
  await Promise.all([capture(a.browser), capture(b.browser)]);
115
- expect(await peak(a.browser)).toBeCloseTo(440, -1);
116
- expect(await peak(b.browser)).toBeCloseTo(880, -1);
164
+ await expectFrequency(a.browser, 440);
165
+ await expectFrequency(b.browser, 880);
117
166
  await a.detach();
118
167
  const attached = await acquirePersistentBrowser("audio-a");
119
168
  expect(attached.attached).toBe(true);
120
- expect(await peak(attached.browser)).toBeCloseTo(440, -1);
169
+ await expectFrequency(attached.browser, 440);
121
170
  await attached.detach();
122
171
  } finally { await Promise.all([a.browser.close(), b.browser.close()]); }
123
172
  }, 20000);
@@ -126,7 +175,7 @@ describe.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-head
126
175
  const browser = await openMobileBackend({ url: server.url.href, microphone: { path: join(dir, "440%name.wav") } });
127
176
  try {
128
177
  await capture(browser);
129
- expect(await peak(browser)).toBeCloseTo(440, -1);
178
+ await expectFrequency(browser, 440);
130
179
  const result = await browser.silentRead(page => page.evaluate(async () => {
131
180
  const { stream, analyser } = (globalThis as any).__mic;
132
181
  const samples = new Float32Array(analyser.fftSize);
@@ -140,4 +189,46 @@ describe.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-head
140
189
  expect(result).toBe("live");
141
190
  } finally { await browser.close(); }
142
191
  }, 20000);
192
+
193
+ test.each(["browser", "mobile"] as const)("%s sessions capture native fixture audio and camera frames by default", async frame => {
194
+ const browser = await openMobileBackend({
195
+ frame, url: server.url.href,
196
+ microphone: { path: join(dir, "440%name.wav"), loop: true },
197
+ });
198
+ try {
199
+ await capture(browser);
200
+ await expectFrequency(browser, 440);
201
+ const image = await browser.silentRead(page => page.evaluate(async () => {
202
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true });
203
+ const video = document.createElement("video");
204
+ video.muted = true;
205
+ video.srcObject = stream;
206
+ document.body.append(video);
207
+ let deadline: ReturnType<typeof setTimeout> | undefined;
208
+ try {
209
+ await video.play();
210
+ await Promise.race([
211
+ new Promise<void>(resolve => video.requestVideoFrameCallback(() => resolve())),
212
+ new Promise<never>((_, reject) => { deadline = setTimeout(() => reject(new Error("native camera delivered no video frame")), 3000); }),
213
+ ]);
214
+ const canvas = document.createElement("canvas");
215
+ canvas.width = 64; canvas.height = 48;
216
+ const context = canvas.getContext("2d")!;
217
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
218
+ const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
219
+ const colors = new Set<string>();
220
+ for (let i = 0; i < pixels.length; i += 4) colors.add(`${pixels[i]},${pixels[i + 1]},${pixels[i + 2]}`);
221
+ return { width: video.videoWidth, height: video.videoHeight, colors: colors.size, state: stream.getVideoTracks()[0]!.readyState };
222
+ } finally {
223
+ clearTimeout(deadline);
224
+ stream.getTracks().forEach(track => track.stop());
225
+ video.remove();
226
+ }
227
+ }));
228
+ expect(image.width).toBeGreaterThan(0);
229
+ expect(image.height).toBeGreaterThan(0);
230
+ expect(image.colors).toBeGreaterThan(4);
231
+ expect(image.state).toBe("live");
232
+ } finally { await browser.close(); }
233
+ }, 20000);
143
234
  });
@@ -6,9 +6,9 @@ import { attachBrowserProbe, desktopStrategy, getLocatorProbe, makeLocator, type
6
6
  import { assertURL } from "./playwright-assertions.js";
7
7
  import { recordBrowser, startRecording, stopRecording } from "./recorder.js";
8
8
 
9
- // Opt in after `bunx playwright install chromium --only-shell`, or run
9
+ // Opt in after `bunx playwright install chromium`, or run
10
10
  // automatically when a system/cached full Chromium is available.
11
- const executablePath = Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? undefined;
11
+ const executablePath = Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome") ?? undefined;
12
12
  const available = !!process.env.SPECTEST_TEST_BROWSER_ASSERTIONS || !!executablePath || existsSync(chromium.executablePath());
13
13
 
14
14
  describe.skipIf(!available)("Spectest assertions backed by real Playwright/Chromium", () => {
@@ -200,7 +200,7 @@ describe.skipIf(!available)("Spectest assertions backed by real Playwright/Chrom
200
200
 
201
201
  // Exercise the production session branding/recording as well as the real
202
202
  // native engine above. browser.ts intentionally uses system Chromium.
203
- test.skipIf(!Bun.which("chromium-headless-shell") && !Bun.which("chrome-headless-shell"))("browser backend preserves assertion provenance and failure page capture", async () => {
203
+ test.skipIf(!(Bun.which("chromium") ?? Bun.which("chromium-browser") ?? Bun.which("google-chrome") ?? Bun.which("chrome")))("browser backend preserves assertion provenance and failure page capture", async () => {
204
204
  const { openBrowser } = await import("./browser.js");
205
205
  const replaySteps: unknown[] = [];
206
206
  const view = await openBrowser({
package/src/recorder.ts CHANGED
@@ -453,7 +453,7 @@ export interface EnvEvent extends BaseEvent {
453
453
  // method name verbatim. Both renderers (control-plane web + CLI) treat it as
454
454
  // an opaque string with a generic `{action} <target>` fallback, so new method
455
455
  // names need no Rust change. Representative values: "goto", "goBack",
456
- // "goForward", "reload", "evaluate", "waitForFunction",
456
+ // "goForward", "reload", "evaluate", "readClipboard", "waitForFunction",
457
457
  // "click", "dblclick", "tap", "fill", "clear", "press", "type", "scroll",
458
458
  // "check", "hover", "textContent", "inputValue", "count", "screenshot".
459
459
  export type BrowserAction = string;