@atomic-testing/playwright 0.87.0 → 0.89.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.
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  BlurOption,
3
+ BoundingRect,
3
4
  byCssSelector,
4
5
  ClickOption,
5
6
  CssProperty,
6
7
  dateUtil,
7
8
  defaultWaitForOption,
9
+ ElementNotFoundError,
8
10
  EnterTextOption,
9
11
  FocusOption,
10
12
  HoverOption,
@@ -19,6 +21,7 @@ import {
19
21
  MouseUpOption,
20
22
  Optional,
21
23
  PartLocator,
24
+ Point,
22
25
  PressKeyOption,
23
26
  timingUtil,
24
27
  WaitForOption,
@@ -35,15 +38,150 @@ export class PlaywrightInteractor implements Interactor {
35
38
  */
36
39
  constructor(public readonly page: Page) {}
37
40
 
41
+ /**
42
+ * Run a Playwright mutation and normalize a "locator matched nothing" failure
43
+ * into {@link ElementNotFoundError}, so a missing element throws the same error
44
+ * class here as it does in `DOMInteractor`, regardless of environment (the
45
+ * unified contract ratified in ADR-006).
46
+ *
47
+ * Playwright auto-waits for actionability and then throws its own
48
+ * `TimeoutError`. We translate that to `ElementNotFoundError` ONLY when the
49
+ * element genuinely does not exist (count 0) and otherwise rethrow the original
50
+ * error — preserving Playwright's auto-wait for an element that exists but is
51
+ * briefly not actionable (covered, disabled, animating). The trade-off is that
52
+ * a truly-missing element waits out the page's action timeout before throwing;
53
+ * bound it with `page.setDefaultTimeout` when fast failure matters.
54
+ *
55
+ * @param locator - Locator the mutation targets
56
+ * @param action - Method name used in the error message (e.g. `'click'`)
57
+ * @param run - The Playwright action to execute
58
+ * @throws {ElementNotFoundError} If the action fails and the element is absent
59
+ */
60
+ private async runMutation<T>(locator: PartLocator, action: string, run: () => Promise<T>): Promise<T> {
61
+ try {
62
+ return await run();
63
+ } catch (e) {
64
+ if ((await this.exists(locator)) === false) {
65
+ throw new ElementNotFoundError(locator, action);
66
+ }
67
+ throw e;
68
+ }
69
+ }
70
+
38
71
  /**
39
72
  * Select the given option values on a `<select>` element.
40
73
  *
41
74
  * @param locator - Locator to the `<select>` element.
42
75
  * @param values - Values to select.
76
+ * @throws {ElementNotFoundError} If the element is not found
43
77
  */
44
78
  async selectOptionValue(locator: PartLocator, values: string[]): Promise<void> {
45
79
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
46
- await this.page.locator(cssLocator).selectOption(values);
80
+ await this.runMutation(locator, 'selectOptionValue', () => this.page.locator(cssLocator).selectOption(values));
81
+ }
82
+
83
+ /**
84
+ * Set the selected files on a `<input type="file">` element.
85
+ *
86
+ * Playwright's native `setInputFiles` reads the given paths from disk and
87
+ * populates the input's `FileList`, firing the change event — the only way to
88
+ * fill a file input, whose value cannot be set programmatically.
89
+ *
90
+ * @param locator - Locator of the `<input type="file">` element
91
+ * @param files - One or more filesystem paths to upload
92
+ * @throws {ElementNotFoundError} If the element is not found
93
+ */
94
+ async setInputFiles(locator: PartLocator, files: string | string[]): Promise<void> {
95
+ const cssLocator = await locatorUtil.toCssSelector(locator, this);
96
+ await this.runMutation(locator, 'setInputFiles', () => this.page.locator(cssLocator).setInputFiles(files));
97
+ }
98
+
99
+ /**
100
+ * Scroll the located element into view, no-op if already visible.
101
+ *
102
+ * Delegates to Playwright's `scrollIntoViewIfNeeded`, which performs a real
103
+ * layout-aware scroll in the browser.
104
+ *
105
+ * @param locator - Locator of the element to scroll into view
106
+ * @throws {ElementNotFoundError} If the element is not found
107
+ */
108
+ async scrollIntoView(locator: PartLocator): Promise<void> {
109
+ const css = await locatorUtil.toCssSelector(locator, this);
110
+ await this.runMutation(locator, 'scrollIntoView', () => this.page.locator(css).scrollIntoViewIfNeeded());
111
+ }
112
+
113
+ /**
114
+ * Scroll the located element by the given pixel delta.
115
+ *
116
+ * The scroll is performed by evaluating `el.scrollBy(dx, dy)` on the element
117
+ * itself rather than `page.mouse.wheel`. A wheel event scrolls whatever sits
118
+ * under the pointer and is non-deterministic across chromium/firefox/webkit,
119
+ * whereas evaluating `scrollBy` on the resolved element scrolls exactly that
120
+ * element. This is a deliberate deviation from ADR 0001's per-engine table
121
+ * (which lists `page.mouse.wheel`), taking the alternative the step-5 prompt
122
+ * permits ("or evaluate el.scrollBy") for cross-browser determinism.
123
+ *
124
+ * @param locator - Locator of the scrollable element
125
+ * @param delta - Pixel offset to scroll by
126
+ * @throws {ElementNotFoundError} If the element is not found
127
+ */
128
+ async scrollBy(locator: PartLocator, delta: Point): Promise<void> {
129
+ const css = await locatorUtil.toCssSelector(locator, this);
130
+ await this.runMutation(locator, 'scrollBy', () =>
131
+ this.page.locator(css).evaluate((el, d) => el.scrollBy(d.x, d.y), { x: delta.x, y: delta.y })
132
+ );
133
+ }
134
+
135
+ /**
136
+ * Drag the source element and drop it onto the target element.
137
+ *
138
+ * Delegates to Playwright's native `Locator.dragTo`, which performs a real,
139
+ * layout-aware drag gesture in the browser.
140
+ *
141
+ * @param source - Locator of the element to drag
142
+ * @param target - Locator of the drop target
143
+ * @throws {ElementNotFoundError} If either the source or target is not found
144
+ */
145
+ async dragTo(source: PartLocator, target: PartLocator): Promise<void> {
146
+ const srcCss = await locatorUtil.toCssSelector(source, this);
147
+ const tgtCss = await locatorUtil.toCssSelector(target, this);
148
+ try {
149
+ await this.page.locator(srcCss).dragTo(this.page.locator(tgtCss));
150
+ } catch (e) {
151
+ if ((await this.exists(source)) === false) {
152
+ throw new ElementNotFoundError(source, 'dragTo');
153
+ }
154
+ if ((await this.exists(target)) === false) {
155
+ throw new ElementNotFoundError(target, 'dragTo');
156
+ }
157
+ throw e;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Drag the located element by the given pixel delta from its center.
163
+ *
164
+ * The gesture is a single uninterrupted `move → down → move → up` sequence
165
+ * computed from the element's center. It deliberately does NOT reuse
166
+ * {@link mouseMove}/{@link mouseDown} — `mouseMove` resets the pointer with
167
+ * `page.mouse.move(0, 0)` after hovering, which would corrupt the drag path
168
+ * (see ADR 0001, option 5). The center comes from {@link getBoundingRect},
169
+ * which throws `ElementNotFoundError` when the element has no box, so this
170
+ * shares that "element not found" contract instead of re-deriving the box +
171
+ * guard here.
172
+ *
173
+ * @param locator - Locator of the element to drag
174
+ * @param delta - Pixel offset to drag by
175
+ * @throws {ElementNotFoundError} If the element has no bounding box
176
+ */
177
+ async drag(locator: PartLocator, delta: Point): Promise<void> {
178
+ const rect = await this.getBoundingRect(locator);
179
+ const cx = rect.x + rect.width / 2;
180
+ const cy = rect.y + rect.height / 2;
181
+ await this.page.mouse.move(cx, cy);
182
+ await this.page.mouse.down();
183
+ await this.page.mouse.move(cx + delta.x, cy + delta.y, { steps: 8 });
184
+ await this.page.mouse.up();
47
185
  }
48
186
 
49
187
  /**
@@ -104,93 +242,152 @@ export class PlaywrightInteractor implements Interactor {
104
242
 
105
243
  async enterText(locator: PartLocator, text: string, option?: Optional<Partial<EnterTextOption>>): Promise<void> {
106
244
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
107
- if (!option?.append) {
108
- await this.page.locator(cssLocator).clear();
109
- }
245
+ await this.runMutation(locator, 'enterText', async () => {
246
+ if (!option?.append) {
247
+ await this.page.locator(cssLocator).clear();
248
+ }
110
249
 
111
- // If it is a date, time or datetime-local input, validate the date format
112
- const type = (await this.getAttribute(locator, 'type')) ?? '';
113
- if (dateUtil.isHtmlDateInputType(type)) {
114
- const result = dateUtil.validateHtmlDateInput(type, text);
115
- if (!result.valid) {
116
- throw new Error(
117
- `Invalid date format for type: ${type}, expected format: ${result.format}, example: ${result.example}`
118
- );
250
+ // If it is a date, time or datetime-local input, validate the date format
251
+ const type = (await this.getAttribute(locator, 'type')) ?? '';
252
+ if (dateUtil.isHtmlDateInputType(type)) {
253
+ const result = dateUtil.validateHtmlDateInput(type, text);
254
+ if (!result.valid) {
255
+ throw new Error(
256
+ `Invalid date format for type: ${type}, expected format: ${result.format}, example: ${result.example}`
257
+ );
258
+ }
119
259
  }
120
- }
121
- await this.page.locator(cssLocator).fill(text);
260
+ await this.page.locator(cssLocator).fill(text);
261
+ });
262
+ }
263
+
264
+ async setRangeValue(locator: PartLocator, value: number): Promise<void> {
265
+ const cssLocator = await locatorUtil.toCssSelector(locator, this);
266
+ // Playwright's `fill` rejects `<input type="range">` (it is not a fillable
267
+ // text control), so set the value in-page through the native value setter.
268
+ // Calling the prototype setter both sanitizes the value to the input's step
269
+ // (the browser snaps an off-step target to the nearest valid step) and lets
270
+ // React's value tracker observe the change; the dispatched input/change
271
+ // events then drive a controlled component (e.g. MUI Slider) to re-render.
272
+ await this.runMutation(locator, 'setRangeValue', () =>
273
+ this.page.locator(cssLocator).evaluate((el, nextValue) => {
274
+ const input = el as HTMLInputElement;
275
+ const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set;
276
+ setter?.call(input, nextValue);
277
+ input.dispatchEvent(new Event('input', { bubbles: true }));
278
+ input.dispatchEvent(new Event('change', { bubbles: true }));
279
+ }, String(value))
280
+ );
122
281
  }
123
282
 
124
283
  async click(locator: PartLocator, option?: Partial<ClickOption>): Promise<void> {
125
284
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
126
- await this.page.locator(cssLocator).click({ position: option?.position });
285
+ await this.runMutation(locator, 'click', () => this.page.locator(cssLocator).click({ position: option?.position }));
286
+ }
287
+
288
+ /**
289
+ * Dispatch a right-click on the located element to open its context menu.
290
+ *
291
+ * Delegates to Playwright's native right-button click, which fires a real
292
+ * `contextmenu` event in the browser.
293
+ *
294
+ * @param locator - Locator of the element to right-click
295
+ * @throws {ElementNotFoundError} If the element is not found
296
+ */
297
+ async contextMenu(locator: PartLocator): Promise<void> {
298
+ const css = await locatorUtil.toCssSelector(locator, this);
299
+ await this.runMutation(locator, 'contextMenu', () => this.page.locator(css).click({ button: 'right' }));
127
300
  }
128
301
 
129
302
  async hover(locator: PartLocator, option?: Partial<HoverOption>): Promise<void> {
130
303
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
131
- await this.page.locator(cssLocator).hover({ position: option?.position });
304
+ await this.runMutation(locator, 'hover', () => this.page.locator(cssLocator).hover({ position: option?.position }));
132
305
  }
133
306
 
134
307
  async mouseMove(locator: PartLocator, option?: Partial<MouseMoveOption>): Promise<void> {
135
- await this.hover(locator, {
136
- position: option?.position,
308
+ await this.runMutation(locator, 'mouseMove', async () => {
309
+ await this.hover(locator, { position: option?.position });
310
+ await this.page.mouse.move(0, 0);
137
311
  });
138
- await this.page.mouse.move(0, 0);
139
312
  }
140
313
 
141
314
  async mouseDown(locator: PartLocator, option?: Partial<MouseDownOption>): Promise<void> {
142
- await this.hover(locator, {
143
- position: option?.position,
315
+ await this.runMutation(locator, 'mouseDown', async () => {
316
+ await this.hover(locator, { position: option?.position });
317
+ await this.page.mouse.down();
144
318
  });
145
- await this.page.mouse.down();
146
319
  }
147
320
 
148
321
  async mouseUp(locator: PartLocator, option?: Partial<MouseUpOption>): Promise<void> {
149
- await this.hover(locator, {
150
- position: option?.position,
322
+ await this.runMutation(locator, 'mouseUp', async () => {
323
+ await this.hover(locator, { position: option?.position });
324
+ await this.page.mouse.up();
151
325
  });
152
- await this.page.mouse.up();
153
326
  }
154
327
 
155
328
  async mouseOver(locator: PartLocator, option?: Partial<HoverOption>): Promise<void> {
156
- return this.hover(locator, option);
329
+ await this.runMutation(locator, 'mouseOver', () => this.hover(locator, option));
157
330
  }
158
331
 
159
332
  async mouseOut(locator: PartLocator, _option?: Partial<MouseOutOption>): Promise<void> {
160
333
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
161
- // First hover over the element to trigger mouseenter/mouseover
162
- await this.page.locator(cssLocator).hover();
163
- // Then dispatch mouseout event directly for cross-browser reliability
164
- await this.page.locator(cssLocator).dispatchEvent('mouseout');
334
+ await this.runMutation(locator, 'mouseOut', async () => {
335
+ // First hover over the element to trigger mouseenter/mouseover
336
+ await this.page.locator(cssLocator).hover();
337
+ // Then dispatch mouseout event directly for cross-browser reliability
338
+ await this.page.locator(cssLocator).dispatchEvent('mouseout');
339
+ });
165
340
  }
166
341
 
167
342
  async mouseEnter(locator: PartLocator, _option?: Partial<MouseEnterOption>): Promise<void> {
168
- return this.hover(locator);
343
+ await this.runMutation(locator, 'mouseEnter', () => this.hover(locator));
169
344
  }
170
345
 
171
346
  async mouseLeave(locator: PartLocator, _option?: Partial<MouseLeaveOption>): Promise<void> {
172
347
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
173
- // First hover over the element to trigger mouseenter/mouseover
174
- await this.page.locator(cssLocator).hover();
175
- // Dispatch mouseout which triggers both mouseout and mouseleave handlers in React
176
- await this.page.locator(cssLocator).dispatchEvent('mouseout');
348
+ await this.runMutation(locator, 'mouseLeave', async () => {
349
+ // First hover over the element to trigger mouseenter/mouseover
350
+ await this.page.locator(cssLocator).hover();
351
+ // Dispatch mouseout which triggers both mouseout and mouseleave handlers in React
352
+ await this.page.locator(cssLocator).dispatchEvent('mouseout');
353
+ });
177
354
  }
178
355
 
179
356
  async focus(locator: PartLocator, _option?: Partial<FocusOption>): Promise<void> {
180
357
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
181
- return this.page.focus(cssLocator);
358
+ await this.runMutation(locator, 'focus', () => this.page.focus(cssLocator));
182
359
  }
183
360
 
184
361
  async blur(locator: PartLocator, _option?: Partial<BlurOption>): Promise<void> {
185
362
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
186
- await this.page.locator(cssLocator).blur();
363
+ await this.runMutation(locator, 'blur', () => this.page.locator(cssLocator).blur());
187
364
  }
188
365
 
189
- async pressKey(locator: PartLocator, key: string, _option?: Partial<PressKeyOption>): Promise<void> {
366
+ async pressKey(locator: PartLocator, key: string, option?: Partial<PressKeyOption>): Promise<void> {
190
367
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
368
+ // Compose Playwright's chord syntax — modifiers joined to the key by `+`, in
369
+ // Playwright's accepted Control+Alt+Shift+Meta order — so the browser holds
370
+ // those modifiers across the keypress and the event carries ctrlKey/etc.
371
+ const modifiers: string[] = [];
372
+ if (option?.ctrl) {
373
+ modifiers.push('Control');
374
+ }
375
+ if (option?.alt) {
376
+ modifiers.push('Alt');
377
+ }
378
+ if (option?.shift) {
379
+ modifiers.push('Shift');
380
+ }
381
+ if (option?.meta) {
382
+ modifiers.push('Meta');
383
+ }
384
+ const chord = modifiers.length > 0 ? `${modifiers.join('+')}+${key}` : key;
191
385
  // locator.press auto-focuses the element, then dispatches a real, trusted
192
386
  // KeyboardEvent — the browser equivalent of the DOM focus-first keyDown/keyUp.
193
- await this.page.locator(cssLocator).press(key);
387
+ // Caveat: for Shift + a printable key the browser case-folds `event.key`
388
+ // (`Shift+a` → `'A'`) whereas the jsdom path keeps `'a'` — only the modifier
389
+ // flags are delivered identically across engines (see #924).
390
+ await this.runMutation(locator, 'pressKey', () => this.page.locator(cssLocator).press(chord));
194
391
  }
195
392
 
196
393
  async activate(locator: PartLocator): Promise<void> {
@@ -198,7 +395,7 @@ export class PlaywrightInteractor implements Interactor {
198
395
  // Geometry-free activation mirrors the mouseout dispatch precedent above: it
199
396
  // bypasses hit-testing to actuate a covered or zero-size input that
200
397
  // locator.click() (a real geometry hit-test) cannot reach.
201
- await this.page.locator(cssLocator).dispatchEvent('click');
398
+ await this.runMutation(locator, 'activate', () => this.page.locator(cssLocator).dispatchEvent('click'));
202
399
  }
203
400
 
204
401
  //#region wait conditions
@@ -249,6 +446,26 @@ export class PlaywrightInteractor implements Interactor {
249
446
  return text ?? undefined;
250
447
  }
251
448
 
449
+ /**
450
+ * Get the located element's bounding rectangle.
451
+ *
452
+ * `boundingBox()` returns `null` for a detached/invisible element rather than
453
+ * auto-waiting, so this throws `ElementNotFoundError` directly (no auto-wait)
454
+ * — matching the unified "element not found" contract (ADR-006).
455
+ *
456
+ * @param locator - Locator of the element to measure
457
+ * @returns The element's bounding rectangle in CSS pixels
458
+ * @throws {ElementNotFoundError} If the element has no bounding box
459
+ */
460
+ async getBoundingRect(locator: PartLocator): Promise<BoundingRect> {
461
+ const css = await locatorUtil.toCssSelector(locator, this);
462
+ const box = await this.page.locator(css).boundingBox();
463
+ if (box == null) {
464
+ throw new ElementNotFoundError(locator, 'getBoundingRect');
465
+ }
466
+ return { x: box.x, y: box.y, width: box.width, height: box.height };
467
+ }
468
+
252
469
  async exists(locator: PartLocator): Promise<boolean> {
253
470
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
254
471
  const count = await this.page.locator(cssLocator).count();
package/src/index.ts CHANGED
@@ -1,3 +1,2 @@
1
1
  export { createTestEngine } from './createTestEngine';
2
2
  export { PlaywrightInteractor } from './PlaywrightInteractor';
3
- export * from './testRunnerAdapter';
@@ -1,79 +0,0 @@
1
- import { ScenePart, TestEngine } from '@atomic-testing/core';
2
- import {
3
- E2eTestInterface,
4
- E2eTestRunEnvironmentFixture,
5
- TestFrameworkMapper,
6
- } from '@atomic-testing/internal-test-runner';
7
- import { expect, Page, test } from '@playwright/test';
8
-
9
- import { createTestEngine } from './createTestEngine';
10
-
11
- /**
12
- * Navigate the current Playwright page to the provided URL.
13
- *
14
- * @param url - Destination URL to load.
15
- * @param fixture - Optional test fixture supplying the Playwright page.
16
- */
17
- export async function goto(url: string): Promise<void>;
18
- export async function goto(url: string, fixture: E2eTestRunEnvironmentFixture): Promise<void>;
19
- export async function goto(url: string, fixture?: E2eTestRunEnvironmentFixture): Promise<void> {
20
- const page = fixture!.page as Page;
21
- await page.goto(url);
22
- }
23
-
24
- /**
25
- * Create a {@link TestEngine} bound to the Playwright page in the given fixture.
26
- *
27
- * @param scenePart - Scene definition to drive.
28
- * @param fixture - Fixture providing the Playwright page.
29
- */
30
- export function playwrightGetTestEngine<T extends ScenePart>(
31
- scenePart: T,
32
- fixture: E2eTestRunEnvironmentFixture
33
- ): TestEngine<T> {
34
- const page = fixture.page as Page;
35
- return createTestEngine(page, scenePart);
36
- }
37
-
38
- /**
39
- * Playwright adapter for the TestFrameworkMapper interface.
40
- */
41
- export const playWrightTestFrameworkMapper: TestFrameworkMapper = {
42
- /*
43
- * INTENTIONAL @ts-expect-error comments: Playwright's test functions have different type
44
- * signatures than the normalized TestFrameworkMapper interface. Playwright uses fixture-based
45
- * callbacks with destructuring ({ page, browser }) while our interface uses a union type for
46
- * Jest compatibility (done callback or fixture object). The functions are compatible at runtime
47
- * but TypeScript cannot verify this due to these fundamental signature differences.
48
- */
49
-
50
- assertEqual: (a, b) => expect(a).toEqual(b),
51
- assertNotEqual: (a, b) => expect(a).not.toEqual(b),
52
- assertTrue: value => expect(value).toBe(true),
53
- assertFalse: value => expect(value).toBe(false),
54
- assertApproxEqual: (actual, expected, tolerance) =>
55
- expect(Math.abs(actual - expected)).toBeLessThanOrEqual(tolerance),
56
- // @ts-expect-error - Playwright describe signature differs from TestFrameworkMapper.Describe
57
- describe: test.describe,
58
-
59
- beforeEach: test.beforeEach,
60
- afterEach: test.afterEach,
61
- beforeAll: test.beforeAll,
62
- afterAll: test.afterAll,
63
-
64
- // @ts-expect-error - Playwright test signature differs from TestFrameworkMapper.Test
65
- test: test,
66
-
67
- // @ts-expect-error - Playwright test signature differs from TestFrameworkMapper.Test
68
- it: test,
69
- };
70
-
71
- /**
72
- * Get a typed interface for running end-to-end tests with Playwright.
73
- */
74
- export function getTestRunnerInterface<T extends ScenePart>(): E2eTestInterface<T> {
75
- return {
76
- getTestEngine: playwrightGetTestEngine,
77
- goto,
78
- };
79
- }