@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.
- package/dist/browser-coverage.d.ts +3 -0
- package/dist/browser-coverage.js +84 -31
- package/dist/browser.d.ts +4 -0
- package/dist/browser.js +80 -22
- package/dist/change-rules.d.ts +45 -0
- package/dist/change-rules.js +179 -0
- package/dist/components/k3s-coverage.js +4 -4
- package/dist/components/supabase-coverage.js +12 -0
- package/dist/components/supabase.js +25 -12
- package/dist/coverage-bun.js +2 -1
- package/dist/coverage-next.d.ts +5 -0
- package/dist/coverage-next.js +27 -3
- package/dist/coverage-rust.d.ts +2 -0
- package/dist/coverage-rust.js +20 -0
- package/dist/coverage.d.ts +27 -3
- package/dist/coverage.js +68 -15
- package/dist/daemon.js +13 -10
- package/dist/harness/component-images.d.ts +5 -0
- package/dist/harness/component-images.js +15 -2
- package/dist/harness/coverage-target.d.ts +2 -0
- package/dist/harness/coverage-target.js +3 -2
- package/dist/harness/reach.d.ts +6 -1
- package/dist/harness/reach.js +8 -34
- package/dist/harness/shadow-builder.d.ts +2 -1
- package/dist/harness/shadow-builder.js +6 -2
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/package.json +1 -1
- package/src/browser-clipboard.test.ts +177 -0
- package/src/browser-coverage-deadline.test.ts +2 -0
- package/src/browser-coverage-identity.test.ts +114 -2
- package/src/browser-coverage-navigation.test.ts +1 -2
- package/src/browser-coverage-sdk.test.ts +91 -0
- package/src/browser-coverage.ts +95 -30
- package/src/browser-executable.test.ts +7 -5
- package/src/browser-finalization.test.ts +1 -1
- package/src/browser-image-loading.test.ts +1 -1
- package/src/browser-navigation.test.ts +1 -1
- package/src/browser-permissions.test.ts +143 -0
- package/src/browser-replay-transport.test.ts +60 -0
- package/src/browser-screenshot.test.ts +1 -2
- package/src/browser-timezone.test.ts +1 -1
- package/src/browser.ts +79 -20
- package/src/change-rules.fixtures.json +35 -0
- package/src/change-rules.test.ts +125 -0
- package/src/change-rules.ts +147 -0
- package/src/components/k3s-coverage.test.ts +32 -0
- package/src/components/k3s-coverage.ts +4 -4
- package/src/components/supabase-coverage.ts +12 -0
- package/src/components/supabase-dir.test.ts +26 -0
- package/src/components/supabase-functions-reach.test.ts +41 -1
- package/src/components/supabase-migrations.test.ts +1 -1
- package/src/components/supabase.ts +26 -12
- package/src/coverage-bun.ts +2 -1
- package/src/coverage-next.ts +29 -3
- package/src/coverage-rust.test.ts +34 -0
- package/src/coverage-rust.ts +22 -0
- package/src/coverage.test.ts +2 -1
- package/src/coverage.ts +76 -7
- package/src/daemon.ts +14 -9
- package/src/harness/component-images.test.ts +42 -0
- package/src/harness/component-images.ts +20 -3
- package/src/harness/coverage-target.ts +5 -2
- package/src/harness/reach.test.ts +40 -6
- package/src/harness/reach.ts +13 -3
- package/src/harness/shadow-builder.test.ts +8 -0
- package/src/harness/shadow-builder.ts +7 -2
- package/src/index.ts +11 -0
- package/src/microphone-fixture.test.ts +106 -15
- package/src/playwright-assertions.test.ts +3 -3
- 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:
|
|
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:
|
|
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:
|
|
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:
|
|
145
|
-
expect(reachContains({ version:
|
|
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
|
+
});
|
package/src/harness/reach.ts
CHANGED
|
@@ -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:
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
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;
|