feather-testing-core 0.1.2 → 0.3.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.
package/README.md CHANGED
@@ -169,25 +169,88 @@ Every method returns `this` for chaining. A single `await` at the start of the c
169
169
  | `check(label)` / `uncheck(label)` | Toggle checkbox by label |
170
170
  | `choose(label)` | Select radio button by label |
171
171
  | `submit()` | Submit the most recently interacted form (see below) |
172
+ | `upload(label, path)` | Set a file input (found by label) to the file at `path` |
173
+ | `dropFile(selector, path)` | Dispatch a `DataTransfer` drop of the file onto a drop area |
174
+
175
+ #### Interactions address controls **exactly**
176
+
177
+ Every interaction above names the control it wants, and that name is matched in full: `clickButton("Check")` clicks the button named *Check*, never the sidebar chip named *"Checklist Run — checklist"*. Whitespace is still normalized, so multi-line markup and padded labels keep working.
178
+
179
+ This matters because Playwright's bare-string matchers are case-insensitive *substring* matchers. Left as-is, a verb aimed at one control silently widens to any other control whose name merely contains the same text — and the run dies on a strict-mode violation that only appears when both are on screen at once, which turns a naming collision into an ordering-dependent flake. RTL matches whole strings by default, so with this both adapters answer the same question.
180
+
181
+ Assertions are the deliberate exception: `assertText` / `refuteText` / `assertHas` ask *"does this text appear"*, so they stay substring matches. An exact `refuteText("Check")` would pass while *Checklist Run* is plainly on the page.
182
+
183
+ To act on a control whose name is genuinely a prefix of another's, scope the lookup rather than loosening it:
184
+
185
+ ```ts
186
+ await session.within("main", (s) => s.clickButton("Check"));
187
+ ```
172
188
 
173
189
  #### How `submit()` finds the submit button
174
190
 
175
191
  `submit()` tracks the `<form>` element from the last `fillIn`, `selectOption`, `check`, `uncheck`, or `choose` call, then uses this strategy:
176
192
 
177
- 1. **By accessible name** — looks for a `<button>` whose name contains "submit" (case-insensitive)
178
- 2. **By `type="submit"`** — looks for `<button type="submit">` or `<input type="submit">`
193
+ 1. **By `type="submit"`** — looks for `<button type="submit">` or `<input type="submit">` (the DOM's ground truth)
194
+ 2. **By accessible name** — looks for a `<button>` whose name contains "submit" (case-insensitive)
179
195
  3. **Enter key fallback** — presses Enter on the last form field
180
196
 
181
197
  If no form was previously interacted with, `submit()` throws an error.
182
198
 
199
+ #### File uploads
200
+
201
+ ```ts
202
+ // Standard file input, found by its label
203
+ await session.upload("Avatar", "fixtures/avatar.png");
204
+
205
+ // Custom drop area (drag-and-drop upload zones)
206
+ await session.dropFile("#dropzone", "fixtures/report.pdf");
207
+ ```
208
+
209
+ In Playwright, `dropFile` reads the real file and dispatches a `drop` event with a `DataTransfer`. In RTL (JSDOM has no filesystem), both verbs synthesize an empty `File` named after the path's basename — assert on the file name, not its contents.
210
+
183
211
  ### Assertions
184
212
 
185
213
  | Method | Description |
186
214
  |--------|-------------|
187
215
  | `assertText(text)` / `refuteText(text)` | Assert text is visible / not visible |
216
+ | `assertValue(label, value)` | Assert a field (by label or placeholder) has this value |
217
+ | `assertChecked(label)` / `refuteChecked(label)` | Assert a checkbox is checked / not checked |
218
+ | `assertSelected(label, optionLabel)` | Assert the select's currently selected option |
219
+ | `assertOptions(label, [labels])` | Assert a select offers exactly these options, in order |
188
220
  | `assertHas(selector, opts?)` / `refuteHas(...)` | Assert element exists (Playwright only, see options below) |
189
221
  | `assertPath(path, opts?)` / `refutePath(path)` | Assert URL path (Playwright only, see options below) |
190
222
 
223
+ #### Form-state assertions
224
+
225
+ ```ts
226
+ await session
227
+ .fillIn("Email", "a@b.com")
228
+ .assertValue("Email", "a@b.com")
229
+ .check("Subscribe")
230
+ .assertChecked("Subscribe")
231
+ .refuteChecked("Receive ads")
232
+ .selectOption("Plan", "Pro")
233
+ .assertSelected("Plan", "Pro")
234
+ .assertOptions("Plan", ["Free", "Pro", "Enterprise"]);
235
+ ```
236
+
237
+ In Playwright these are backed by `toHaveValue` / `toBeChecked` / `toHaveText`, so they auto-retry. The RTL adapter polls the DOM with `waitFor` for the same retry semantics.
238
+
239
+ #### Pair every refute with a positive assertion
240
+
241
+ `refuteText` / `refuteHas` assert *absence* — and absence also holds when the page failed to render at all. A blank page passes `refuteHas(".delete-button")`. Always pair a refute with a positive assertion on the same region so the test proves the page actually rendered:
242
+
243
+ ```ts
244
+ // ❌ Passes even if the action bar never rendered
245
+ await session.refuteHas(".action-bar button", { text: "Delete" });
246
+
247
+ // ✅ The positive complement proves the action bar rendered with exactly [Import]
248
+ await session
249
+ .assertHas(".action-bar button", { count: 1 })
250
+ .assertHas(".action-bar button", { text: "Import" })
251
+ .refuteHas(".action-bar button", { text: "Delete" });
252
+ ```
253
+
191
254
  #### `assertHas` / `refuteHas` options
192
255
 
193
256
  | Option | Type | Description |
@@ -217,6 +280,8 @@ await session.refuteHas(".card", { text: "Deleted Item" });
217
280
 
218
281
  #### `assertPath` / `refutePath` options
219
282
 
283
+ The path is compared against the URL's parsed `pathname` exactly — `assertPath("/import")` does **not** pass on `/re/import`.
284
+
220
285
  ```ts
221
286
  // Assert path (ignores query params)
222
287
  await session.assertPath("/projects");
@@ -244,6 +309,19 @@ await session
244
309
  .assertText("Dashboard"); // back to full-page scope after within()
245
310
  ```
246
311
 
312
+ ### Escape hatch: `step(name, fn)`
313
+
314
+ When you need something the DSL doesn't cover, queue a named custom step instead of abandoning the chain. The callback receives the adapter's context — `{ page, scope }` for Playwright, `{ user, container }` for RTL — and the name shows up in `StepError` output like any built-in step:
315
+
316
+ ```ts
317
+ await session
318
+ .visit("/board")
319
+ .step("drag card to Done column", async ({ page }) => {
320
+ await page.getByText("My card").dragTo(page.locator("#done"));
321
+ })
322
+ .assertText("Done (1)");
323
+ ```
324
+
247
325
  ### Debug
248
326
 
249
327
  | Method | Description |
@@ -316,6 +394,10 @@ Chain:
316
394
  [skipped] assertText('Hello! You are signed in.')
317
395
  ```
318
396
 
397
+ The session keeps a history of executed steps, so when you break a flow into multiple chains (multiple `await`s), the `StepError` still shows the full walk — steps from earlier chains appear as `[ok]` above the failing chain.
398
+
399
+ With the Playwright adapter, each queued step is also wrapped in `test.step()`, so chains appear as named steps in the trace viewer and HTML report.
400
+
319
401
  ## RTL Adapter Limitations
320
402
 
321
403
  The RTL adapter runs in JSDOM, which has no real browser. These methods are not available and will throw:
@@ -324,6 +406,36 @@ The RTL adapter runs in JSDOM, which has no real browser. These methods are not
324
406
  - `assertPath()` / `refutePath()` — no URL in JSDOM
325
407
  - `assertHas()` / `refuteHas()` — RTL discourages CSS selectors; use `assertText()` instead
326
408
 
409
+ ### Extending the RTL adapter
410
+
411
+ `RTLDriver` is meant to be subclassed when an app's markup needs a different lookup, so that a host harness binds *this* DSL rather than reimplementing it. Everything worth specializing is `protected`:
412
+
413
+ | Member | Why you'd override it |
414
+ |--------|----------------------|
415
+ | `findField(label)` | The single label-addressed lookup. Every labelled verb — `fillIn`, `selectOption`, `check`, `uncheck`, `upload`, `assertValue`, `assertChecked`, `assertSelected`, `assertOptions` — goes through it, so one override retargets them all |
416
+ | `scoped(element)` | Factory used by `within()`, so a scoped session keeps your driver's behaviour |
417
+ | `user`, `root`, `container`, `lastFormElement`, `timeout` | Shared state the built-in verbs read and write |
418
+
419
+ ```ts
420
+ class WrapperLabelDriver extends RTLDriver {
421
+ // Labels with no htmlFor, control is a sibling inside a wrapper div
422
+ protected override async findField(label: string): Promise<HTMLElement> {
423
+ for (const l of this.rootElement().querySelectorAll("label")) {
424
+ if (l.textContent?.trim() !== label) continue;
425
+ const control = l.parentElement?.querySelector("input, textarea, select");
426
+ if (control) return control as HTMLElement;
427
+ }
428
+ throw new Error(`no field labelled '${label}'`);
429
+ }
430
+
431
+ protected override scoped(element: HTMLElement) {
432
+ return new WrapperLabelDriver(this.user, element, this.timeout);
433
+ }
434
+ }
435
+ ```
436
+
437
+ The third constructor argument is a per-lookup timeout in ms; omit it to keep RTL's own default.
438
+
327
439
  ## Exports
328
440
 
329
441
  ```ts
@@ -1,6 +1,12 @@
1
1
  import { type Page, type Locator } from "@playwright/test";
2
2
  import type { AssertHasOptions, AssertPathOptions, TestDriver } from "../types.js";
3
- export declare class PlaywrightDriver implements TestDriver {
3
+ /** Context handed to custom step() callbacks in the Playwright adapter. */
4
+ export interface PlaywrightStepContext {
5
+ page: Page;
6
+ /** Current scope: the page, or the container locator inside within(). */
7
+ scope: Page | Locator;
8
+ }
9
+ export declare class PlaywrightDriver implements TestDriver<PlaywrightStepContext> {
4
10
  private page;
5
11
  private scope;
6
12
  private lastFormLocator;
@@ -9,19 +15,31 @@ export declare class PlaywrightDriver implements TestDriver {
9
15
  click(text: string): Promise<void>;
10
16
  clickLink(text: string): Promise<void>;
11
17
  clickButton(text: string): Promise<void>;
18
+ /** The single label-addressed lookup: every labelled verb goes through it. */
19
+ private labelled;
20
+ private fieldByLabelOrPlaceholder;
12
21
  fillIn(label: string, value: string): Promise<void>;
13
22
  selectOption(label: string, option: string): Promise<void>;
14
23
  check(label: string): Promise<void>;
15
24
  uncheck(label: string): Promise<void>;
16
25
  choose(label: string): Promise<void>;
17
26
  submit(): Promise<void>;
27
+ upload(label: string, path: string): Promise<void>;
28
+ dropFile(selector: string, path: string): Promise<void>;
18
29
  assertHas(selector: string, opts?: AssertHasOptions): Promise<void>;
19
30
  refuteHas(selector: string, opts?: AssertHasOptions): Promise<void>;
20
31
  assertText(text: string): Promise<void>;
21
32
  refuteText(text: string): Promise<void>;
33
+ assertValue(label: string, value: string): Promise<void>;
34
+ assertChecked(label: string): Promise<void>;
35
+ refuteChecked(label: string): Promise<void>;
36
+ assertSelected(label: string, optionLabel: string): Promise<void>;
37
+ assertOptions(label: string, optionLabels: string[]): Promise<void>;
22
38
  assertPath(path: string, opts?: AssertPathOptions): Promise<void>;
23
39
  refutePath(path: string): Promise<void>;
24
- within(selector: string): Promise<TestDriver>;
40
+ step(fn: (context: PlaywrightStepContext) => Promise<unknown>): Promise<void>;
41
+ within(selector: string): Promise<TestDriver<PlaywrightStepContext>>;
25
42
  debug(): Promise<void>;
43
+ wrapStep(name: string, fn: () => Promise<void>): Promise<void>;
26
44
  }
27
45
  //# sourceMappingURL=driver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../../src/playwright/driver.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,IAAI,EAAE,KAAK,OAAO,EAAU,MAAM,kBAAkB,CAAC;AACnE,OAAO,KAAK,EACV,gBAAgB,EAChB,iBAAiB,EACjB,UAAU,EACX,MAAM,aAAa,CAAC;AAErB,qBAAa,gBAAiB,YAAW,UAAU;IAI/C,OAAO,CAAC,IAAI;IACZ,OAAO,CAAC,KAAK;IAJf,OAAO,CAAC,eAAe,CAAwB;gBAGrC,IAAI,EAAE,IAAI,EACV,KAAK,GAAE,IAAI,GAAG,OAAc;IAGhC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIlC,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAItC,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIxC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAcnD,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAM1D,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMnC,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMrC,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMpC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IA8BvB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBnE,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAUnE,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvC,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvC,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC;IAYjE,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOvC,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAM7C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAM7B"}
1
+ {"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../../src/playwright/driver.ts"],"names":[],"mappings":"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"}
@@ -1,4 +1,21 @@
1
- import { expect } from "@playwright/test";
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import { expect, test } from "@playwright/test";
4
+ /**
5
+ * Playwright matches a bare string name/label/text as a case-insensitive
6
+ * SUBSTRING. That makes every text-addressed verb ambient: `clickButton('Check')`
7
+ * also matches an unrelated "Checklist Run — checklist" control that happens to
8
+ * be on the page, and the run dies on a strict-mode violation whose appearance
9
+ * depends on what else rendered. When a spec names a control it means *that*
10
+ * control, so every addressing matcher here passes `exact: true`. Playwright
11
+ * still normalizes whitespace under exact matching, so multi-line markup and
12
+ * padded labels keep working.
13
+ *
14
+ * Assertions (assertText/refuteText/assertHas) deliberately stay substring:
15
+ * they ask "does this text appear", and an exact `refuteText` would pass while
16
+ * the text is plainly on the page inside a longer string.
17
+ */
18
+ const EXACT = { exact: true };
2
19
  export class PlaywrightDriver {
3
20
  page;
4
21
  scope;
@@ -11,44 +28,48 @@ export class PlaywrightDriver {
11
28
  await this.page.goto(path);
12
29
  }
13
30
  async click(text) {
14
- await this.scope.getByText(text).click();
31
+ await this.scope.getByText(text, EXACT).click();
15
32
  }
16
33
  async clickLink(text) {
17
- await this.scope.getByRole("link", { name: text }).click();
34
+ await this.scope.getByRole("link", { name: text, ...EXACT }).click();
18
35
  }
19
36
  async clickButton(text) {
20
- await this.scope.getByRole("button", { name: text }).click();
37
+ await this.scope.getByRole("button", { name: text, ...EXACT }).click();
38
+ }
39
+ /** The single label-addressed lookup: every labelled verb goes through it. */
40
+ labelled(label) {
41
+ return this.scope.getByLabel(label, EXACT);
42
+ }
43
+ fieldByLabelOrPlaceholder(label) {
44
+ // .or() lets Playwright auto-wait on whichever appears, so
45
+ // async-rendered labeled fields don't fall through to the
46
+ // placeholder branch. Both branches match exactly — substring
47
+ // matching would collide with labels ("Name" vs placeholder
48
+ // "Nickname") and trip strict mode.
49
+ return this.labelled(label).or(this.scope.getByPlaceholder(label, EXACT));
21
50
  }
22
51
  async fillIn(label, value) {
23
- const byLabel = this.scope.getByLabel(label);
24
- if ((await byLabel.count()) > 0) {
25
- await byLabel.fill(value);
26
- this.lastFormLocator = this.scope.locator("form", { has: byLabel });
27
- return;
28
- }
29
- const byPlaceholder = this.scope.getByPlaceholder(label);
30
- await byPlaceholder.fill(value);
31
- this.lastFormLocator = this.scope.locator("form", {
32
- has: byPlaceholder,
33
- });
52
+ const field = this.fieldByLabelOrPlaceholder(label);
53
+ await field.fill(value);
54
+ this.lastFormLocator = this.scope.locator("form", { has: field });
34
55
  }
35
56
  async selectOption(label, option) {
36
- const select = this.scope.getByLabel(label);
57
+ const select = this.labelled(label);
37
58
  await select.selectOption({ label: option });
38
59
  this.lastFormLocator = this.scope.locator("form", { has: select });
39
60
  }
40
61
  async check(label) {
41
- const checkbox = this.scope.getByLabel(label);
62
+ const checkbox = this.labelled(label);
42
63
  await checkbox.check();
43
64
  this.lastFormLocator = this.scope.locator("form", { has: checkbox });
44
65
  }
45
66
  async uncheck(label) {
46
- const checkbox = this.scope.getByLabel(label);
67
+ const checkbox = this.labelled(label);
47
68
  await checkbox.uncheck();
48
69
  this.lastFormLocator = this.scope.locator("form", { has: checkbox });
49
70
  }
50
71
  async choose(label) {
51
- const radio = this.scope.getByRole("radio", { name: label });
72
+ const radio = this.scope.getByRole("radio", { name: label, ...EXACT });
52
73
  await radio.check();
53
74
  this.lastFormLocator = this.scope.locator("form", { has: radio });
54
75
  }
@@ -57,18 +78,18 @@ export class PlaywrightDriver {
57
78
  throw new Error("submit() called but no form was previously interacted with. " +
58
79
  "Use fillIn(), selectOption(), check(), uncheck(), or choose() first.");
59
80
  }
60
- // First try: find a button by accessible name containing "submit"
81
+ // First try: an explicit type="submit" element the DOM's ground truth
82
+ const submitBtn = this.lastFormLocator.locator('button[type="submit"], input[type="submit"]');
83
+ if ((await submitBtn.count()) > 0) {
84
+ await submitBtn.first().click();
85
+ return;
86
+ }
87
+ // Second try: a button whose accessible name contains "submit"
61
88
  const byRole = this.lastFormLocator.getByRole("button", {
62
89
  name: /submit/i,
63
90
  });
64
91
  if ((await byRole.count()) > 0) {
65
92
  await byRole.first().click();
66
- return;
67
- }
68
- // Second try: find an explicit type="submit" element
69
- const submitBtn = this.lastFormLocator.locator('button[type="submit"], input[type="submit"]');
70
- if ((await submitBtn.count()) > 0) {
71
- await submitBtn.first().click();
72
93
  }
73
94
  else {
74
95
  // Last resort: press Enter on the last form field
@@ -78,6 +99,22 @@ export class PlaywrightDriver {
78
99
  .press("Enter");
79
100
  }
80
101
  }
102
+ async upload(label, path) {
103
+ const input = this.labelled(label);
104
+ await input.setInputFiles(path);
105
+ this.lastFormLocator = this.scope.locator("form", { has: input });
106
+ }
107
+ async dropFile(selector, path) {
108
+ const content = await readFile(path);
109
+ const name = basename(path);
110
+ const dataTransfer = await this.page.evaluateHandle(([fileName, base64]) => {
111
+ const dt = new DataTransfer();
112
+ const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
113
+ dt.items.add(new File([bytes], fileName));
114
+ return dt;
115
+ }, [name, content.toString("base64")]);
116
+ await this.scope.locator(selector).dispatchEvent("drop", { dataTransfer });
117
+ }
81
118
  async assertHas(selector, opts) {
82
119
  let locator = this.scope.locator(selector);
83
120
  if (opts?.text) {
@@ -109,19 +146,45 @@ export class PlaywrightDriver {
109
146
  async refuteText(text) {
110
147
  await expect(this.scope.getByText(text)).toHaveCount(0);
111
148
  }
149
+ async assertValue(label, value) {
150
+ await expect(this.fieldByLabelOrPlaceholder(label)).toHaveValue(value);
151
+ }
152
+ async assertChecked(label) {
153
+ await expect(this.labelled(label)).toBeChecked();
154
+ }
155
+ async refuteChecked(label) {
156
+ await expect(this.labelled(label)).not.toBeChecked();
157
+ }
158
+ async assertSelected(label, optionLabel) {
159
+ const select = this.labelled(label);
160
+ await expect(select.locator("option:checked")).toHaveText(optionLabel);
161
+ }
162
+ async assertOptions(label, optionLabels) {
163
+ const select = this.labelled(label);
164
+ await expect(select.locator("option")).toHaveText(optionLabels);
165
+ }
112
166
  async assertPath(path, opts) {
113
167
  if (opts?.queryParams) {
114
168
  const params = new URLSearchParams(opts.queryParams).toString();
115
169
  await expect(this.page).toHaveURL(`${path}?${params}`);
116
170
  }
117
171
  else {
118
- const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
119
- await expect(this.page).toHaveURL(new RegExp(`^[^?]*${escaped}(\\?.*)?$`));
172
+ await expect
173
+ .poll(() => new URL(this.page.url()).pathname, {
174
+ message: `assertPath('${path}')`,
175
+ })
176
+ .toBe(path);
120
177
  }
121
178
  }
122
179
  async refutePath(path) {
123
- const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
124
- await expect(this.page).not.toHaveURL(new RegExp(`^[^?]*${escaped}(\\?.*)?$`));
180
+ await expect
181
+ .poll(() => new URL(this.page.url()).pathname, {
182
+ message: `refutePath('${path}')`,
183
+ })
184
+ .not.toBe(path);
185
+ }
186
+ async step(fn) {
187
+ await fn({ page: this.page, scope: this.scope });
125
188
  }
126
189
  async within(selector) {
127
190
  const scopedLocator = this.scope.locator(selector);
@@ -134,4 +197,15 @@ export class PlaywrightDriver {
134
197
  fullPage: true,
135
198
  });
136
199
  }
200
+ async wrapStep(name, fn) {
201
+ try {
202
+ // Throws when not running inside @playwright/test — fall back to
203
+ // executing the step directly.
204
+ test.info();
205
+ }
206
+ catch {
207
+ return fn();
208
+ }
209
+ return test.step(name, fn);
210
+ }
137
211
  }
@@ -1,12 +1,13 @@
1
1
  import { type Page } from "@playwright/test";
2
2
  import { Session } from "../session.js";
3
+ import { type PlaywrightStepContext } from "./driver.js";
3
4
  export { Session } from "../session.js";
4
5
  export { StepError } from "../errors.js";
5
- export { PlaywrightDriver } from "./driver.js";
6
+ export { PlaywrightDriver, type PlaywrightStepContext } from "./driver.js";
6
7
  export type { AssertHasOptions, AssertPathOptions, TestDriver, } from "../types.js";
7
- export declare function createSession(page: Page): Session;
8
+ export declare function createSession(page: Page): Session<PlaywrightStepContext>;
8
9
  export declare const test: import("playwright/test").TestType<import("playwright/test").PlaywrightTestArgs & import("playwright/test").PlaywrightTestOptions & {
9
- session: Session;
10
+ session: Session<PlaywrightStepContext>;
10
11
  }, import("playwright/test").PlaywrightWorkerArgs & import("playwright/test").PlaywrightWorkerOptions>;
11
12
  export { expect } from "@playwright/test";
12
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/playwright/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAGxC,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EACV,gBAAgB,EAChB,iBAAiB,EACjB,UAAU,GACX,MAAM,aAAa,CAAC;AAErB,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAEjD;AAED,eAAO,MAAM,IAAI;aAA0B,OAAO;sGAIhD,CAAC;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/playwright/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AACxC,OAAO,EAAoB,KAAK,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAE3E,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,gBAAgB,EAAE,KAAK,qBAAqB,EAAE,MAAM,aAAa,CAAC;AAC3E,YAAY,EACV,gBAAgB,EAChB,iBAAiB,EACjB,UAAU,GACX,MAAM,aAAa,CAAC;AAErB,wBAAgB,aAAa,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,qBAAqB,CAAC,CAExE;AAED,eAAO,MAAM,IAAI;aAA0B,OAAO,CAAC,qBAAqB,CAAC;sGAIvE,CAAC;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC"}
@@ -1,14 +1,45 @@
1
+ import { screen, within as rtlWithin } from "@testing-library/react";
1
2
  import { type UserEvent } from "@testing-library/user-event";
2
3
  import type { AssertHasOptions, TestDriver } from "../types.js";
4
+ /** Context handed to custom step() callbacks in the RTL adapter. */
5
+ export interface RTLStepContext {
6
+ user: UserEvent;
7
+ /** Current scope: `screen`, or a within()-scoped query container. */
8
+ container: ReturnType<typeof rtlWithin> | typeof screen;
9
+ }
3
10
  /**
4
11
  * RTL adapter implementing the subset of TestDriver that applies in JSDOM.
5
12
  * Navigation methods (visit, assertPath, refutePath) are not supported.
13
+ *
14
+ * Everything a host adapter is likely to specialize is `protected`: the
15
+ * label-to-control lookup (`findField`), the scoped-driver factory
16
+ * (`scoped`), and the `user` / `root` / `lastFormElement` state the verbs
17
+ * share. Subclass it rather than reimplementing the DSL when an app's markup
18
+ * needs a different lookup — that is how feather-testing-postgres binds this
19
+ * driver to markup whose labels are wrapper siblings rather than `htmlFor`
20
+ * targets.
6
21
  */
7
- export declare class RTLDriver implements TestDriver {
8
- private user;
9
- private container;
10
- private lastFormElement;
11
- constructor(user?: UserEvent, container?: HTMLElement);
22
+ export declare class RTLDriver implements TestDriver<RTLStepContext> {
23
+ protected user: UserEvent;
24
+ /** The element every query and selector in this driver resolves against. */
25
+ protected root: HTMLElement;
26
+ protected container: ReturnType<typeof rtlWithin>;
27
+ protected lastFormElement: HTMLFormElement | null;
28
+ /** Per-lookup timeout in ms; undefined leaves RTL's own default in place. */
29
+ protected timeout: number | undefined;
30
+ constructor(user?: UserEvent, container?: HTMLElement, timeout?: number);
31
+ protected rootElement(): HTMLElement;
32
+ /** Options forwarded to every async query and waitFor call. */
33
+ protected waitOpts(): {
34
+ timeout?: number;
35
+ };
36
+ /**
37
+ * The single label-addressed lookup: every labelled verb goes through it,
38
+ * so overriding this one method retargets them all.
39
+ */
40
+ protected findField(label: string): Promise<HTMLElement>;
41
+ /** Subclasses override this so within() yields a driver of their own type. */
42
+ protected scoped(element: HTMLElement): TestDriver<RTLStepContext>;
12
43
  visit(): Promise<void>;
13
44
  click(text: string): Promise<void>;
14
45
  clickLink(text: string): Promise<void>;
@@ -19,13 +50,21 @@ export declare class RTLDriver implements TestDriver {
19
50
  uncheck(label: string): Promise<void>;
20
51
  choose(label: string): Promise<void>;
21
52
  submit(): Promise<void>;
53
+ upload(label: string, path: string): Promise<void>;
54
+ dropFile(selector: string, path: string): Promise<void>;
22
55
  assertHas(_selector: string, _opts?: AssertHasOptions): Promise<void>;
23
56
  refuteHas(_selector: string, _opts?: AssertHasOptions): Promise<void>;
24
57
  assertText(text: string): Promise<void>;
25
58
  refuteText(text: string): Promise<void>;
59
+ assertValue(label: string, value: string): Promise<void>;
60
+ assertChecked(label: string): Promise<void>;
61
+ refuteChecked(label: string): Promise<void>;
62
+ assertSelected(label: string, optionLabel: string): Promise<void>;
63
+ assertOptions(label: string, optionLabels: string[]): Promise<void>;
26
64
  assertPath(): Promise<void>;
27
65
  refutePath(): Promise<void>;
28
- within(selector: string): Promise<TestDriver>;
66
+ step(fn: (context: RTLStepContext) => Promise<unknown>): Promise<void>;
67
+ within(selector: string): Promise<TestDriver<RTLStepContext>>;
29
68
  debug(): Promise<void>;
30
69
  }
31
70
  //# sourceMappingURL=driver.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../../src/rtl/driver.ts"],"names":[],"mappings":"AACA,OAAkB,EAAE,KAAK,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEhE;;;GAGG;AACH,qBAAa,SAAU,YAAW,UAAU;IAC1C,OAAO,CAAC,IAAI,CAAY;IACxB,OAAO,CAAC,SAAS,CAA+C;IAChE,OAAO,CAAC,eAAe,CAAgC;gBAE3C,IAAI,CAAC,EAAE,SAAS,EAAE,SAAS,CAAC,EAAE,WAAW;IAK/C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAMtB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKlC,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKtC,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAKxC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYnD,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAc1D,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQnC,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQrC,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAMpC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAoBvB,SAAS,CACb,SAAS,EAAE,MAAM,EACjB,KAAK,CAAC,EAAE,gBAAgB,GACvB,OAAO,CAAC,IAAI,CAAC;IAMV,SAAS,CACb,SAAS,EAAE,MAAM,EACjB,KAAK,CAAC,EAAE,gBAAgB,GACvB,OAAO,CAAC,IAAI,CAAC;IAMV,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIvC,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAWvC,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAM3B,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAM3B,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC;IAW7C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B"}
1
+ {"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../../src/rtl/driver.ts"],"names":[],"mappings":"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"}
@@ -1,46 +1,78 @@
1
- import { screen, waitFor, within as rtlWithin } from "@testing-library/react";
1
+ import { fireEvent, screen, waitFor, within as rtlWithin, } from "@testing-library/react";
2
2
  import userEvent from "@testing-library/user-event";
3
3
  /**
4
4
  * RTL adapter implementing the subset of TestDriver that applies in JSDOM.
5
5
  * Navigation methods (visit, assertPath, refutePath) are not supported.
6
+ *
7
+ * Everything a host adapter is likely to specialize is `protected`: the
8
+ * label-to-control lookup (`findField`), the scoped-driver factory
9
+ * (`scoped`), and the `user` / `root` / `lastFormElement` state the verbs
10
+ * share. Subclass it rather than reimplementing the DSL when an app's markup
11
+ * needs a different lookup — that is how feather-testing-postgres binds this
12
+ * driver to markup whose labels are wrapper siblings rather than `htmlFor`
13
+ * targets.
6
14
  */
7
15
  export class RTLDriver {
8
16
  user;
17
+ /** The element every query and selector in this driver resolves against. */
18
+ root;
9
19
  container;
10
20
  lastFormElement = null;
11
- constructor(user, container) {
21
+ /** Per-lookup timeout in ms; undefined leaves RTL's own default in place. */
22
+ timeout;
23
+ constructor(user, container, timeout) {
12
24
  this.user = user ?? userEvent.setup();
13
- this.container = container ? rtlWithin(container) : screen;
25
+ this.root = container ?? document.body;
26
+ this.container = rtlWithin(this.root);
27
+ this.timeout = timeout;
28
+ }
29
+ rootElement() {
30
+ return this.root;
31
+ }
32
+ /** Options forwarded to every async query and waitFor call. */
33
+ waitOpts() {
34
+ return this.timeout === undefined ? {} : { timeout: this.timeout };
35
+ }
36
+ /**
37
+ * The single label-addressed lookup: every labelled verb goes through it,
38
+ * so overriding this one method retargets them all.
39
+ */
40
+ async findField(label) {
41
+ try {
42
+ return await this.container.findByLabelText(label, undefined, this.waitOpts());
43
+ }
44
+ catch {
45
+ return await this.container.findByPlaceholderText(label, undefined, this.waitOpts());
46
+ }
47
+ }
48
+ /** Subclasses override this so within() yields a driver of their own type. */
49
+ scoped(element) {
50
+ return new RTLDriver(this.user, element, this.timeout);
14
51
  }
15
52
  async visit() {
16
53
  throw new Error("visit() is not available in the RTL adapter. Render the desired component directly.");
17
54
  }
18
55
  async click(text) {
19
- const element = await this.container.findByText(text);
56
+ const element = await this.container.findByText(text, undefined, this.waitOpts());
20
57
  await this.user.click(element);
21
58
  }
22
59
  async clickLink(text) {
23
- const link = await this.container.findByRole("link", { name: text });
60
+ const link = await this.container.findByRole("link", { name: text }, this.waitOpts());
24
61
  await this.user.click(link);
25
62
  }
26
63
  async clickButton(text) {
27
- const button = await this.container.findByRole("button", { name: text });
64
+ const button = await this.container.findByRole("button", { name: text }, this.waitOpts());
28
65
  await this.user.click(button);
29
66
  }
30
67
  async fillIn(label, value) {
31
- let input;
32
- try {
33
- input = await this.container.findByLabelText(label);
34
- }
35
- catch {
36
- input = await this.container.findByPlaceholderText(label);
37
- }
68
+ const input = await this.findField(label);
38
69
  await this.user.clear(input);
39
- await this.user.type(input, value);
70
+ if (value)
71
+ await this.user.type(input, value);
40
72
  this.lastFormElement = input.closest("form");
41
73
  }
42
74
  async selectOption(label, option) {
43
- const select = await this.container.findByLabelText(label);
75
+ const select = await this.findField(label);
44
76
  const optionEl = Array.from(select.querySelectorAll("option")).find((o) => o.textContent?.trim() === option);
45
77
  if (!optionEl) {
46
78
  throw new Error(`selectOption('${label}', '${option}'): no <option> with text '${option}' found.`);
@@ -49,21 +81,21 @@ export class RTLDriver {
49
81
  this.lastFormElement = select.closest("form");
50
82
  }
51
83
  async check(label) {
52
- const checkbox = await this.container.findByLabelText(label);
84
+ const checkbox = await this.findField(label);
53
85
  if (!checkbox.checked) {
54
86
  await this.user.click(checkbox);
55
87
  }
56
88
  this.lastFormElement = checkbox.closest("form");
57
89
  }
58
90
  async uncheck(label) {
59
- const checkbox = await this.container.findByLabelText(label);
91
+ const checkbox = await this.findField(label);
60
92
  if (checkbox.checked) {
61
93
  await this.user.click(checkbox);
62
94
  }
63
95
  this.lastFormElement = checkbox.closest("form");
64
96
  }
65
97
  async choose(label) {
66
- const radio = await this.container.findByRole("radio", { name: label });
98
+ const radio = await this.container.findByRole("radio", { name: label }, this.waitOpts());
67
99
  await this.user.click(radio);
68
100
  this.lastFormElement = radio.closest("form");
69
101
  }
@@ -71,10 +103,12 @@ export class RTLDriver {
71
103
  if (!this.lastFormElement) {
72
104
  throw new Error("submit() called but no form was previously interacted with.");
73
105
  }
74
- const submitBtn = rtlWithin(this.lastFormElement).queryByRole("button", {
75
- name: /submit/i,
76
- }) ??
77
- this.lastFormElement.querySelector('button[type="submit"], input[type="submit"]');
106
+ // Prefer an explicit type="submit" element (the DOM's ground truth),
107
+ // then a button whose accessible name contains "submit".
108
+ const submitBtn = this.lastFormElement.querySelector('button[type="submit"], input[type="submit"]') ??
109
+ rtlWithin(this.lastFormElement).queryByRole("button", {
110
+ name: /submit/i,
111
+ });
78
112
  if (submitBtn) {
79
113
  await this.user.click(submitBtn);
80
114
  }
@@ -82,6 +116,29 @@ export class RTLDriver {
82
116
  this.lastFormElement.requestSubmit();
83
117
  }
84
118
  }
119
+ async upload(label, path) {
120
+ const input = await this.findField(label);
121
+ // JSDOM has no filesystem access; synthesize a File from the basename.
122
+ const name = path.split(/[\\/]/).pop() ?? path;
123
+ const file = new File([""], name);
124
+ await this.user.upload(input, file);
125
+ this.lastFormElement = input.closest("form");
126
+ }
127
+ async dropFile(selector, path) {
128
+ const target = this.rootElement().querySelector(selector);
129
+ if (!target) {
130
+ throw new Error(`dropFile('${selector}'): element not found`);
131
+ }
132
+ const name = path.split(/[\\/]/).pop() ?? path;
133
+ const file = new File([""], name);
134
+ fireEvent.drop(target, {
135
+ dataTransfer: {
136
+ files: [file],
137
+ items: [{ kind: "file", type: file.type, getAsFile: () => file }],
138
+ types: ["Files"],
139
+ },
140
+ });
141
+ }
85
142
  async assertHas(_selector, _opts) {
86
143
  throw new Error("assertHas() with CSS selectors is not recommended in RTL. Use assertText() instead.");
87
144
  }
@@ -89,7 +146,7 @@ export class RTLDriver {
89
146
  throw new Error("refuteHas() with CSS selectors is not recommended in RTL. Use refuteText() instead.");
90
147
  }
91
148
  async assertText(text) {
92
- await this.container.findByText(text);
149
+ await this.container.findByText(text, undefined, this.waitOpts());
93
150
  }
94
151
  async refuteText(text) {
95
152
  await waitFor(() => {
@@ -97,7 +154,52 @@ export class RTLDriver {
97
154
  if (el) {
98
155
  throw new Error(`Expected NOT to find text '${text}', but it was present.`);
99
156
  }
100
- });
157
+ }, this.waitOpts());
158
+ }
159
+ async assertValue(label, value) {
160
+ const field = await this.findField(label);
161
+ await waitFor(() => {
162
+ const actual = field.value;
163
+ if (actual !== value) {
164
+ throw new Error(`assertValue('${label}', '${value}'): expected value '${value}', but found '${actual}'.`);
165
+ }
166
+ }, this.waitOpts());
167
+ }
168
+ async assertChecked(label) {
169
+ const checkbox = await this.findField(label);
170
+ await waitFor(() => {
171
+ if (!checkbox.checked) {
172
+ throw new Error(`assertChecked('${label}'): expected checkbox to be checked, but it was not.`);
173
+ }
174
+ }, this.waitOpts());
175
+ }
176
+ async refuteChecked(label) {
177
+ const checkbox = await this.findField(label);
178
+ await waitFor(() => {
179
+ if (checkbox.checked) {
180
+ throw new Error(`refuteChecked('${label}'): expected checkbox NOT to be checked, but it was.`);
181
+ }
182
+ }, this.waitOpts());
183
+ }
184
+ async assertSelected(label, optionLabel) {
185
+ const select = (await this.findField(label));
186
+ await waitFor(() => {
187
+ const selected = select.selectedOptions[0]?.textContent?.trim();
188
+ if (selected !== optionLabel) {
189
+ throw new Error(`assertSelected('${label}', '${optionLabel}'): expected selected option '${optionLabel}', but found '${selected ?? "(none)"}'.`);
190
+ }
191
+ }, this.waitOpts());
192
+ }
193
+ async assertOptions(label, optionLabels) {
194
+ const select = (await this.findField(label));
195
+ await waitFor(() => {
196
+ const actual = Array.from(select.querySelectorAll("option")).map((o) => o.textContent?.trim() ?? "");
197
+ const matches = actual.length === optionLabels.length &&
198
+ actual.every((text, i) => text === optionLabels[i]);
199
+ if (!matches) {
200
+ throw new Error(`assertOptions('${label}'): expected options [${optionLabels.join(", ")}], but found [${actual.join(", ")}].`);
201
+ }
202
+ }, this.waitOpts());
101
203
  }
102
204
  async assertPath() {
103
205
  throw new Error("assertPath() is not available in the RTL adapter (no real URL in JSDOM).");
@@ -105,17 +207,16 @@ export class RTLDriver {
105
207
  async refutePath() {
106
208
  throw new Error("refutePath() is not available in the RTL adapter (no real URL in JSDOM).");
107
209
  }
210
+ async step(fn) {
211
+ await fn({ user: this.user, container: this.container });
212
+ }
108
213
  async within(selector) {
109
- const root = this.container === screen
110
- ? document.body
111
- : (this.container
112
- .container ?? document.body);
113
- const element = root.querySelector(selector);
214
+ const element = this.rootElement().querySelector(selector);
114
215
  if (!element)
115
216
  throw new Error(`within('${selector}'): element not found`);
116
- return new RTLDriver(this.user, element);
217
+ return this.scoped(element);
117
218
  }
118
219
  async debug() {
119
- screen.debug();
220
+ screen.debug(this.rootElement());
120
221
  }
121
222
  }
@@ -1,6 +1,7 @@
1
1
  import { Session } from "../session.js";
2
+ import { type RTLStepContext } from "./driver.js";
2
3
  export { Session } from "../session.js";
3
4
  export { StepError } from "../errors.js";
4
- export { RTLDriver } from "./driver.js";
5
- export declare function createSession(): Session;
5
+ export { RTLDriver, type RTLStepContext } from "./driver.js";
6
+ export declare function createSession(): Session<RTLStepContext>;
6
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/rtl/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AAGxC,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AAExC,wBAAgB,aAAa,IAAI,OAAO,CAEvC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/rtl/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AACxC,OAAO,EAAa,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7D,OAAO,EAAE,OAAO,EAAE,MAAM,eAAe,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzC,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,aAAa,CAAC;AAE7D,wBAAgB,aAAa,IAAI,OAAO,CAAC,cAAc,CAAC,CAEvD"}
package/dist/session.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import type { AssertHasOptions, AssertPathOptions, TestDriver } from "./types.js";
2
- export declare class Session implements PromiseLike<void> {
2
+ export declare class Session<TContext = unknown> implements PromiseLike<void> {
3
3
  private driver;
4
4
  private steps;
5
+ private executedSteps;
5
6
  private stepIndex;
6
- constructor(driver: TestDriver);
7
+ constructor(driver: TestDriver<TContext>);
7
8
  then<TResult1 = void, TResult2 = never>(onfulfilled?: ((value: void) => TResult1 | PromiseLike<TResult1>) | null, onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null): Promise<TResult1 | TResult2>;
8
9
  private executeSteps;
9
10
  private enqueue;
@@ -17,13 +18,32 @@ export declare class Session implements PromiseLike<void> {
17
18
  uncheck(label: string): this;
18
19
  choose(label: string): this;
19
20
  submit(): this;
21
+ upload(label: string, path: string): this;
22
+ dropFile(selector: string, path: string): this;
20
23
  assertText(text: string): this;
21
24
  refuteText(text: string): this;
25
+ assertValue(label: string, value: string): this;
26
+ assertChecked(label: string): this;
27
+ refuteChecked(label: string): this;
28
+ assertSelected(label: string, optionLabel: string): this;
29
+ assertOptions(label: string, optionLabels: string[]): this;
22
30
  assertHas(selector: string, opts?: AssertHasOptions): this;
23
31
  refuteHas(selector: string, opts?: AssertHasOptions): this;
24
32
  assertPath(path: string, opts?: AssertPathOptions): this;
25
33
  refutePath(path: string): this;
26
- within(selector: string, fn: (scoped: Session) => Session): this;
34
+ /**
35
+ * Queue a named custom step. `fn` receives the adapter's context
36
+ * ({ page, scope } for Playwright, { user, container } for RTL), so a
37
+ * missing verb never forces abandoning the chain. The name shows up in
38
+ * StepError output like any built-in step.
39
+ */
40
+ step(name: string, fn: (context: TContext) => Promise<unknown>): this;
41
+ /**
42
+ * `fn` must either return the scoped session — so its queued steps run — or
43
+ * a promise it already awaited. Returning anything else would silently drop
44
+ * the scoped chain, which is why the callback's return type is not `unknown`.
45
+ */
46
+ within(selector: string, fn: (scoped: Session<TContext>) => Session<TContext> | PromiseLike<unknown>): this;
27
47
  debug(): this;
28
48
  }
29
49
  //# sourceMappingURL=session.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAChB,iBAAiB,EAEjB,UAAU,EACX,MAAM,YAAY,CAAC;AAGpB,qBAAa,OAAQ,YAAW,WAAW,CAAC,IAAI,CAAC;IAInC,OAAO,CAAC,MAAM;IAH1B,OAAO,CAAC,KAAK,CAAoB;IACjC,OAAO,CAAC,SAAS,CAAK;gBAEF,MAAM,EAAE,UAAU;IAEtC,IAAI,CAAC,QAAQ,GAAG,IAAI,EAAE,QAAQ,GAAG,KAAK,EACpC,WAAW,CAAC,EACR,CAAC,CAAC,KAAK,EAAE,IAAI,KAAK,QAAQ,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC,GACnD,IAAI,EACR,UAAU,CAAC,EACP,CAAC,CAAC,MAAM,EAAE,OAAO,KAAK,QAAQ,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC,GACvD,IAAI,GACP,OAAO,CAAC,QAAQ,GAAG,QAAQ,CAAC;YAIjB,YAAY;IAa1B,OAAO,CAAC,OAAO;IAOf,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAMzB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIzB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM7B,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM/B,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAM1C,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAMjD,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAI1B,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAM5B,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAM3B,MAAM,IAAI,IAAI;IAMd,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM9B,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM9B,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,IAAI;IAO1D,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,IAAI;IAO1D,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,iBAAiB,GAAG,IAAI;IAMxD,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAQ9B,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,OAAO,GAAG,IAAI;IAUhE,KAAK,IAAI,IAAI;CAGd"}
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,gBAAgB,EAChB,iBAAiB,EAEjB,UAAU,EACX,MAAM,YAAY,CAAC;AAGpB,qBAAa,OAAO,CAAC,QAAQ,GAAG,OAAO,CAAE,YAAW,WAAW,CAAC,IAAI,CAAC;IAKvD,OAAO,CAAC,MAAM;IAJ1B,OAAO,CAAC,KAAK,CAAoB;IACjC,OAAO,CAAC,aAAa,CAAoB;IACzC,OAAO,CAAC,SAAS,CAAK;gBAEF,MAAM,EAAE,UAAU,CAAC,QAAQ,CAAC;IAEhD,IAAI,CAAC,QAAQ,GAAG,IAAI,EAAE,QAAQ,GAAG,KAAK,EACpC,WAAW,CAAC,EACR,CAAC,CAAC,KAAK,EAAE,IAAI,KAAK,QAAQ,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC,GACnD,IAAI,EACR,UAAU,CAAC,EACP,CAAC,CAAC,MAAM,EAAE,OAAO,KAAK,QAAQ,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAC,GACvD,IAAI,GACP,OAAO,CAAC,QAAQ,GAAG,QAAQ,CAAC;YAIjB,YAAY;IAuB1B,OAAO,CAAC,OAAO;IAOf,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAMzB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAIzB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM7B,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM/B,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAM1C,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI;IAMjD,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAI1B,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAM5B,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAM3B,MAAM,IAAI,IAAI;IAId,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAMzC,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI;IAQ9C,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM9B,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAM9B,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI;IAM/C,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAMlC,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI;IAMlC,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,IAAI;IAOxD,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,IAAI;IAO1D,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,IAAI;IAO1D,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,IAAI;IAO1D,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,iBAAiB,GAAG,IAAI;IAMxD,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI;IAQ9B;;;;;OAKG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,CAAC,OAAO,EAAE,QAAQ,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,IAAI;IAMrE;;;;OAIG;IACH,MAAM,CACJ,QAAQ,EAAE,MAAM,EAChB,EAAE,EAAE,CAAC,MAAM,EAAE,OAAO,CAAC,QAAQ,CAAC,KAAK,OAAO,CAAC,QAAQ,CAAC,GAAG,WAAW,CAAC,OAAO,CAAC,GAC1E,IAAI;IAUP,KAAK,IAAI,IAAI;CAGd"}
package/dist/session.js CHANGED
@@ -2,6 +2,7 @@ import { StepError } from "./errors.js";
2
2
  export class Session {
3
3
  driver;
4
4
  steps = [];
5
+ executedSteps = [];
5
6
  stepIndex = 0;
6
7
  constructor(driver) {
7
8
  this.driver = driver;
@@ -12,12 +13,16 @@ export class Session {
12
13
  async executeSteps() {
13
14
  const steps = [...this.steps];
14
15
  this.steps = [];
15
- for (const step of steps) {
16
+ const wrap = this.driver.wrapStep?.bind(this.driver) ?? ((_name, fn) => fn());
17
+ for (const [i, step] of steps.entries()) {
16
18
  try {
17
- await step.action();
19
+ await wrap(step.name, step.action);
20
+ this.executedSteps.push(step);
18
21
  }
19
22
  catch (error) {
20
- throw new StepError(step, steps, error);
23
+ // Include steps executed in earlier chains so the full walk is
24
+ // visible even when the user broke the chain across multiple awaits.
25
+ throw new StepError(step, [...this.executedSteps, ...steps.slice(i)], error);
21
26
  }
22
27
  }
23
28
  }
@@ -57,6 +62,12 @@ export class Session {
57
62
  submit() {
58
63
  return this.enqueue("submit()", () => this.driver.submit());
59
64
  }
65
+ upload(label, path) {
66
+ return this.enqueue(`upload('${label}', '${path}')`, () => this.driver.upload(label, path));
67
+ }
68
+ dropFile(selector, path) {
69
+ return this.enqueue(`dropFile('${selector}', '${path}')`, () => this.driver.dropFile(selector, path));
70
+ }
60
71
  // --- Assertions ---
61
72
  assertText(text) {
62
73
  return this.enqueue(`assertText('${text}')`, () => this.driver.assertText(text));
@@ -64,6 +75,22 @@ export class Session {
64
75
  refuteText(text) {
65
76
  return this.enqueue(`refuteText('${text}')`, () => this.driver.refuteText(text));
66
77
  }
78
+ assertValue(label, value) {
79
+ return this.enqueue(`assertValue('${label}', '${value}')`, () => this.driver.assertValue(label, value));
80
+ }
81
+ assertChecked(label) {
82
+ return this.enqueue(`assertChecked('${label}')`, () => this.driver.assertChecked(label));
83
+ }
84
+ refuteChecked(label) {
85
+ return this.enqueue(`refuteChecked('${label}')`, () => this.driver.refuteChecked(label));
86
+ }
87
+ assertSelected(label, optionLabel) {
88
+ return this.enqueue(`assertSelected('${label}', '${optionLabel}')`, () => this.driver.assertSelected(label, optionLabel));
89
+ }
90
+ assertOptions(label, optionLabels) {
91
+ const list = optionLabels.map((l) => `'${l}'`).join(", ");
92
+ return this.enqueue(`assertOptions('${label}', [${list}])`, () => this.driver.assertOptions(label, optionLabels));
93
+ }
67
94
  assertHas(selector, opts) {
68
95
  const desc = opts?.text
69
96
  ? `assertHas('${selector}', text: '${opts.text}')`
@@ -82,7 +109,22 @@ export class Session {
82
109
  refutePath(path) {
83
110
  return this.enqueue(`refutePath('${path}')`, () => this.driver.refutePath(path));
84
111
  }
112
+ // --- Escape hatch ---
113
+ /**
114
+ * Queue a named custom step. `fn` receives the adapter's context
115
+ * ({ page, scope } for Playwright, { user, container } for RTL), so a
116
+ * missing verb never forces abandoning the chain. The name shows up in
117
+ * StepError output like any built-in step.
118
+ */
119
+ step(name, fn) {
120
+ return this.enqueue(`step('${name}')`, () => this.driver.step(fn));
121
+ }
85
122
  // --- Scoping ---
123
+ /**
124
+ * `fn` must either return the scoped session — so its queued steps run — or
125
+ * a promise it already awaited. Returning anything else would silently drop
126
+ * the scoped chain, which is why the callback's return type is not `unknown`.
127
+ */
86
128
  within(selector, fn) {
87
129
  return this.enqueue(`within('${selector}')`, async () => {
88
130
  const scopedDriver = await this.driver.within(selector);
package/dist/types.d.ts CHANGED
@@ -12,7 +12,11 @@ export interface QueuedStep {
12
12
  action: () => Promise<void>;
13
13
  index: number;
14
14
  }
15
- export interface TestDriver {
15
+ /**
16
+ * TContext is the adapter-specific context handed to custom step() callbacks
17
+ * (e.g. { page, scope } for Playwright, { user, container } for RTL).
18
+ */
19
+ export interface TestDriver<TContext = unknown> {
16
20
  visit(path: string): Promise<void>;
17
21
  click(text: string): Promise<void>;
18
22
  clickLink(text: string): Promise<void>;
@@ -23,13 +27,26 @@ export interface TestDriver {
23
27
  uncheck(label: string): Promise<void>;
24
28
  choose(label: string): Promise<void>;
25
29
  submit(): Promise<void>;
30
+ upload(label: string, path: string): Promise<void>;
31
+ dropFile(selector: string, path: string): Promise<void>;
26
32
  assertHas(selector: string, opts?: AssertHasOptions): Promise<void>;
27
33
  refuteHas(selector: string, opts?: AssertHasOptions): Promise<void>;
28
34
  assertText(text: string): Promise<void>;
29
35
  refuteText(text: string): Promise<void>;
36
+ assertValue(label: string, value: string): Promise<void>;
37
+ assertChecked(label: string): Promise<void>;
38
+ refuteChecked(label: string): Promise<void>;
39
+ assertSelected(label: string, optionLabel: string): Promise<void>;
40
+ assertOptions(label: string, optionLabels: string[]): Promise<void>;
30
41
  assertPath(path: string, opts?: AssertPathOptions): Promise<void>;
31
42
  refutePath(path: string): Promise<void>;
32
- within(selector: string): Promise<TestDriver>;
43
+ step(fn: (context: TContext) => Promise<unknown>): Promise<void>;
44
+ within(selector: string): Promise<TestDriver<TContext>>;
33
45
  debug(): Promise<void>;
46
+ /**
47
+ * Optional hook: wrap a queued step's execution (e.g. in Playwright's
48
+ * test.step()) so chains appear in trace viewers and reporters.
49
+ */
50
+ wrapStep?(name: string, fn: () => Promise<void>): Promise<void>;
34
51
  }
35
52
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,iBAAiB;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CACtC;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5B,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnC,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACvC,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3D,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpC,OAAO,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACtC,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACrC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACxB,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE,SAAS,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAAC;IAC9C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feather-testing-core",
3
- "version": "0.1.2",
3
+ "version": "0.3.0",
4
4
  "description": "Phoenix Test-inspired fluent testing DSL for Playwright and React Testing Library",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,6 +64,7 @@
64
64
  "@testing-library/jest-dom": "^6.9.1",
65
65
  "@testing-library/react": "^16.3.0",
66
66
  "@testing-library/user-event": "^14.6.0",
67
+ "@types/node": "^26.1.2",
67
68
  "@types/react": "^19.2.14",
68
69
  "@types/react-dom": "^19.2.3",
69
70
  "jsdom": "^28.1.0",