@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.
- package/package.json +1 -1
- package/src/browser.ts +119 -2
package/package.json
CHANGED
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>
|
|
537
|
-
//
|
|
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
|
|