@specific.dev/spectest 0.30.0 → 0.31.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/locator.js +78 -5
  2. package/package.json +1 -1
package/dist/locator.js CHANGED
@@ -196,15 +196,74 @@ export const desktopStrategy = {
196
196
  * the press dwell RN Pressables need (playwright's `tap()` has no dwell). */
197
197
  export const mobileStrategy = {
198
198
  touch: true,
199
- async tap(backend, loc, opts) {
199
+ async tap(backend, loc, opts, rec) {
200
200
  await loc.waitFor({ state: "visible", timeout: opts?.timeout });
201
201
  await loc.scrollIntoViewIfNeeded({ timeout: opts?.timeout });
202
202
  const box = await loc.boundingBox();
203
203
  if (!box)
204
204
  throw new Error("tap: element vanished before it could be tapped");
205
- await backend.rawTap(box.x + box.width / 2, box.y + box.height / 2, opts?.duration);
205
+ const x = box.x + box.width / 2;
206
+ const y = box.y + box.height / 2;
207
+ // Exact, no probe needed: this IS the point the touch is dispatched at,
208
+ // measured after the scroll. Same field the replay reads — see
209
+ // stampActionPoint.
210
+ if (rec) {
211
+ rec.x = Math.round(x);
212
+ rec.y = Math.round(y);
213
+ }
214
+ await backend.rawTap(x, y, opts?.duration);
206
215
  },
207
216
  };
217
+ /** How long the click-point probe waits for the element. Short on purpose:
218
+ * either it is already on screen and the answer is one round trip away, or
219
+ * the action's own actionability wait — with the author's timeout and a real
220
+ * error message — is the one that should report it. */
221
+ const POINT_PROBE_MS = 250;
222
+ /** Stamp the viewport point an action is about to act on onto its (still
223
+ * open) step, as the `x`/`y` a raw `browser.clickAt` records.
224
+ *
225
+ * The replay cursor is normally positioned from rrweb's own recorded mouse
226
+ * events — but a click that navigates loses them. The page-side event buffer
227
+ * lives on `window`, so everything the click emitted dies with the document
228
+ * before the post-op drain can collect it, and the step arrives carrying only
229
+ * the destination page's load. The step's own coordinates are then the sole
230
+ * record of where the click landed; without them the viewer has nothing to
231
+ * put the cursor on.
232
+ *
233
+ * Best-effort in every direction. A probe failure (not attached yet, strict
234
+ * violation, frame detached) leaves the step unstamped and lets the action
235
+ * itself report the problem. We only stamp a centre that is inside the
236
+ * viewport of the top-level document, because that is exactly the case where
237
+ * playwright clicks the element where it currently sits — one that is
238
+ * off-screen (or in an iframe, whose rect is frame-relative) gets scrolled
239
+ * into view first, which moves the point we measured. */
240
+ async function stampActionPoint(loc, rec, position) {
241
+ try {
242
+ const pt = await loc.evaluate((el, pos) => {
243
+ if (window.top !== window)
244
+ return null;
245
+ const r = el.getBoundingClientRect();
246
+ if (!r.width || !r.height)
247
+ return null;
248
+ // Playwright clicks the element's centre unless the caller named a
249
+ // point — which it takes relative to the PADDING box, so an element
250
+ // with a border (a plain `<button>` has 2px of it) sits that far off
251
+ // the rect we measured.
252
+ const cs = pos ? getComputedStyle(el) : null;
253
+ const x = cs ? r.left + parseFloat(cs.borderLeftWidth) + pos.x : r.left + r.width / 2;
254
+ const y = cs ? r.top + parseFloat(cs.borderTopWidth) + pos.y : r.top + r.height / 2;
255
+ const onScreen = x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight;
256
+ return onScreen ? { x, y } : null;
257
+ }, position, { timeout: POINT_PROBE_MS });
258
+ if (pt) {
259
+ rec.x = Math.round(pt.x);
260
+ rec.y = Math.round(pt.y);
261
+ }
262
+ }
263
+ catch {
264
+ /* Element not ready / gone / strict violation — the action reports it. */
265
+ }
266
+ }
208
267
  // ────────────────────────────────────────────────────────────────────────
209
268
  // Factory
210
269
  // ────────────────────────────────────────────────────────────────────────
@@ -216,6 +275,14 @@ export function makeLocator(backend, strategy, chain) {
216
275
  const extend = (step) => makeLocator(backend, strategy, { steps: [...chain.steps, step] });
217
276
  // One recorded event, result NOT wrapped (void/action).
218
277
  const act = (action, fields, fn) => backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain), page));
278
+ // One recorded event whose fields the action itself finishes filling in:
279
+ // `rec` is the very object the recorder spreads once `fn` resolves (the
280
+ // same mutate-in-flight seam `screenshot`'s artifact id rides), so the
281
+ // click family can stamp the point it acted on. See stampActionPoint.
282
+ const actAt = (action, fn) => {
283
+ const rec = { selector: label };
284
+ return backend.pageOp(action, rec, (page) => fn(lower(page, chain), rec));
285
+ };
219
286
  // One recorded event, result provenance-wrapped for expect().
220
287
  const read = (action, fn, fields = {}) => backend.pageOp(action, { selector: label, ...fields }, (page) => fn(lower(page, chain)), {
221
288
  wrap: true,
@@ -250,9 +317,15 @@ export function makeLocator(backend, strategy, chain) {
250
317
  last: () => extend({ m: "last", args: [] }),
251
318
  nth: (i) => extend({ m: "nth", args: [i] }),
252
319
  // actions
253
- click: (opts) => act("click", {}, (l) => l.click(opts)),
254
- dblclick: (opts) => act("dblclick", {}, (l) => l.dblclick(opts)),
255
- tap: (opts) => act("tap", {}, (l) => strategy.tap(backend, l, opts)),
320
+ click: (opts) => actAt("click", async (l, rec) => {
321
+ await stampActionPoint(l, rec, opts?.position);
322
+ return l.click(opts);
323
+ }),
324
+ dblclick: (opts) => actAt("dblclick", async (l, rec) => {
325
+ await stampActionPoint(l, rec, opts?.position);
326
+ return l.dblclick(opts);
327
+ }),
328
+ tap: (opts) => actAt("tap", (l, rec) => strategy.tap(backend, l, opts, rec)),
256
329
  fill: (value, opts) => {
257
330
  const t = truncateUtf8(value);
258
331
  return act("fill", { text: t.value, textTruncated: t.truncated }, (l) => l.fill(value, { timeout: opts?.timeout }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@specific.dev/spectest",
3
- "version": "0.30.0",
3
+ "version": "0.31.0",
4
4
  "description": "Spectest SDK for defining test environments in TypeScript.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",