feather-testing-core 0.1.0 → 0.1.1

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
@@ -23,6 +23,8 @@ Inspired by [Phoenix Test](https://hexdocs.pm/phoenix_test/PhoenixTest.html) —
23
23
  npm install feather-testing-core
24
24
  ```
25
25
 
26
+ > **Note:** This package is ESM-only (`"type": "module"`). It works with modern bundlers and test runners out of the box. If your project uses CommonJS `require()`, you'll need to update your config to support ESM imports.
27
+
26
28
  All test framework dependencies are optional peers — install only what you use:
27
29
 
28
30
  ```bash
@@ -111,15 +113,65 @@ Every method returns `this` for chaining. A single `await` at the start of the c
111
113
  | `selectOption(label, option)` | Select dropdown option by label |
112
114
  | `check(label)` / `uncheck(label)` | Toggle checkbox by label |
113
115
  | `choose(label)` | Select radio button by label |
114
- | `submit()` | Submit the most recently interacted form |
116
+ | `submit()` | Submit the most recently interacted form (see below) |
117
+
118
+ #### How `submit()` finds the submit button
119
+
120
+ `submit()` tracks the `<form>` element from the last `fillIn`, `selectOption`, `check`, `uncheck`, or `choose` call, then uses this strategy:
121
+
122
+ 1. **By accessible name** — looks for a `<button>` whose name contains "submit" (case-insensitive)
123
+ 2. **By `type="submit"`** — looks for `<button type="submit">` or `<input type="submit">`
124
+ 3. **Enter key fallback** — presses Enter on the last form field
125
+
126
+ If no form was previously interacted with, `submit()` throws an error.
115
127
 
116
128
  ### Assertions
117
129
 
118
130
  | Method | Description |
119
131
  |--------|-------------|
120
132
  | `assertText(text)` / `refuteText(text)` | Assert text is visible / not visible |
121
- | `assertHas(selector, opts?)` / `refuteHas(...)` | Assert element exists (Playwright only) |
122
- | `assertPath(path, opts?)` / `refutePath(path)` | Assert URL path (Playwright only) |
133
+ | `assertHas(selector, opts?)` / `refuteHas(...)` | Assert element exists (Playwright only, see options below) |
134
+ | `assertPath(path, opts?)` / `refutePath(path)` | Assert URL path (Playwright only, see options below) |
135
+
136
+ #### `assertHas` / `refuteHas` options
137
+
138
+ | Option | Type | Description |
139
+ |--------|------|-------------|
140
+ | `text` | `string` | Filter elements to those containing this text |
141
+ | `count` | `number` | Assert exact number of matching elements |
142
+ | `exact` | `boolean` | When `true`, `text` matches as an exact substring. When `false` (default), matches as a regex |
143
+ | `timeout` | `number` | Custom timeout in milliseconds (overrides Playwright default) |
144
+
145
+ ```ts
146
+ // Assert at least one .card element is visible
147
+ await session.assertHas(".card");
148
+
149
+ // Assert a .card containing specific text
150
+ await session.assertHas(".card", { text: "Overdue" });
151
+
152
+ // Assert exact count
153
+ await session.assertHas("li.todo-item", { count: 3 });
154
+
155
+ // Assert with custom timeout
156
+ await session.assertHas(".loaded", { timeout: 10000 });
157
+
158
+ // Refute: assert no matching elements exist
159
+ await session.refuteHas(".spinner");
160
+ await session.refuteHas(".card", { text: "Deleted Item" });
161
+ ```
162
+
163
+ #### `assertPath` / `refutePath` options
164
+
165
+ ```ts
166
+ // Assert path (ignores query params)
167
+ await session.assertPath("/projects");
168
+
169
+ // Assert path with specific query params
170
+ await session.assertPath("/search", { queryParams: { q: "hello", page: "1" } });
171
+
172
+ // Refute: assert you are NOT on this path
173
+ await session.refutePath("/login");
174
+ ```
123
175
 
124
176
  ### Scoping
125
177
 
@@ -127,11 +179,21 @@ Every method returns `this` for chaining. A single `await` at the start of the c
127
179
  |--------|-------------|
128
180
  | `within(selector, fn)` | Scope actions to a container element |
129
181
 
182
+ ```ts
183
+ // All actions inside the callback are scoped to the matched element
184
+ await session
185
+ .visit("/dashboard")
186
+ .within(".sidebar", (s) =>
187
+ s.clickLink("Settings").assertText("Preferences")
188
+ )
189
+ .assertText("Dashboard"); // back to full-page scope after within()
190
+ ```
191
+
130
192
  ### Debug
131
193
 
132
194
  | Method | Description |
133
195
  |--------|-------------|
134
- | `debug()` | Screenshot (Playwright) or log DOM (RTL) |
196
+ | `debug()` | Playwright: saves a full-page screenshot to `debug-{timestamp}.png` in the CWD. RTL: calls `screen.debug()` to log the current DOM to the console. |
135
197
 
136
198
  ## How It Works
137
199
 
@@ -210,7 +272,7 @@ The RTL adapter runs in JSDOM, which has no real browser. These methods are not
210
272
  ## Exports
211
273
 
212
274
  ```ts
213
- // Core types (for building custom drivers)
275
+ // Core (Session class + types)
214
276
  import { Session, StepError, type TestDriver } from "feather-testing-core";
215
277
 
216
278
  // Playwright adapter
@@ -220,6 +282,13 @@ import { test, createSession, expect } from "feather-testing-core/playwright";
220
282
  import { createSession } from "feather-testing-core/rtl";
221
283
  ```
222
284
 
285
+ Both adapter subpaths also re-export `Session` and `StepError`, so you can import everything from a single path:
286
+
287
+ ```ts
288
+ import { test, Session, StepError } from "feather-testing-core/playwright";
289
+ import { createSession, Session, StepError } from "feather-testing-core/rtl";
290
+ ```
291
+
223
292
  ## License
224
293
 
225
294
  MIT
@@ -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;IAoBvB,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;IAQnE,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":"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"}
@@ -57,11 +57,21 @@ export class PlaywrightDriver {
57
57
  throw new Error("submit() called but no form was previously interacted with. " +
58
58
  "Use fillIn(), selectOption(), check(), uncheck(), or choose() first.");
59
59
  }
60
+ // First try: find a button by accessible name containing "submit"
61
+ const byRole = this.lastFormLocator.getByRole("button", {
62
+ name: /submit/i,
63
+ });
64
+ if ((await byRole.count()) > 0) {
65
+ await byRole.first().click();
66
+ return;
67
+ }
68
+ // Second try: find an explicit type="submit" element
60
69
  const submitBtn = this.lastFormLocator.locator('button[type="submit"], input[type="submit"]');
61
70
  if ((await submitBtn.count()) > 0) {
62
71
  await submitBtn.first().click();
63
72
  }
64
73
  else {
74
+ // Last resort: press Enter on the last form field
65
75
  await this.lastFormLocator
66
76
  .locator("input, textarea, select")
67
77
  .last()
@@ -87,7 +97,9 @@ export class PlaywrightDriver {
87
97
  async refuteHas(selector, opts) {
88
98
  let locator = this.scope.locator(selector);
89
99
  if (opts?.text) {
90
- locator = locator.filter({ hasText: opts.text });
100
+ locator = opts.exact
101
+ ? locator.filter({ hasText: opts.text })
102
+ : locator.filter({ hasText: new RegExp(opts.text) });
91
103
  }
92
104
  await expect(locator).toHaveCount(0, { timeout: opts?.timeout });
93
105
  }
@@ -108,10 +120,8 @@ export class PlaywrightDriver {
108
120
  }
109
121
  }
110
122
  async refutePath(path) {
111
- const url = new URL(this.page.url());
112
- if (url.pathname === path) {
113
- throw new Error(`Expected path to NOT be '${path}', but it is.`);
114
- }
123
+ const escaped = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
124
+ await expect(this.page).not.toHaveURL(new RegExp(`^[^?]*${escaped}(\\?.*)?$`));
115
125
  }
116
126
  async within(selector) {
117
127
  const scopedLocator = this.scope.locator(selector);
@@ -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;IAM1D,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;IASvC,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":"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,4 +1,4 @@
1
- import { screen, within as rtlWithin } from "@testing-library/react";
1
+ import { 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.
@@ -41,7 +41,11 @@ export class RTLDriver {
41
41
  }
42
42
  async selectOption(label, option) {
43
43
  const select = await this.container.findByLabelText(label);
44
- await this.user.selectOptions(select, option);
44
+ const optionEl = Array.from(select.querySelectorAll("option")).find((o) => o.textContent?.trim() === option);
45
+ if (!optionEl) {
46
+ throw new Error(`selectOption('${label}', '${option}'): no <option> with text '${option}' found.`);
47
+ }
48
+ await this.user.selectOptions(select, optionEl);
45
49
  this.lastFormElement = select.closest("form");
46
50
  }
47
51
  async check(label) {
@@ -88,10 +92,12 @@ export class RTLDriver {
88
92
  await this.container.findByText(text);
89
93
  }
90
94
  async refuteText(text) {
91
- const el = this.container.queryByText(text);
92
- if (el) {
93
- throw new Error(`Expected NOT to find text '${text}', but it was present.`);
94
- }
95
+ await waitFor(() => {
96
+ const el = this.container.queryByText(text);
97
+ if (el) {
98
+ throw new Error(`Expected NOT to find text '${text}', but it was present.`);
99
+ }
100
+ });
95
101
  }
96
102
  async assertPath() {
97
103
  throw new Error("assertPath() is not available in the RTL adapter (no real URL in JSDOM).");
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "feather-testing-core",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Phoenix Test-inspired fluent testing DSL for Playwright and React Testing Library",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/siraj-samsudeen/feather-testing-core"
9
+ },
6
10
  "keywords": [
7
11
  "testing",
8
12
  "playwright",
@@ -50,12 +54,22 @@
50
54
  }
51
55
  },
52
56
  "scripts": {
53
- "build": "tsc"
57
+ "build": "tsc",
58
+ "test": "vitest run",
59
+ "test:pw": "playwright test",
60
+ "test:all": "vitest run && playwright test"
54
61
  },
55
62
  "devDependencies": {
56
63
  "@playwright/test": "^1.58.0",
64
+ "@testing-library/jest-dom": "^6.9.1",
57
65
  "@testing-library/react": "^16.3.0",
58
66
  "@testing-library/user-event": "^14.6.0",
59
- "typescript": "~5.9.3"
67
+ "@types/react": "^19.2.14",
68
+ "@types/react-dom": "^19.2.3",
69
+ "jsdom": "^28.1.0",
70
+ "react": "^19.2.4",
71
+ "react-dom": "^19.2.4",
72
+ "typescript": "~5.9.3",
73
+ "vitest": "^4.0.18"
60
74
  }
61
75
  }