@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 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). Never copy the npm dist over
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 (err) {
1179
- if (image.crossOrigin !== "anonymous") {
1180
- image.crossOrigin = "anonymous";
1181
- if (image.complete && image.naturalWidth !== 0)
1182
- recordInlineImage();
1183
- else image.addEventListener("load", recordInlineImage);
1184
- return;
1185
- } else {
1186
- console.warn(
1187
- `Cannot inline img src=${imageSrc}! Error: ${err}`
1188
- );
1189
- }
1190
- }
1191
- if (image.crossOrigin === "anonymous") {
1192
- priorCrossOrigin ? attributes.crossOrigin = priorCrossOrigin : image.removeAttribute("crossorigin");
1176
+ } catch (e) {
1177
+ return;
1193
1178
  }
1194
1179
  };
1195
1180
  if (image.complete && image.naturalWidth !== 0) recordInlineImage();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.131.20",
3
+ "version": "0.131.21",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,10 +14,13 @@ test.skipIf(!existsSync(executablePath))("cross-site script ID reuse cannot attr
14
14
  const map = Buffer.from(JSON.stringify({ version: 3, sources: ["src/customer.ts"], names: [], mappings: "AAAA" })).toString("base64");
15
15
  const server = Bun.serve({ hostname: "0.0.0.0", port: 0, fetch(request) {
16
16
  const url = new URL(request.url);
17
+ if (url.pathname === "/padding.js") return new Response("void 0;", { headers: { "content-type": "application/javascript" } });
17
18
  if (url.pathname === emptyScript) return new Response("", { headers: { "content-type": "application/javascript" } });
18
19
  if (url.pathname === "/app.js") return new Response(`globalThis.appRan = true;\n//# sourceMappingURL=data:application/json;base64,${map}`, { headers: { "content-type": "application/javascript" } });
19
20
  return new Response(url.hostname === "127.0.0.1"
20
- ? `<script src="${emptyScript}"></script><script src="/app.js"></script><p>Application</p>`
21
+ // Advance the application's script IDs past browser-injected scripts.
22
+ // Padding is served outside the covered service and has no source map.
23
+ ? `${Array.from({ length: 32 }, (_, i) => `<script src="http://127.0.0.2:${url.port}/padding.js?${i}"></script>`).join("")}<script src="${emptyScript}"></script><script src="/app.js"></script><p>Application</p>`
21
24
  : "<p>External login</p>", { headers: { "content-type": "text/html", "cache-control": "no-store" } });
22
25
  } });
23
26
  const browser = await chromium.launch({ executablePath, headless: true, args: ["--no-sandbox", "--site-per-process"] });
@@ -36,7 +39,12 @@ test.skipIf(!existsSync(executablePath))("cross-site script ID reuse cannot attr
36
39
  // Keep collected application counts across navigation until service capture.
37
40
  for (const host of ["127.0.0.1", "127.0.0.2", "127.0.0.1", "127.0.0.2"]) {
38
41
  await page.goto(`http://${host}:${server.port}/`);
39
- await page.evaluate("(() => { const value = 1; return value + 1; })()");
42
+ // V8's first evaluation ID varies across browser versions and runners.
43
+ // Generate distinct anonymous scripts until the old named ID is reused.
44
+ for (let i = 0; i < 128; i++) {
45
+ await cdp.send("Runtime.evaluate", { expression: `(() => { const value = ${i}; return value + 1; })()` });
46
+ if (host === "127.0.0.1" || anonymousReuse) break;
47
+ }
40
48
  await harvestBrowserCoverage(cdp);
41
49
  }
42
50
  expect(anonymousReuse).toBe(true); // Prove the regression's trigger happened.
@@ -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). Never copy the npm dist over
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)).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);
@@ -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 (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();