@specific.dev/spectest 0.131.20 → 0.131.21
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.js +4 -2
- package/dist/harness/shadow-builder.js +3 -1
- package/dist/vendor/rrweb-record.min.js +2 -17
- package/package.json +1 -1
- package/src/browser-coverage-navigation.test.ts +10 -2
- package/src/browser-image-loading.test.ts +51 -0
- package/src/browser.ts +4 -2
- package/src/harness/shadow-builder.test.ts +4 -1
- package/src/harness/shadow-builder.ts +3 -1
- package/src/vendor/rrweb-record.min.js +2 -17
package/dist/browser.js
CHANGED
|
@@ -289,7 +289,8 @@ async function applySafeAreaInsets(cdp, d) {
|
|
|
289
289
|
// ────────────────────────────────────────────────────────────────────────
|
|
290
290
|
// Vendored `@rrweb/record` UMD bundle (rrweb 2.x), BUILT FROM OUR FORK by
|
|
291
291
|
// `scripts/rrweb-record/build.sh` (specific-dev/rrweb, branch `spectest` =
|
|
292
|
-
// the `rrweb@2.1.0` tag + upstream PR 1802
|
|
292
|
+
// the `rrweb@2.1.0` tag + upstream PR 1802 + non-mutating image capture).
|
|
293
|
+
// Never copy the npm dist over
|
|
293
294
|
// it: upstream's `@rrweb/utils` lists `querySelector` as an accessor, so its
|
|
294
295
|
// pristine-prototype test always fails and `record()` appends an <iframe>
|
|
295
296
|
// to every document — measured at ~1 s per navigation (see the top-frame
|
|
@@ -380,7 +381,8 @@ const RRWEB_BOOTSTRAP = `
|
|
|
380
381
|
// CSS (what next/font emits), so it's a no-op for most apps; the
|
|
381
382
|
// url()-based fonts are handled by the inliner below instead. All
|
|
382
383
|
// degrade gracefully — an asset that can't be read is skipped,
|
|
383
|
-
// never fatal to recording.
|
|
384
|
+
// never fatal to recording. Our fork never changes a live image's
|
|
385
|
+
// crossorigin attribute to retry a failed canvas read.
|
|
384
386
|
inlineStylesheet: true,
|
|
385
387
|
collectFonts: true,
|
|
386
388
|
inlineImages: true,
|
|
@@ -19,8 +19,10 @@ export function shadowImage(c, service) {
|
|
|
19
19
|
const id = createHash("sha256").update(service).digest("hex").slice(0, 32);
|
|
20
20
|
return `${c.registry}/shadow/${c.experiment}/${id}:result`;
|
|
21
21
|
}
|
|
22
|
+
// Convert inherited gzip layers too; the persistent builder caches the conversion.
|
|
23
|
+
// Level 1 favors export latency over a small additional size reduction.
|
|
22
24
|
export function shadowOutput(reference) {
|
|
23
|
-
return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd`;
|
|
25
|
+
return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd,compression-level=1,force-compression=true`;
|
|
24
26
|
}
|
|
25
27
|
export function imageDigest(metadata) {
|
|
26
28
|
const digest = metadata?.["containerimage.digest"];
|
|
@@ -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 (
|
|
1179
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { readRaw } from "./inspect.js";
|
|
3
|
+
import { openBrowser } from "./browser.js";
|
|
4
|
+
|
|
5
|
+
const available = !!(Bun.which("chromium-headless-shell") ?? Bun.which("chrome-headless-shell"));
|
|
6
|
+
const png = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=", "base64");
|
|
7
|
+
|
|
8
|
+
for (const late of [false, true]) {
|
|
9
|
+
test.skipIf(!available)(`SDK recording preserves cookie-protected cross-origin images (${late ? "late" : "initial"})`, async () => {
|
|
10
|
+
const imageRequests: string[] = [];
|
|
11
|
+
const images = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch(request) {
|
|
12
|
+
// The SDK also fetches assets for replay. Count actual <img> requests
|
|
13
|
+
// separately so the regression catches recorder-induced image reloads.
|
|
14
|
+
if (request.headers.get("sec-fetch-dest") === "image") imageRequests.push(request.headers.get("cookie") ?? "");
|
|
15
|
+
if (!request.headers.get("cookie")?.includes("image_session=allowed")) return new Response("", { status: 401 });
|
|
16
|
+
return new Response(png, { headers: { "content-type": "image/png", "cache-control": "no-store" } });
|
|
17
|
+
} });
|
|
18
|
+
const page = Bun.serve({ port: 0, hostname: "127.0.0.1", fetch() {
|
|
19
|
+
return new Response(`<!doctype html><body><script>
|
|
20
|
+
document.cookie = 'image_session=allowed; path=/';
|
|
21
|
+
window.loads = 0; window.errors = 0; window.changes = [];
|
|
22
|
+
window.addImage = () => {
|
|
23
|
+
const img = document.createElement('img'); img.id = 'subject';
|
|
24
|
+
new MutationObserver(records => window.changes.push(...records.map(r => r.attributeName)))
|
|
25
|
+
.observe(img, {attributes:true, attributeFilter:['crossorigin']});
|
|
26
|
+
img.onload = () => window.loads++; img.onerror = () => window.errors++;
|
|
27
|
+
img.src = ${JSON.stringify(images.url.href)}; document.body.append(img);
|
|
28
|
+
};
|
|
29
|
+
${late ? "" : "window.addImage();"}
|
|
30
|
+
</script>`, { headers: { "content-type": "text/html" } });
|
|
31
|
+
} });
|
|
32
|
+
const browser = await openBrowser({ recorder: { sessionId: `image-${late}`, recordStep() {} } });
|
|
33
|
+
try {
|
|
34
|
+
await browser.goto(page.url.href);
|
|
35
|
+
if (late) await browser.evaluate("Insert image after recording starts", "window.addImage()");
|
|
36
|
+
// Wait through load and recorder callbacks, including asynchronous retries.
|
|
37
|
+
await browser.evaluate("Allow image capture to finish", "new Promise(resolve => setTimeout(resolve, 500))");
|
|
38
|
+
const state = await browser.evaluate("Inspect live image", `({
|
|
39
|
+
attr: document.querySelector('#subject').getAttribute('crossorigin'),
|
|
40
|
+
width: document.querySelector('#subject').naturalWidth,
|
|
41
|
+
loads: window.loads, errors: window.errors, changes: window.changes,
|
|
42
|
+
recording: !!window.__spectestRrwebInit
|
|
43
|
+
})`);
|
|
44
|
+
expect(readRaw(state)).toEqual({ attr: null, width: 1, loads: 1, errors: 0, changes: [], recording: true });
|
|
45
|
+
expect(imageRequests).toEqual(["image_session=allowed"]);
|
|
46
|
+
} finally {
|
|
47
|
+
await browser.close();
|
|
48
|
+
page.stop(true); images.stop(true);
|
|
49
|
+
}
|
|
50
|
+
}, 15000);
|
|
51
|
+
}
|
package/src/browser.ts
CHANGED
|
@@ -672,7 +672,8 @@ async function applySafeAreaInsets(
|
|
|
672
672
|
|
|
673
673
|
// Vendored `@rrweb/record` UMD bundle (rrweb 2.x), BUILT FROM OUR FORK by
|
|
674
674
|
// `scripts/rrweb-record/build.sh` (specific-dev/rrweb, branch `spectest` =
|
|
675
|
-
// the `rrweb@2.1.0` tag + upstream PR 1802
|
|
675
|
+
// the `rrweb@2.1.0` tag + upstream PR 1802 + non-mutating image capture).
|
|
676
|
+
// Never copy the npm dist over
|
|
676
677
|
// it: upstream's `@rrweb/utils` lists `querySelector` as an accessor, so its
|
|
677
678
|
// pristine-prototype test always fails and `record()` appends an <iframe>
|
|
678
679
|
// to every document — measured at ~1 s per navigation (see the top-frame
|
|
@@ -766,7 +767,8 @@ const RRWEB_BOOTSTRAP = `
|
|
|
766
767
|
// CSS (what next/font emits), so it's a no-op for most apps; the
|
|
767
768
|
// url()-based fonts are handled by the inliner below instead. All
|
|
768
769
|
// degrade gracefully — an asset that can't be read is skipped,
|
|
769
|
-
// never fatal to recording.
|
|
770
|
+
// never fatal to recording. Our fork never changes a live image's
|
|
771
|
+
// crossorigin attribute to retry a failed canvas read.
|
|
770
772
|
inlineStylesheet: true,
|
|
771
773
|
collectFonts: true,
|
|
772
774
|
inlineImages: true,
|
|
@@ -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)
|
|
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);
|
|
@@ -24,8 +24,10 @@ export function shadowImage(c: ShadowBuilder, service: string): string {
|
|
|
24
24
|
const id = createHash("sha256").update(service).digest("hex").slice(0, 32);
|
|
25
25
|
return `${c.registry}/shadow/${c.experiment}/${id}:result`;
|
|
26
26
|
}
|
|
27
|
+
// Convert inherited gzip layers too; the persistent builder caches the conversion.
|
|
28
|
+
// Level 1 favors export latency over a small additional size reduction.
|
|
27
29
|
export function shadowOutput(reference: string): string {
|
|
28
|
-
return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd`;
|
|
30
|
+
return `type=image,name=${reference},push=true,oci-mediatypes=true,compression=zstd,compression-level=1,force-compression=true`;
|
|
29
31
|
}
|
|
30
32
|
export function imageDigest(metadata: unknown): string {
|
|
31
33
|
const digest = (metadata as Record<string, unknown>)?.["containerimage.digest"];
|
|
@@ -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 (
|
|
1179
|
-
|
|
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();
|