@atomic-testing/playwright 0.88.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.
@@ -38,15 +38,46 @@ export class PlaywrightInteractor implements Interactor {
38
38
  */
39
39
  constructor(public readonly page: Page) {}
40
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
+
41
71
  /**
42
72
  * Select the given option values on a `<select>` element.
43
73
  *
44
74
  * @param locator - Locator to the `<select>` element.
45
75
  * @param values - Values to select.
76
+ * @throws {ElementNotFoundError} If the element is not found
46
77
  */
47
78
  async selectOptionValue(locator: PartLocator, values: string[]): Promise<void> {
48
79
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
49
- await this.page.locator(cssLocator).selectOption(values);
80
+ await this.runMutation(locator, 'selectOptionValue', () => this.page.locator(cssLocator).selectOption(values));
50
81
  }
51
82
 
52
83
  /**
@@ -54,31 +85,29 @@ export class PlaywrightInteractor implements Interactor {
54
85
  *
55
86
  * Playwright's native `setInputFiles` reads the given paths from disk and
56
87
  * populates the input's `FileList`, firing the change event — the only way to
57
- * fill a file input, whose value cannot be set programmatically. Following
58
- * this layer's convention, no `ElementNotFoundError` is fabricated: a missing
59
- * element surfaces through Playwright's own auto-wait timeout.
88
+ * fill a file input, whose value cannot be set programmatically.
60
89
  *
61
90
  * @param locator - Locator of the `<input type="file">` element
62
91
  * @param files - One or more filesystem paths to upload
92
+ * @throws {ElementNotFoundError} If the element is not found
63
93
  */
64
94
  async setInputFiles(locator: PartLocator, files: string | string[]): Promise<void> {
65
95
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
66
- await this.page.locator(cssLocator).setInputFiles(files);
96
+ await this.runMutation(locator, 'setInputFiles', () => this.page.locator(cssLocator).setInputFiles(files));
67
97
  }
68
98
 
69
99
  /**
70
100
  * Scroll the located element into view, no-op if already visible.
71
101
  *
72
102
  * Delegates to Playwright's `scrollIntoViewIfNeeded`, which performs a real
73
- * layout-aware scroll in the browser. Per this layer's convention, no
74
- * `ElementNotFoundError` is fabricated: a missing element surfaces through
75
- * Playwright's own auto-wait timeout.
103
+ * layout-aware scroll in the browser.
76
104
  *
77
105
  * @param locator - Locator of the element to scroll into view
106
+ * @throws {ElementNotFoundError} If the element is not found
78
107
  */
79
108
  async scrollIntoView(locator: PartLocator): Promise<void> {
80
109
  const css = await locatorUtil.toCssSelector(locator, this);
81
- await this.page.locator(css).scrollIntoViewIfNeeded();
110
+ await this.runMutation(locator, 'scrollIntoView', () => this.page.locator(css).scrollIntoViewIfNeeded());
82
111
  }
83
112
 
84
113
  /**
@@ -90,33 +119,43 @@ export class PlaywrightInteractor implements Interactor {
90
119
  * whereas evaluating `scrollBy` on the resolved element scrolls exactly that
91
120
  * element. This is a deliberate deviation from ADR 0001's per-engine table
92
121
  * (which lists `page.mouse.wheel`), taking the alternative the step-5 prompt
93
- * permits ("or evaluate el.scrollBy") for cross-browser determinism. As with
94
- * {@link scrollIntoView}, no `ElementNotFoundError` is fabricated; a missing
95
- * element surfaces through Playwright's own auto-wait timeout.
122
+ * permits ("or evaluate el.scrollBy") for cross-browser determinism.
96
123
  *
97
124
  * @param locator - Locator of the scrollable element
98
125
  * @param delta - Pixel offset to scroll by
126
+ * @throws {ElementNotFoundError} If the element is not found
99
127
  */
100
128
  async scrollBy(locator: PartLocator, delta: Point): Promise<void> {
101
129
  const css = await locatorUtil.toCssSelector(locator, this);
102
- await this.page.locator(css).evaluate((el, d) => el.scrollBy(d.x, d.y), { x: delta.x, y: delta.y });
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
+ );
103
133
  }
104
134
 
105
135
  /**
106
136
  * Drag the source element and drop it onto the target element.
107
137
  *
108
138
  * Delegates to Playwright's native `Locator.dragTo`, which performs a real,
109
- * layout-aware drag gesture in the browser. Per this layer's convention, no
110
- * `ElementNotFoundError` is fabricated: a missing element surfaces through
111
- * Playwright's own auto-wait timeout.
139
+ * layout-aware drag gesture in the browser.
112
140
  *
113
141
  * @param source - Locator of the element to drag
114
142
  * @param target - Locator of the drop target
143
+ * @throws {ElementNotFoundError} If either the source or target is not found
115
144
  */
116
145
  async dragTo(source: PartLocator, target: PartLocator): Promise<void> {
117
146
  const srcCss = await locatorUtil.toCssSelector(source, this);
118
147
  const tgtCss = await locatorUtil.toCssSelector(target, this);
119
- await this.page.locator(srcCss).dragTo(this.page.locator(tgtCss));
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
+ }
120
159
  }
121
160
 
122
161
  /**
@@ -127,9 +166,9 @@ export class PlaywrightInteractor implements Interactor {
127
166
  * {@link mouseMove}/{@link mouseDown} — `mouseMove` resets the pointer with
128
167
  * `page.mouse.move(0, 0)` after hovering, which would corrupt the drag path
129
168
  * (see ADR 0001, option 5). The center comes from {@link getBoundingRect},
130
- * which throws `ElementNotFoundError` when the element has no box
131
- * (detached/invisible) rather than auto-waiting so this shares that
132
- * "element not found" contract instead of re-deriving the box + guard here.
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.
133
172
  *
134
173
  * @param locator - Locator of the element to drag
135
174
  * @param delta - Pixel offset to drag by
@@ -203,101 +242,125 @@ export class PlaywrightInteractor implements Interactor {
203
242
 
204
243
  async enterText(locator: PartLocator, text: string, option?: Optional<Partial<EnterTextOption>>): Promise<void> {
205
244
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
206
- if (!option?.append) {
207
- await this.page.locator(cssLocator).clear();
208
- }
245
+ await this.runMutation(locator, 'enterText', async () => {
246
+ if (!option?.append) {
247
+ await this.page.locator(cssLocator).clear();
248
+ }
209
249
 
210
- // If it is a date, time or datetime-local input, validate the date format
211
- const type = (await this.getAttribute(locator, 'type')) ?? '';
212
- if (dateUtil.isHtmlDateInputType(type)) {
213
- const result = dateUtil.validateHtmlDateInput(type, text);
214
- if (!result.valid) {
215
- throw new Error(
216
- `Invalid date format for type: ${type}, expected format: ${result.format}, example: ${result.example}`
217
- );
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
+ }
218
259
  }
219
- }
220
- 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
+ );
221
281
  }
222
282
 
223
283
  async click(locator: PartLocator, option?: Partial<ClickOption>): Promise<void> {
224
284
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
225
- await this.page.locator(cssLocator).click({ position: option?.position });
285
+ await this.runMutation(locator, 'click', () => this.page.locator(cssLocator).click({ position: option?.position }));
226
286
  }
227
287
 
228
288
  /**
229
289
  * Dispatch a right-click on the located element to open its context menu.
230
290
  *
231
291
  * Delegates to Playwright's native right-button click, which fires a real
232
- * `contextmenu` event in the browser. Per this layer's convention, no
233
- * `ElementNotFoundError` is fabricated: a missing element surfaces through
234
- * Playwright's own auto-wait timeout.
292
+ * `contextmenu` event in the browser.
235
293
  *
236
294
  * @param locator - Locator of the element to right-click
295
+ * @throws {ElementNotFoundError} If the element is not found
237
296
  */
238
297
  async contextMenu(locator: PartLocator): Promise<void> {
239
298
  const css = await locatorUtil.toCssSelector(locator, this);
240
- await this.page.locator(css).click({ button: 'right' });
299
+ await this.runMutation(locator, 'contextMenu', () => this.page.locator(css).click({ button: 'right' }));
241
300
  }
242
301
 
243
302
  async hover(locator: PartLocator, option?: Partial<HoverOption>): Promise<void> {
244
303
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
245
- await this.page.locator(cssLocator).hover({ position: option?.position });
304
+ await this.runMutation(locator, 'hover', () => this.page.locator(cssLocator).hover({ position: option?.position }));
246
305
  }
247
306
 
248
307
  async mouseMove(locator: PartLocator, option?: Partial<MouseMoveOption>): Promise<void> {
249
- await this.hover(locator, {
250
- 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);
251
311
  });
252
- await this.page.mouse.move(0, 0);
253
312
  }
254
313
 
255
314
  async mouseDown(locator: PartLocator, option?: Partial<MouseDownOption>): Promise<void> {
256
- await this.hover(locator, {
257
- position: option?.position,
315
+ await this.runMutation(locator, 'mouseDown', async () => {
316
+ await this.hover(locator, { position: option?.position });
317
+ await this.page.mouse.down();
258
318
  });
259
- await this.page.mouse.down();
260
319
  }
261
320
 
262
321
  async mouseUp(locator: PartLocator, option?: Partial<MouseUpOption>): Promise<void> {
263
- await this.hover(locator, {
264
- position: option?.position,
322
+ await this.runMutation(locator, 'mouseUp', async () => {
323
+ await this.hover(locator, { position: option?.position });
324
+ await this.page.mouse.up();
265
325
  });
266
- await this.page.mouse.up();
267
326
  }
268
327
 
269
328
  async mouseOver(locator: PartLocator, option?: Partial<HoverOption>): Promise<void> {
270
- return this.hover(locator, option);
329
+ await this.runMutation(locator, 'mouseOver', () => this.hover(locator, option));
271
330
  }
272
331
 
273
332
  async mouseOut(locator: PartLocator, _option?: Partial<MouseOutOption>): Promise<void> {
274
333
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
275
- // First hover over the element to trigger mouseenter/mouseover
276
- await this.page.locator(cssLocator).hover();
277
- // Then dispatch mouseout event directly for cross-browser reliability
278
- 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
+ });
279
340
  }
280
341
 
281
342
  async mouseEnter(locator: PartLocator, _option?: Partial<MouseEnterOption>): Promise<void> {
282
- return this.hover(locator);
343
+ await this.runMutation(locator, 'mouseEnter', () => this.hover(locator));
283
344
  }
284
345
 
285
346
  async mouseLeave(locator: PartLocator, _option?: Partial<MouseLeaveOption>): Promise<void> {
286
347
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
287
- // First hover over the element to trigger mouseenter/mouseover
288
- await this.page.locator(cssLocator).hover();
289
- // Dispatch mouseout which triggers both mouseout and mouseleave handlers in React
290
- 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
+ });
291
354
  }
292
355
 
293
356
  async focus(locator: PartLocator, _option?: Partial<FocusOption>): Promise<void> {
294
357
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
295
- return this.page.focus(cssLocator);
358
+ await this.runMutation(locator, 'focus', () => this.page.focus(cssLocator));
296
359
  }
297
360
 
298
361
  async blur(locator: PartLocator, _option?: Partial<BlurOption>): Promise<void> {
299
362
  const cssLocator = await locatorUtil.toCssSelector(locator, this);
300
- await this.page.locator(cssLocator).blur();
363
+ await this.runMutation(locator, 'blur', () => this.page.locator(cssLocator).blur());
301
364
  }
302
365
 
303
366
  async pressKey(locator: PartLocator, key: string, option?: Partial<PressKeyOption>): Promise<void> {
@@ -324,7 +387,7 @@ export class PlaywrightInteractor implements Interactor {
324
387
  // Caveat: for Shift + a printable key the browser case-folds `event.key`
325
388
  // (`Shift+a` → `'A'`) whereas the jsdom path keeps `'a'` — only the modifier
326
389
  // flags are delivered identically across engines (see #924).
327
- await this.page.locator(cssLocator).press(chord);
390
+ await this.runMutation(locator, 'pressKey', () => this.page.locator(cssLocator).press(chord));
328
391
  }
329
392
 
330
393
  async activate(locator: PartLocator): Promise<void> {
@@ -332,7 +395,7 @@ export class PlaywrightInteractor implements Interactor {
332
395
  // Geometry-free activation mirrors the mouseout dispatch precedent above: it
333
396
  // bypasses hit-testing to actuate a covered or zero-size input that
334
397
  // locator.click() (a real geometry hit-test) cannot reach.
335
- await this.page.locator(cssLocator).dispatchEvent('click');
398
+ await this.runMutation(locator, 'activate', () => this.page.locator(cssLocator).dispatchEvent('click'));
336
399
  }
337
400
 
338
401
  //#region wait conditions
@@ -387,9 +450,8 @@ export class PlaywrightInteractor implements Interactor {
387
450
  * Get the located element's bounding rectangle.
388
451
  *
389
452
  * `boundingBox()` returns `null` for a detached/invisible element rather than
390
- * auto-waiting, so this is one of the few Playwright methods that throws
391
- * `ElementNotFoundError` — matching the house "element not found" contract
392
- * (ADR 0001).
453
+ * auto-waiting, so this throws `ElementNotFoundError` directly (no auto-wait)
454
+ * — matching the unified "element not found" contract (ADR-006).
393
455
  *
394
456
  * @param locator - Locator of the element to measure
395
457
  * @returns The element's bounding rectangle in CSS pixels
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,81 +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
- hasLayout: true,
71
- };
72
-
73
- /**
74
- * Get a typed interface for running end-to-end tests with Playwright.
75
- */
76
- export function getTestRunnerInterface<T extends ScenePart>(): E2eTestInterface<T> {
77
- return {
78
- getTestEngine: playwrightGetTestEngine,
79
- goto,
80
- };
81
- }