@specific.dev/spectest 0.36.0 → 0.37.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 (2) hide show
  1. package/dist/browser.js +81 -15
  2. package/package.json +1 -1
package/dist/browser.js CHANGED
@@ -61,11 +61,37 @@ function base64ByteLength(b64) {
61
61
  // surfaces as an intermittent `net::ERR_NAME_NOT_RESOLVED`. getaddrinfo
62
62
  // reads resolv.conf synchronously per lookup, so it has no startup race
63
63
  // and always reaches the resolver. See run 878d0054 (dashboard:3000).
64
+ //
65
+ // The two media flags below make `getUserMedia` work at all. The microVM
66
+ // has no camera and no sound card, so without them a page that asks for one
67
+ // fails on `NotFoundError: Requested device not found` — an app that gates
68
+ // its UI on microphone access can't be driven past that point.
69
+ //
70
+ // What the app gets is Chromium's own synthetic devices: the microphone is a
71
+ // generated beep pattern (a live, real MediaStreamTrack — MediaRecorder,
72
+ // WebRTC and AudioContext analysis all work on it), the camera a rolling
73
+ // test pattern at 640x480. There is deliberately NO way to feed a recording
74
+ // in; this is device availability, not audio fixtures.
75
+ //
76
+ // --use-fake-device-for-media-stream: the synthetic camera + microphone.
77
+ // Also what makes `enumerateDevices()` return anything at all.
78
+ // --use-fake-ui-for-media-stream: auto-accept the permission prompt.
79
+ // Headless has no UI to accept it with, so without this `getUserMedia`
80
+ // fails on `NotAllowedError` even when the fake devices are present.
81
+ //
82
+ // `--autoplay-policy=no-user-gesture-required` is deliberately NOT here.
83
+ // WebAudio over the captured stream works without it: holding a live
84
+ // getUserMedia stream is itself one of the conditions under which Chromium
85
+ // lets an AudioContext start, so an app's level meter or VAD reads the fake
86
+ // device fine (measured). Adding it would also un-block ordinary media
87
+ // autoplay, which is a behaviour change in the app under test, not in us.
64
88
  const CHROME_ARGV = [
65
89
  "--no-sandbox",
66
90
  "--disable-dev-shm-usage",
67
91
  "--disable-features=AsyncDns,DnsOverHttps",
68
92
  "--dns-over-https-mode=off",
93
+ "--use-fake-device-for-media-stream",
94
+ "--use-fake-ui-for-media-stream",
69
95
  ];
70
96
  /** Default touchStart→touchEnd dwell for touch taps — see `rawTap`. */
71
97
  const TAP_DWELL_MS = 60;
@@ -305,15 +331,29 @@ const RRWEB_BOOTSTRAP = `
305
331
  // src points at a same-origin URL (e.g. next/font's
306
332
  // /_next/static/media/*.woff2) is unreachable at replay time and the
307
333
  // page silently falls back to a system font. collectFonts above won't
308
- // help (FontFace-API fonts only), so inline the bytes ourselves: once
309
- // fonts have loaded, fetch each url() (same-origin -> allowed, and the
310
- // page already trusts the in-VM CA), rewrite the rule's src to a data:
334
+ // help (FontFace-API fonts only), so inline the bytes ourselves: fetch
335
+ // each url() (same-origin -> allowed, and the page already trusts the
336
+ // in-VM CA), rewrite the rule's src to a data:
311
337
  // URI, then take a fresh full snapshot so the now-self-contained CSS
312
338
  // is captured. Plain string ops, no regex — this whole script ships
313
339
  // inside a TS template literal where regex backslashes get mangled.
340
+ //
341
+ // Do NOT start this scan from document.fonts.ready. This script runs at
342
+ // document-start. At that moment the font set is idle, so Chrome settles
343
+ // that first ready promise immediately, before the parser has registered
344
+ // one stylesheet. The scan then saw 0 @font-face rules, and a one-shot
345
+ // latch stopped it from looking again, so no font was ever inlined.
346
+ // (Found on run_03j0mxtvp6x0p16d9y83e: every next/font face fell back.
347
+ // The diagnostics were invisible too, because rrweb's console plugin
348
+ // only patches console at the load event.)
349
+ //
350
+ // Two triggers instead: the load event, by which all render-blocking CSS
351
+ // is parsed, and each \`loadingdone\` on document.fonts, which catches CSS
352
+ // added later by an HMR update or a client-side route change. The scan
353
+ // skips a rule it already inlined, so repeated triggers are cheap.
314
354
  (function () {
315
- if (window.__spectestFontsInlined) return;
316
- window.__spectestFontsInlined = true;
355
+ if (window.__spectestFontInliner) return;
356
+ window.__spectestFontInliner = true;
317
357
  // Diagnostics ride the console-record plugin, so they land in the
318
358
  // recording (and state.db) — our only debugging window into the
319
359
  // headless page. Prefix is grep-able; keep them terse.
@@ -370,7 +410,12 @@ const RRWEB_BOOTSTRAP = `
370
410
  }
371
411
  }
372
412
  }
373
- function run() {
413
+ // Every @font-face cssText this run already rewrote. A rule stays out
414
+ // of later scans, so an HMR update that adds one stylesheet costs one
415
+ // fetch, not a re-inline of the whole page.
416
+ var inlined = {};
417
+ var scans = 0, busy = false, rescan = false;
418
+ function collect() {
374
419
  // Collect each @font-face's *cssText* (reliable for any CSSRule —
375
420
  // unlike .style.setProperty('src',…), which silently no-ops on
376
421
  // CSSFontFaceRule in Chrome) plus the http url()s inside it.
@@ -384,13 +429,26 @@ const RRWEB_BOOTSTRAP = `
384
429
  eachFontFace(rules, function (rule) {
385
430
  faceCount++;
386
431
  var cssText = rule.cssText || "";
432
+ if (inlined[cssText]) return;
387
433
  var urls = urlsIn(cssText, base);
388
434
  if (urls.length) faces.push({ cssText: cssText, urls: urls });
389
435
  });
390
436
  }
391
- log("sheets=" + sheets.length + " readable=" + readable +
392
- " fontFaces=" + faceCount + " withUrl=" + faces.length);
437
+ // Log the first scan (so a page with no web fonts still says so) and
438
+ // every scan with work to do. A quiet re-trigger stays silent.
439
+ if (!scans++ || faces.length) {
440
+ log("sheets=" + sheets.length + " readable=" + readable +
441
+ " fontFaces=" + faceCount + " new=" + faces.length);
442
+ }
443
+ return faces;
444
+ }
445
+ function run() {
446
+ // One pass at a time. A trigger during a pass queues exactly one
447
+ // more, which then sees the marks the pass just wrote.
448
+ if (busy) { rescan = true; return; }
449
+ var faces = collect();
393
450
  if (!faces.length) return;
451
+ busy = true;
394
452
  var jobs = faces.map(function (face) {
395
453
  return Promise.all(face.urls.map(function (u) {
396
454
  return fetchDataUri(u.abs)
@@ -403,7 +461,9 @@ const RRWEB_BOOTSTRAP = `
403
461
  text = text.split(pairs[k].token).join(pairs[k].dataUri);
404
462
  changed = true;
405
463
  }
406
- return changed ? text : null;
464
+ if (!changed) return null;
465
+ inlined[face.cssText] = true;
466
+ return text;
407
467
  });
408
468
  });
409
469
  Promise.all(jobs).then(function (texts) {
@@ -426,15 +486,21 @@ const RRWEB_BOOTSTRAP = `
426
486
  log("took full snapshot");
427
487
  }
428
488
  } catch (e) { log("snapshot FAIL " + (e && e.message)); }
489
+ }).catch(function (e) {
490
+ log("run FAIL " + (e && e.message));
491
+ }).then(function () {
492
+ busy = false;
493
+ if (rescan) { rescan = false; setTimeout(run, 0); }
429
494
  });
430
495
  }
496
+ function schedule() { setTimeout(run, 0); }
431
497
  try {
432
- if (document.fonts && document.fonts.ready && document.fonts.ready.then) {
433
- document.fonts.ready.then(function () { setTimeout(run, 0); });
434
- } else if (document.readyState === "complete") {
435
- run();
436
- } else {
437
- window.addEventListener("load", run);
498
+ if (document.readyState === "complete") schedule();
499
+ else window.addEventListener("load", schedule);
500
+ // Fires each time a batch of fonts finishes loading, including fonts
501
+ // pulled in by CSS that arrives after the load event.
502
+ if (document.fonts && document.fonts.addEventListener) {
503
+ document.fonts.addEventListener("loadingdone", schedule);
438
504
  }
439
505
  } catch (e) { log("init FAIL " + (e && e.message)); }
440
506
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.36.0",
3
+ "version": "0.37.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",