@specific.dev/spectest 0.18.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/src/mobile.ts +44 -12
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
|
|
package/src/mobile.ts
CHANGED
|
@@ -68,11 +68,17 @@ interface LocatorDesc {
|
|
|
68
68
|
regex?: { source: string; flags: string };
|
|
69
69
|
name?: string;
|
|
70
70
|
nameExact?: boolean;
|
|
71
|
+
/** Narrowing applied after resolution (`.first()`/`.last()`/`.nth(i)`) —
|
|
72
|
+
* the explicit escape hatch from strict mode. */
|
|
73
|
+
nth?: number | "first" | "last";
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
/** A lazy reference to a single element, resolved (with auto-wait) at the
|
|
74
77
|
* moment an action runs. Mirrors the select-then-act idiom shared by
|
|
75
|
-
* Playwright, Detox, and RN Testing Library
|
|
78
|
+
* Playwright, Detox, and RN Testing Library — including Playwright's
|
|
79
|
+
* STRICT mode: a locator matching multiple elements throws an
|
|
80
|
+
* element-listing error rather than acting on one of them. Narrow with
|
|
81
|
+
* `exact: true`, a testID, or {@link first}/{@link nth}. */
|
|
76
82
|
export interface MobileLocator {
|
|
77
83
|
/** Wait for the element to be visible (default 5s, `timeoutMs` overrides),
|
|
78
84
|
* then touch-tap its center. `durationMs` overrides the touch dwell. */
|
|
@@ -94,13 +100,23 @@ export interface MobileLocator {
|
|
|
94
100
|
/** The element's text content, wrapped. Waits (default 5s) for the
|
|
95
101
|
* element to become visible; throws if it never does. */
|
|
96
102
|
textContent(): Promise<Wrapped<string | null>>;
|
|
103
|
+
/** Narrow to the first resolved element — the explicit opt-out from
|
|
104
|
+
* strict mode when multiple matches are intentional. */
|
|
105
|
+
first(): MobileLocator;
|
|
106
|
+
/** Narrow to the last resolved element. */
|
|
107
|
+
last(): MobileLocator;
|
|
108
|
+
/** Narrow to the i-th resolved element (0-based). */
|
|
109
|
+
nth(index: number): MobileLocator;
|
|
97
110
|
}
|
|
98
111
|
|
|
99
|
-
/** Map a locator descriptor onto a playwright Locator
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
112
|
+
/** Map a locator descriptor onto a playwright Locator — STRICT, exactly
|
|
113
|
+
* like stock Playwright: a locator that resolves to multiple elements
|
|
114
|
+
* throws an element-listing "strict mode violation" at action time
|
|
115
|
+
* instead of silently picking one. (An earlier `.filter({visible})
|
|
116
|
+
* .first()` auto-pick deterministically tapped the WRONG element when
|
|
117
|
+
* case-insensitive substring matching made two leaves qualify —
|
|
118
|
+
* `getByText("Men")` also matches "Wo**men**". Loud beats lucky.)
|
|
119
|
+
* Disambiguate with `exact: true`, a testID, or `.first()`/`.nth(i)`. */
|
|
104
120
|
function pwLocator(page: Page, desc: LocatorDesc): Locator {
|
|
105
121
|
let base: Locator;
|
|
106
122
|
switch (desc.kind) {
|
|
@@ -126,18 +142,25 @@ function pwLocator(page: Page, desc: LocatorDesc): Locator {
|
|
|
126
142
|
);
|
|
127
143
|
break;
|
|
128
144
|
}
|
|
129
|
-
|
|
145
|
+
if (desc.nth === "first") return base.first();
|
|
146
|
+
if (desc.nth === "last") return base.last();
|
|
147
|
+
if (typeof desc.nth === "number") return base.nth(desc.nth);
|
|
148
|
+
return base;
|
|
130
149
|
}
|
|
131
150
|
|
|
132
151
|
/** Short human label for a descriptor, used in event descriptions. */
|
|
133
152
|
function descLabel(desc: LocatorDesc): string {
|
|
153
|
+
let base: string;
|
|
134
154
|
if (desc.kind === "text") {
|
|
135
|
-
|
|
155
|
+
base = desc.regex ? `text /${desc.regex.source}/` : `text ${JSON.stringify(desc.value)}`;
|
|
156
|
+
} else if (desc.kind === "role") {
|
|
157
|
+
base = desc.name ? `role ${desc.value} ${JSON.stringify(desc.name)}` : `role ${desc.value}`;
|
|
158
|
+
} else {
|
|
159
|
+
base = `${desc.kind} ${JSON.stringify(desc.value)}`;
|
|
136
160
|
}
|
|
137
|
-
if (desc.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
return `${desc.kind} ${JSON.stringify(desc.value)}`;
|
|
161
|
+
if (desc.nth === "first" || desc.nth === "last") return `${base} .${desc.nth}()`;
|
|
162
|
+
if (typeof desc.nth === "number") return `${base} .nth(${desc.nth})`;
|
|
163
|
+
return base;
|
|
141
164
|
}
|
|
142
165
|
|
|
143
166
|
/** Default wait for a locator action's target to become visible. Override
|
|
@@ -223,6 +246,15 @@ function makeLocator(backend: MobileBackend, desc: LocatorDesc): MobileLocator {
|
|
|
223
246
|
pwLocator(page, desc).textContent({ timeout: DEFAULT_ACTION_TIMEOUT_MS }),
|
|
224
247
|
) as Promise<Wrapped<string | null>>;
|
|
225
248
|
},
|
|
249
|
+
first() {
|
|
250
|
+
return makeLocator(backend, { ...desc, nth: "first" });
|
|
251
|
+
},
|
|
252
|
+
last() {
|
|
253
|
+
return makeLocator(backend, { ...desc, nth: "last" });
|
|
254
|
+
},
|
|
255
|
+
nth(index: number) {
|
|
256
|
+
return makeLocator(backend, { ...desc, nth: index });
|
|
257
|
+
},
|
|
226
258
|
};
|
|
227
259
|
}
|
|
228
260
|
|