@e2e-dev/web 0.15.1 → 0.15.2

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/dist/.build.json CHANGED
@@ -1 +1 @@
1
- {"version":"0.15.1"}
1
+ {"version":"0.15.2"}
package/dist/browser.js CHANGED
@@ -237,6 +237,7 @@ export function createBrowserFixture(surface, context) {
237
237
  url: response.url(),
238
238
  status: response.status(),
239
239
  headers: response.headers(),
240
+ // oxlint-disable-next-line e2e/no-unchecked-json -- public API: the caller names T, unknown by default
240
241
  json: async () => JSON.parse(await text()),
241
242
  text,
242
243
  };
package/dist/engine.js CHANGED
@@ -36,8 +36,8 @@ export function web(options = {}) {
36
36
  if (provider !== undefined && options.connect !== undefined) {
37
37
  throw new ConfigurationError('INVALID_CONFIG', `web({ browser, connect }) names two browser sources; browser provider "${provider.name}" leases its own browsers, so remove connect`);
38
38
  }
39
- if (isRecord(options.connect))
40
- rejectUnknownKeys('web({ connect })', options.connect, ['cdpEndpoint', 'reconnectEndpoint']);
39
+ if (options.connect !== undefined)
40
+ validateConnect(options.connect);
41
41
  if (options.connect !== undefined && typeof options.browser === 'string' && options.browser !== 'chromium') {
42
42
  throw new ConfigurationError('INVALID_CONFIG', `web({ connect }) requires the chromium browser; CDP attach is chromium-only, got "${options.browser}"`);
43
43
  }
@@ -59,9 +59,6 @@ export function web(options = {}) {
59
59
  if (options.screencast !== undefined)
60
60
  validateScreencast(options.screencast);
61
61
  const reconnecting = options.connect?.reconnectEndpoint !== undefined;
62
- if (reconnecting && typeof options.connect?.reconnectEndpoint !== 'function') {
63
- throw new ConfigurationError('INVALID_CONFIG', 'connect.reconnectEndpoint must be a function');
64
- }
65
62
  // A per-attempt lease rides the same persistent context as `reconnectEndpoint`, with the same limits.
66
63
  const recoverable = reconnecting || provider?.scope === 'attempt';
67
64
  const mode = provider === undefined ? 'connect.reconnectEndpoint' : `browser provider "${provider.name}" with scope "attempt"`;
@@ -155,6 +152,38 @@ function persistentContextLimit(what, mode, leased) {
155
152
  function isRecord(value) {
156
153
  return typeof value === 'object' && value !== null && !Array.isArray(value);
157
154
  }
155
+ /** Names a value's kind for a config error without echoing it: a CDP URL can carry a session token. */
156
+ function kindOf(value) {
157
+ if (value === null)
158
+ return 'null';
159
+ if (Array.isArray(value))
160
+ return 'an array';
161
+ const type = typeof value;
162
+ return type === 'object' ? 'an object' : `a ${type}`;
163
+ }
164
+ /**
165
+ * `cdpEndpoint` is the one resolver every connect mode needs: the shared
166
+ * browser's source at init, and the fresh browser each persistent attempt
167
+ * provisions. Without it the attach would call an undefined resolver, so a
168
+ * config missing it fails here, at load, instead of at worker init or on
169
+ * every attempt with `ENGINE_FAILURE: ... resolve is not a function`.
170
+ */
171
+ function validateConnect(connect) {
172
+ if (!isRecord(connect)) {
173
+ throw new ConfigurationError('INVALID_CONFIG', `web({ connect }) must be a plain object: { cdpEndpoint, reconnectEndpoint? }, got ${kindOf(connect)}`);
174
+ }
175
+ rejectUnknownKeys('web({ connect })', connect, ['cdpEndpoint', 'reconnectEndpoint']);
176
+ const { cdpEndpoint, reconnectEndpoint } = connect;
177
+ if (cdpEndpoint === undefined) {
178
+ throw new ConfigurationError('INVALID_CONFIG', `web({ connect }) requires cdpEndpoint, the resolver that provisions the browser: (signal) => url${reconnectEndpoint === undefined ? '' : '; reconnectEndpoint only reconnects to the browser cdpEndpoint provisioned'}`);
179
+ }
180
+ if (typeof cdpEndpoint !== 'function') {
181
+ throw new ConfigurationError('INVALID_CONFIG', `web({ connect: { cdpEndpoint } }) must be a resolver function (signal) => url, got ${kindOf(cdpEndpoint)}`);
182
+ }
183
+ if (reconnectEndpoint !== undefined && typeof reconnectEndpoint !== 'function') {
184
+ throw new ConfigurationError('INVALID_CONFIG', `web({ connect: { reconnectEndpoint } }) must be a resolver function (signal) => url, got ${kindOf(reconnectEndpoint)}`);
185
+ }
186
+ }
158
187
  /**
159
188
  * Refuses a header the browser could not send, at config load rather than at
160
189
  * the first request: a name outside the token grammar, a value carrying a
@@ -83,6 +83,7 @@ async function captureInto(deps, scope, stage, host, framePath, budget, signal)
83
83
  return data;
84
84
  }));
85
85
  const decoded = typeof metadata === 'string'
86
+ // oxlint-disable-next-line e2e/no-unchecked-json -- page-reported metadata, as unchecked as the structured branch; only the ids and nodes alignment below is checked
86
87
  ? JSON.parse(metadata)
87
88
  : metadata;
88
89
  const { nodes, ids, truncated: walkTruncated } = decoded;
package/dist/support.js CHANGED
@@ -316,7 +316,7 @@ export async function performViewportSwipe(page, direction, momentum) {
316
316
  const viewport = await currentViewport(page);
317
317
  const distance = swipeDistance(direction === 'up' || direction === 'down' ? viewport.height : viewport.width, momentum);
318
318
  const [deltaX, deltaY] = wheelDelta(direction, distance);
319
- return page.mouse.wheel(deltaX, deltaY);
319
+ await wheelRendered(page, deltaX, deltaY);
320
320
  }
321
321
  /**
322
322
  * The browser truncates a fractional pointer coordinate, so a point composed
@@ -354,10 +354,16 @@ export async function performPointDrag(mouse, start, end, durationMs, signal) {
354
354
  }
355
355
  }
356
356
  export async function performElementSwipe(target, direction, momentum, timeout) {
357
- // Hover first: it auto-waits for visibility on both target kinds, so the
358
- // immediate box read below observes a settled element.
359
- await asActionable(target).hover({ timeout });
360
- const box = await targetBoundingBox(target, timeout);
357
+ // A node the pointer already hovers takes the wheel where it is; one hit at
358
+ // its centre takes it there, at the box that hit test read; otherwise hover
359
+ // waits for it to be actionable, scrolls it into view, and reports whatever
360
+ // intercepts the pointer.
361
+ const aimed = await (target.kind === 'locator'
362
+ ? target.locator.evaluate(wheelAim, undefined, { timeout })
363
+ : target.element.evaluate(wheelAim));
364
+ if (aimed.aim === 'blocked')
365
+ await asActionable(target).hover({ timeout });
366
+ const box = aimed.aim === 'blocked' ? await targetBoundingBox(target, timeout) : aimed.box;
361
367
  if (box === null) {
362
368
  throw new EngineError('NOT_ACTIONABLE', 'element has no visible bounding box', {
363
369
  retryable: false,
@@ -366,7 +372,41 @@ export async function performElementSwipe(target, direction, momentum, timeout)
366
372
  const distance = swipeDistance(direction === 'up' || direction === 'down' ? box.height : box.width, momentum);
367
373
  const [deltaX, deltaY] = wheelDelta(direction, distance);
368
374
  const page = await targetPage(target);
375
+ if (aimed.aim === 'centre')
376
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
377
+ await wheelRendered(page, deltaX, deltaY);
378
+ }
379
+ /**
380
+ * Wheels the page and resolves once a frame rendered the scroll it caused.
381
+ * The scroll lands on the compositor and reaches script on the next frame, so
382
+ * a read right after a bare `mouse.wheel` can see the document it left. A
383
+ * hidden document renders no frames, so it resolves at once.
384
+ */
385
+ async function wheelRendered(page, deltaX, deltaY) {
369
386
  await page.mouse.wheel(deltaX, deltaY);
387
+ await page.evaluate(nextRenderedFrame);
388
+ }
389
+ /** Page-side: resolves after the next rendered frame, or at once in a hidden document. */
390
+ function nextRenderedFrame() {
391
+ if (document.visibilityState === 'hidden')
392
+ return Promise.resolve();
393
+ return new Promise((resolve) => requestAnimationFrame(() => window.setTimeout(resolve, 0)));
394
+ }
395
+ /**
396
+ * Page-side: where a wheel reaches `element` in a top-level document, whose
397
+ * box is the page's: at the pointer when it already hovers the element, at
398
+ * its centre when the hit target there is the element or inside it, or
399
+ * `blocked` when neither holds.
400
+ */
401
+ function wheelAim(element) {
402
+ if (window !== window.top || element.getClientRects().length === 0)
403
+ return { aim: 'blocked' };
404
+ const rect = element.getBoundingClientRect();
405
+ const box = { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
406
+ if (element.matches(':hover'))
407
+ return { aim: 'pointer', box };
408
+ const hit = document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2);
409
+ return hit !== null && element.contains(hit) ? { aim: 'centre', box } : { aim: 'blocked' };
370
410
  }
371
411
  function swipeDistance(extent, momentum) {
372
412
  const ratio = momentum === 'fast' ? 1.5 : momentum === 'slow' ? 0.75 : 0.5;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@e2e-dev/web",
3
- "version": "0.15.1",
3
+ "version": "0.15.2",
4
4
  "description": "Web engine for e2e agentic end-to-end testing, built on Playwright",
5
5
  "keywords": [
6
6
  "e2e",
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/node": "26.6.2",
57
- "e2e": "0.21.1",
57
+ "e2e": "0.21.2",
58
58
  "@types/pngjs": "6.0.5",
59
59
  "pngjs": "7.0.0",
60
60
  "typescript": "7.0.2",