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 +114 -2
- package/dist/playwright/driver.d.ts +20 -2
- package/dist/playwright/driver.d.ts.map +1 -1
- package/dist/playwright/driver.js +104 -30
- package/dist/playwright/index.d.ts +4 -3
- package/dist/playwright/index.d.ts.map +1 -1
- package/dist/rtl/driver.d.ts +45 -6
- package/dist/rtl/driver.d.ts.map +1 -1
- package/dist/rtl/driver.js +132 -31
- package/dist/rtl/index.d.ts +3 -2
- package/dist/rtl/index.d.ts.map +1 -1
- package/dist/session.d.ts +23 -3
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +45 -3
- package/dist/types.d.ts +19 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -1
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
|
|
178
|
-
2. **By
|
|
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
|
-
|
|
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
|
-
|
|
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":"
|
|
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 {
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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;
|
|
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"}
|
package/dist/rtl/driver.d.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
package/dist/rtl/driver.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../../src/rtl/driver.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../../src/rtl/driver.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,MAAM,EAEN,MAAM,IAAI,SAAS,EACpB,MAAM,wBAAwB,CAAC;AAChC,OAAkB,EAAE,KAAK,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEhE,oEAAoE;AACpE,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,SAAS,CAAC;IAChB,qEAAqE;IACrE,SAAS,EAAE,UAAU,CAAC,OAAO,SAAS,CAAC,GAAG,OAAO,MAAM,CAAC;CACzD;AAOD;;;;;;;;;;;GAWG;AACH,qBAAa,SAAU,YAAW,UAAU,CAAC,cAAc,CAAC;IAC1D,SAAS,CAAC,IAAI,EAAE,SAAS,CAAC;IAC1B,4EAA4E;IAC5E,SAAS,CAAC,IAAI,EAAE,WAAW,CAAC;IAC5B,SAAS,CAAC,SAAS,EAAE,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;IAClD,SAAS,CAAC,eAAe,EAAE,eAAe,GAAG,IAAI,CAAQ;IACzD,6EAA6E;IAC7E,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,SAAS,CAAC;gBAE1B,IAAI,CAAC,EAAE,SAAS,EAAE,SAAS,CAAC,EAAE,WAAW,EAAE,OAAO,CAAC,EAAE,MAAM;IAOvE,SAAS,CAAC,WAAW,IAAI,WAAW;IAIpC,+DAA+D;IAC/D,SAAS,CAAC,QAAQ,IAAI;QAAE,OAAO,CAAC,EAAE,MAAM,CAAA;KAAE;IAI1C;;;OAGG;cACa,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IAgB9D,8EAA8E;IAC9E,SAAS,CAAC,MAAM,CAAC,OAAO,EAAE,WAAW,GAAG,UAAU,CAAC,cAAc,CAAC;IAI5D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAMtB,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IASlC,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAStC,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IASxC,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOnD,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;IAUpC,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC;IAsBvB,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IASlD,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAgBvD,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,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYxD,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW3C,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAW3C,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYjE,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBnE,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAM3B,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAM3B,IAAI,CAAC,EAAE,EAAE,CAAC,OAAO,EAAE,cAAc,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAItE,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,cAAc,CAAC,CAAC;IAM7D,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAG7B"}
|
package/dist/rtl/driver.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.lastFormElement.
|
|
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
|
|
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
|
|
217
|
+
return this.scoped(element);
|
|
117
218
|
}
|
|
118
219
|
async debug() {
|
|
119
|
-
screen.debug();
|
|
220
|
+
screen.debug(this.rootElement());
|
|
120
221
|
}
|
|
121
222
|
}
|
package/dist/rtl/index.d.ts
CHANGED
|
@@ -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
|
package/dist/rtl/index.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
package/dist/session.d.ts.map
CHANGED
|
@@ -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,
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/types.d.ts.map
CHANGED
|
@@ -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;
|
|
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;;;GAGG;AACH,MAAM,WAAW,UAAU,CAAC,QAAQ,GAAG,OAAO;IAC5C,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,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,QAAQ,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACxD,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,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACzD,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAClE,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpE,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,IAAI,CAAC,EAAE,EAAE,CAAC,OAAO,EAAE,QAAQ,KAAK,OAAO,CAAC,OAAO,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACjE,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC;IACxD,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB;;;OAGG;IACH,QAAQ,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACjE"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "feather-testing-core",
|
|
3
|
-
"version": "0.
|
|
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",
|