@specific.dev/spectest 0.19.0 → 0.19.1

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/package.json +1 -1
  2. package/src/browser.ts +119 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
package/src/browser.ts CHANGED
@@ -533,8 +533,12 @@ const RRWEB_BOOTSTRAP = `
533
533
  // faithfully offline (the dashboard reconstructs the DOM with no
534
534
  // access to the env's origin). \`inlineStylesheet\` (default, set
535
535
  // explicitly) bakes <style>/<link> CSS into the snapshot;
536
- // \`inlineImages\` turns <img> and CSS background images into data
537
- // URIs. \`collectFonts\` only captures fonts added via the JS
536
+ // \`inlineImages\` turns <img> elements into data URIs — but ONLY
537
+ // synchronously, for images already decoded at serialize time
538
+ // (its late-load path patches the emitted event in place, which
539
+ // loses the race against our per-op drains; the image inliner
540
+ // below covers that case with synthetic mutation events).
541
+ // \`collectFonts\` only captures fonts added via the JS
538
542
  // FontFace API — it does NOT inline static \`@font-face { src:url() }\`
539
543
  // CSS (what next/font emits), so it's a no-op for most apps; the
540
544
  // url()-based fonts are handled by the inliner below instead. All
@@ -685,6 +689,119 @@ const RRWEB_BOOTSTRAP = `
685
689
  }
686
690
  } catch (e) { log("init FAIL " + (e && e.message)); }
687
691
  })();
692
+ // ── Inline late-loading <img>s as synthetic src mutations ──────────
693
+ // rrweb's inlineImages only bakes a data: URI in synchronously when
694
+ // the image is already decoded at serialize time. An image that
695
+ // finishes loading AFTER its node was serialized is patched by rrweb
696
+ // IN PLACE on the already-emitted event object — lost whenever a
697
+ // drain (op boundary) ships the buffer first. Classic case: a tap
698
+ // reveals a screen full of first-seen images; the step's drain fires
699
+ // right after the tap, the images decode a beat later, and the replay
700
+ // is left with raw in-VM URLs it can't fetch (hermetic env).
701
+ //
702
+ // Fix: listen for image loads ourselves (capture phase on document —
703
+ // runs BEFORE rrweb's target-phase listener) and, once the element
704
+ // has an id in rrweb's mirror, push a synthetic incremental mutation
705
+ // rewriting the img's src to a data: URI. A mutation event rides
706
+ // whatever drain comes next, so it survives the race even when the
707
+ // add event is long gone. Bytes come from a same-origin fetch of the
708
+ // ORIGINAL file (compact and lossless — rrweb's canvas path
709
+ // re-encodes JPEGs as much larger PNGs). It must be a plain \`src\`
710
+ // rewrite: the replayer honors \`rr_dataURL\` only when BUILDING a
711
+ // node; in the attribute-mutation path it just sets the literal
712
+ // attribute (only canvas is special-cased there).
713
+ (function () {
714
+ if (window.__spectestImgInliner) return;
715
+ window.__spectestImgInliner = true;
716
+ function log(m) { try { console.log("[spectest-img] " + m); } catch (e) {} }
717
+ var cache = {};
718
+ function fetchDataUri(url) {
719
+ if (cache[url]) return cache[url];
720
+ cache[url] = fetch(url).then(function (r) {
721
+ if (!r.ok) throw new Error("HTTP " + r.status);
722
+ return r.blob();
723
+ }).then(function (blob) {
724
+ return new Promise(function (resolve, reject) {
725
+ var fr = new FileReader();
726
+ fr.onload = function () { resolve(fr.result); };
727
+ fr.onerror = reject;
728
+ fr.readAsDataURL(blob);
729
+ });
730
+ });
731
+ return cache[url];
732
+ }
733
+ // The serialized-node meta when rrweb already inlined THIS src on
734
+ // it (rr_dataURL present and the recorded src matches — a stale
735
+ // bake from a previous src must not count).
736
+ function bakedMeta(mirror, img, src) {
737
+ try {
738
+ var meta = mirror.getMeta(img);
739
+ if (meta && meta.attributes && meta.attributes.rr_dataURL &&
740
+ meta.attributes.src === src) return meta;
741
+ } catch (e) {}
742
+ return null;
743
+ }
744
+ function onImgLoad(img, src) {
745
+ var mirror = rec && rec.mirror;
746
+ if (!mirror || typeof mirror.getId !== "function" ||
747
+ typeof mirror.getMeta !== "function") return;
748
+ // A patch visible NOW — before rrweb's own load listener has run
749
+ // (we're capture phase, it's target phase) — can only be the
750
+ // synchronous serialize-time bake, which shipped inside the event
751
+ // itself. Nothing to do.
752
+ var preId = mirror.getId(img);
753
+ if (preId > 0 && bakedMeta(mirror, img, src)) return;
754
+ var tries = 0;
755
+ function finish() {
756
+ var id = mirror.getId(img);
757
+ if (!(id > 0)) {
758
+ // Not serialized yet (mutation batch pending, or the initial
759
+ // full snapshot hasn't run — rrweb defers it to window load).
760
+ tries++;
761
+ if (tries < 40) setTimeout(finish, 50);
762
+ else log("drop (never serialized) " + src);
763
+ return;
764
+ }
765
+ var meta = bakedMeta(mirror, img, src);
766
+ if (meta && preId < 1) return; // serialized after load → sync bake
767
+ if (meta) {
768
+ // rrweb's async in-place patch landed after our capture-phase
769
+ // check. If the add is still in the buffer it now carries a
770
+ // PNG re-encode; strip it and ship ours instead (original
771
+ // bytes, and immune to a drain having already taken the add).
772
+ try { delete meta.attributes.rr_dataURL; } catch (e) {}
773
+ }
774
+ fetchDataUri(src).then(function (dataUri) {
775
+ var attrs = { src: dataUri };
776
+ // A srcset would out-rank the patched src in the replay
777
+ // iframe and point back at the unreachable original.
778
+ if (img.getAttribute && img.getAttribute("srcset")) attrs.srcset = "";
779
+ window.__spectestRrwebEvents.push({
780
+ type: 3,
781
+ data: { source: 0, texts: [], attributes: [{ id: id, attributes: attrs }], removes: [], adds: [] },
782
+ timestamp: Date.now(),
783
+ });
784
+ log("inlined id=" + id + " bytes=" + dataUri.length + " " + src);
785
+ }).catch(function (e) {
786
+ log("fetch FAIL " + src + " : " + (e && e.message));
787
+ });
788
+ }
789
+ // Next task, so rrweb's own load listener has run and its patch
790
+ // (if any) is observable above.
791
+ setTimeout(finish, 0);
792
+ }
793
+ try {
794
+ document.addEventListener("load", function (ev) {
795
+ var el = ev.target;
796
+ if (!el || !el.tagName || String(el.tagName).toLowerCase() !== "img") return;
797
+ var src = el.currentSrc || el.src || "";
798
+ if (!src || src.lastIndexOf("http", 0) !== 0) return; // data:/blob: already replayable
799
+ if (el.__spectestImgSrc === src) return; // this src already handled
800
+ el.__spectestImgSrc = src;
801
+ onImgLoad(el, src);
802
+ }, true);
803
+ } catch (e) { log("init FAIL " + (e && e.message)); }
804
+ })();
688
805
  })();
689
806
  `;
690
807