feather-testing-core 0.2.0 → 0.4.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 +177 -4
- package/dist/errors.d.ts +8 -0
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +12 -0
- package/dist/eslint-plugin/index.d.ts +18 -0
- package/dist/eslint-plugin/index.d.ts.map +1 -0
- package/dist/eslint-plugin/index.js +37 -0
- package/dist/eslint-plugin/rules/no-conditional-skip.d.ts +4 -0
- package/dist/eslint-plugin/rules/no-conditional-skip.d.ts.map +1 -0
- package/dist/eslint-plugin/rules/no-conditional-skip.js +56 -0
- package/dist/eslint-plugin/rules/no-swallowed-cleanup-catch.d.ts +4 -0
- package/dist/eslint-plugin/rules/no-swallowed-cleanup-catch.d.ts.map +1 -0
- package/dist/eslint-plugin/rules/no-swallowed-cleanup-catch.js +63 -0
- package/dist/eslint-plugin/rules/no-wait-for-timeout.d.ts +4 -0
- package/dist/eslint-plugin/rules/no-wait-for-timeout.d.ts.map +1 -0
- package/dist/eslint-plugin/rules/no-wait-for-timeout.js +69 -0
- package/dist/eslint-plugin/rules/no-weak-assertions.d.ts +4 -0
- package/dist/eslint-plugin/rules/no-weak-assertions.d.ts.map +1 -0
- package/dist/eslint-plugin/rules/no-weak-assertions.js +73 -0
- package/dist/eslint-plugin/rules/warn-serial-mode.d.ts +4 -0
- package/dist/eslint-plugin/rules/warn-serial-mode.d.ts.map +1 -0
- package/dist/eslint-plugin/rules/warn-serial-mode.js +59 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/playwright/driver.d.ts +18 -3
- package/dist/playwright/driver.d.ts.map +1 -1
- package/dist/playwright/driver.js +82 -18
- package/dist/playwright/index.d.ts +4 -4
- package/dist/playwright/index.d.ts.map +1 -1
- package/dist/playwright/index.js +1 -1
- package/dist/rtl/driver.d.ts +55 -9
- package/dist/rtl/driver.d.ts.map +1 -1
- package/dist/rtl/driver.js +135 -35
- package/dist/rtl/index.d.ts +5 -4
- package/dist/rtl/index.d.ts.map +1 -1
- package/dist/rtl/index.js +2 -2
- package/dist/session.d.ts +41 -4
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +72 -1
- package/dist/types.d.ts +35 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +10 -2
package/README.md
CHANGED
|
@@ -169,8 +169,26 @@ 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
|
-
| `
|
|
172
|
+
| `attachFile(label, path)` | Set a file input (found by label) to the file at `path` |
|
|
173
173
|
| `dropFile(selector, path)` | Dispatch a `DataTransfer` drop of the file onto a drop area |
|
|
174
|
+
| `pressKey(key)` | Press a key on the focused control — `'Enter'`, `'Escape'`, `'Control+A'` |
|
|
175
|
+
| `hover(text)` | Hover the element with this text |
|
|
176
|
+
|
|
177
|
+
`upload(label, path)` is the former name of `attachFile` and still works, deprecated.
|
|
178
|
+
|
|
179
|
+
#### Interactions address controls **exactly**
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
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.
|
|
184
|
+
|
|
185
|
+
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.
|
|
186
|
+
|
|
187
|
+
To act on a control whose name is genuinely a prefix of another's, scope the lookup rather than loosening it:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
await session.within("main", (s) => s.clickButton("Check"));
|
|
191
|
+
```
|
|
174
192
|
|
|
175
193
|
#### How `submit()` finds the submit button
|
|
176
194
|
|
|
@@ -186,7 +204,7 @@ If no form was previously interacted with, `submit()` throws an error.
|
|
|
186
204
|
|
|
187
205
|
```ts
|
|
188
206
|
// Standard file input, found by its label
|
|
189
|
-
await session.
|
|
207
|
+
await session.attachFile("Avatar", "fixtures/avatar.png");
|
|
190
208
|
|
|
191
209
|
// Custom drop area (drag-and-drop upload zones)
|
|
192
210
|
await session.dropFile("#dropzone", "fixtures/report.pdf");
|
|
@@ -194,6 +212,10 @@ await session.dropFile("#dropzone", "fixtures/report.pdf");
|
|
|
194
212
|
|
|
195
213
|
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.
|
|
196
214
|
|
|
215
|
+
#### Keys
|
|
216
|
+
|
|
217
|
+
`pressKey` names keys the Playwright way on both adapters: a single character types itself, a named key is `'Enter'` / `'Escape'` / `'ArrowDown'`, and modifiers combine with `+` (`'Control+A'`, `'Meta+Enter'`). The RTL adapter translates that to user-event's keyboard syntax, so one spec reads the same in both places. A prefix that is not `Control`, `Shift`, `Alt`, or `Meta` is rejected rather than typed as text.
|
|
218
|
+
|
|
197
219
|
### Assertions
|
|
198
220
|
|
|
199
221
|
| Method | Description |
|
|
@@ -205,6 +227,7 @@ In Playwright, `dropFile` reads the real file and dispatches a `drop` event with
|
|
|
205
227
|
| `assertOptions(label, [labels])` | Assert a select offers exactly these options, in order |
|
|
206
228
|
| `assertHas(selector, opts?)` / `refuteHas(...)` | Assert element exists (Playwright only, see options below) |
|
|
207
229
|
| `assertPath(path, opts?)` / `refutePath(path)` | Assert URL path (Playwright only, see options below) |
|
|
230
|
+
| `assertDownload(filename, trigger, opts?)` | Assert `trigger` starts a download with this filename (Playwright only) |
|
|
208
231
|
|
|
209
232
|
#### Form-state assertions
|
|
210
233
|
|
|
@@ -279,6 +302,47 @@ await session.assertPath("/search", { queryParams: { q: "hello", page: "1" } });
|
|
|
279
302
|
await session.refutePath("/login");
|
|
280
303
|
```
|
|
281
304
|
|
|
305
|
+
#### Downloads
|
|
306
|
+
|
|
307
|
+
The wait has to be armed before the click that starts the download, so the triggering steps go in a callback — the same shape as `within()`:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
await session
|
|
311
|
+
.visit("/exports")
|
|
312
|
+
.assertDownload("report.csv", (s) => s.clickButton("Export"))
|
|
313
|
+
.assertText("Export complete");
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
`filename` is matched against the browser's suggested filename, exactly for a string or by `test()` for a `RegExp`. `opts.timeout` bounds the wait for the download to start. This is browser-only: the RTL adapter throws a `BrowserOnlyVerbError`, wrapped by the chain into a `StepError` that names the step.
|
|
317
|
+
|
|
318
|
+
### Waiting: `until(description, fn)`
|
|
319
|
+
|
|
320
|
+
| Method | Description |
|
|
321
|
+
|--------|-------------|
|
|
322
|
+
| `until(description, fn, opts?)` | Poll `fn` until it returns something truthy, then continue |
|
|
323
|
+
|
|
324
|
+
Tests wait for conditions, not for clocks. `until()` is the honest alternative to a sleep: it polls a predicate you write, and the **mandatory** description is what the trace prints, so a timeout names the thing you were waiting for instead of the mechanism you waited with.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
await session
|
|
328
|
+
.visit("/exports")
|
|
329
|
+
.clickButton("Export")
|
|
330
|
+
.until("the export job reports done", ({ page }) =>
|
|
331
|
+
page.evaluate(() => window.__exportDone),
|
|
332
|
+
)
|
|
333
|
+
.assertText("Download ready");
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
The predicate receives the adapter context — `{ page, scope }` for Playwright, `{ user, container }` for RTL — and may be sync or async. `opts` takes `{ timeout, interval }` in ms; omit them to inherit the adapter's own budget (Playwright's `expect.poll`, RTL's `waitFor`).
|
|
337
|
+
|
|
338
|
+
When the budget runs out, the chain trace says what you were waiting for:
|
|
339
|
+
|
|
340
|
+
```
|
|
341
|
+
>>> [FAILED] until: the export job reports done
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The description is required at the call site, before the chain runs — a blank one throws immediately, because a step named `until: ` teaches a reader nothing. The `feather-testing/no-wait-for-timeout` lint rule (see [Lint plugin](#lint-plugin)) points at this verb, so "no sleeps" stops being a review convention and becomes a check.
|
|
345
|
+
|
|
282
346
|
### Scoping
|
|
283
347
|
|
|
284
348
|
| Method | Description |
|
|
@@ -295,7 +359,7 @@ await session
|
|
|
295
359
|
.assertText("Dashboard"); // back to full-page scope after within()
|
|
296
360
|
```
|
|
297
361
|
|
|
298
|
-
### Escape
|
|
362
|
+
### Escape hatches: `step(name, fn)` and `raw(label, fn)`
|
|
299
363
|
|
|
300
364
|
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:
|
|
301
365
|
|
|
@@ -308,6 +372,26 @@ await session
|
|
|
308
372
|
.assertText("Done (1)");
|
|
309
373
|
```
|
|
310
374
|
|
|
375
|
+
`raw(label, fn)` goes one level lower: it hands over the driver itself — Playwright's `page`, RTL's scoped query object — with no context wrapper. Use it when you want the native API and nothing else:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
await session
|
|
379
|
+
.visit("/board")
|
|
380
|
+
.raw("stub the clipboard", (page) =>
|
|
381
|
+
page.evaluate(() => navigator.clipboard.writeText("copied")),
|
|
382
|
+
)
|
|
383
|
+
.clickButton("Paste")
|
|
384
|
+
.assertText("copied");
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Both hatches take a **mandatory** label and register as named steps, so a failure inside one still names intent:
|
|
388
|
+
|
|
389
|
+
```
|
|
390
|
+
>>> [FAILED] raw('stub the clipboard')
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
That is the whole point of having them: an untraced raw tail ends the trace at the last DSL verb, and no hatch at all pushes teams to abandon the DSL mid-spec. In Playwright, `raw` always hands over the page, not the `within()` scope — re-scoping is the caller's job once you have left the DSL.
|
|
394
|
+
|
|
311
395
|
### Debug
|
|
312
396
|
|
|
313
397
|
| Method | Description |
|
|
@@ -391,18 +475,59 @@ The RTL adapter runs in JSDOM, which has no real browser. These methods are not
|
|
|
391
475
|
- `visit()` — render the component directly instead
|
|
392
476
|
- `assertPath()` / `refutePath()` — no URL in JSDOM
|
|
393
477
|
- `assertHas()` / `refuteHas()` — RTL discourages CSS selectors; use `assertText()` instead
|
|
478
|
+
- `assertDownload()` — JSDOM has no download machinery; it throws `BrowserOnlyVerbError` naming the verb and pointing at a Playwright spec
|
|
479
|
+
|
|
480
|
+
The verbs JSDOM *can* honestly do, it does: `attachFile` synthesizes a `File` from the path's basename, `pressKey` translates to user-event's keyboard syntax, `hover` fires real pointer events, and `raw` hands over the scoped query object.
|
|
481
|
+
|
|
482
|
+
### Extending the RTL adapter
|
|
483
|
+
|
|
484
|
+
`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`:
|
|
485
|
+
|
|
486
|
+
| Member | Why you'd override it |
|
|
487
|
+
|--------|----------------------|
|
|
488
|
+
| `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 |
|
|
489
|
+
| `scoped(element)` | Factory used by `within()`, so a scoped session keeps your driver's behaviour |
|
|
490
|
+
| `user`, `root`, `container`, `lastFormElement`, `timeout` | Shared state the built-in verbs read and write |
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
class WrapperLabelDriver extends RTLDriver {
|
|
494
|
+
// Labels with no htmlFor, control is a sibling inside a wrapper div
|
|
495
|
+
protected override async findField(label: string): Promise<HTMLElement> {
|
|
496
|
+
for (const l of this.rootElement().querySelectorAll("label")) {
|
|
497
|
+
if (l.textContent?.trim() !== label) continue;
|
|
498
|
+
const control = l.parentElement?.querySelector("input, textarea, select");
|
|
499
|
+
if (control) return control as HTMLElement;
|
|
500
|
+
}
|
|
501
|
+
throw new Error(`no field labelled '${label}'`);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
protected override scoped(element: HTMLElement) {
|
|
505
|
+
return new WrapperLabelDriver(this.user, element, this.timeout);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
The third constructor argument is a per-lookup timeout in ms; omit it to keep RTL's own default.
|
|
394
511
|
|
|
395
512
|
## Exports
|
|
396
513
|
|
|
397
514
|
```ts
|
|
398
515
|
// Core (Session class + types)
|
|
399
|
-
import {
|
|
516
|
+
import {
|
|
517
|
+
Session,
|
|
518
|
+
StepError,
|
|
519
|
+
BrowserOnlyVerbError,
|
|
520
|
+
type TestDriver,
|
|
521
|
+
} from "feather-testing-core";
|
|
400
522
|
|
|
401
523
|
// Playwright adapter
|
|
402
524
|
import { test, createSession, expect } from "feather-testing-core/playwright";
|
|
403
525
|
|
|
404
526
|
// RTL adapter
|
|
405
527
|
import { createSession } from "feather-testing-core/rtl";
|
|
528
|
+
|
|
529
|
+
// ESLint plugin (see below)
|
|
530
|
+
import featherTesting from "feather-testing-core/eslint-plugin";
|
|
406
531
|
```
|
|
407
532
|
|
|
408
533
|
Both adapter subpaths also re-export `Session` and `StepError`, so you can import everything from a single path:
|
|
@@ -412,6 +537,54 @@ import { test, Session, StepError } from "feather-testing-core/playwright";
|
|
|
412
537
|
import { createSession, Session, StepError } from "feather-testing-core/rtl";
|
|
413
538
|
```
|
|
414
539
|
|
|
540
|
+
## Lint plugin
|
|
541
|
+
|
|
542
|
+
The DSL can only offer good habits; a linter can insist on them. This package ships an ESLint plugin whose rules are the defect classes a real suite audit found by expensive reading — each one now a check that runs in a second, with a message that names the fix so whoever hits it (person or agent) learns the alternative from the error alone.
|
|
543
|
+
|
|
544
|
+
```js
|
|
545
|
+
// eslint.config.js — flat config
|
|
546
|
+
import featherTesting from "feather-testing-core/eslint-plugin";
|
|
547
|
+
|
|
548
|
+
export default [
|
|
549
|
+
{
|
|
550
|
+
files: ["tests/**/*.ts", "e2e/**/*.spec.ts"],
|
|
551
|
+
...featherTesting.configs.recommended,
|
|
552
|
+
},
|
|
553
|
+
];
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
Or wire the rules yourself:
|
|
557
|
+
|
|
558
|
+
```js
|
|
559
|
+
import featherTesting from "feather-testing-core/eslint-plugin";
|
|
560
|
+
|
|
561
|
+
export default [
|
|
562
|
+
{
|
|
563
|
+
files: ["tests/**/*.ts"],
|
|
564
|
+
plugins: { "feather-testing": featherTesting },
|
|
565
|
+
rules: {
|
|
566
|
+
"feather-testing/no-weak-assertions": ["error", { matchers: ["toBeTruthy", "toBeDefined", "toBeFalsy"] }],
|
|
567
|
+
},
|
|
568
|
+
},
|
|
569
|
+
];
|
|
570
|
+
```
|
|
571
|
+
|
|
572
|
+
| Rule | Catches | Points at |
|
|
573
|
+
|------|---------|-----------|
|
|
574
|
+
| `no-wait-for-timeout` | `page.waitForTimeout(...)`, and the `new Promise(r => setTimeout(r, n))` sleep idiom | `session.until(description, fn)`, `expect.poll`, web-first assertions |
|
|
575
|
+
| `no-conditional-skip` | `test.skip(cond)`, `test.skip()`, `this.skip()` — a spec that un-tests itself at runtime | making the precondition part of the test, or `test.fixme` so the report names it |
|
|
576
|
+
| `no-weak-assertions` | `expect(x).toBeTruthy()` / `.toBeDefined()` (configurable) | asserting the shape you mean |
|
|
577
|
+
| `no-swallowed-cleanup-catch` | `.catch(() => {})` and empty `catch {}` blocks | asserting on the error, rethrowing with context, or annotating the deliberate ignore |
|
|
578
|
+
| `warn-serial-mode` | `test.describe.serial(...)`, `configure({ mode: "serial" })` — warning, not error | independent tests, or an `eslint-disable` line saying why serial is required |
|
|
579
|
+
|
|
580
|
+
Deliberate exceptions stay possible and stay visible: an `eslint-disable-next-line` comment with a reason is exactly the annotation these rules are trying to force.
|
|
581
|
+
|
|
582
|
+
**Why these five.** They are not style preferences. Each one is a way a suite goes green while proving nothing: a sleep passes on a slow machine and fails on a fast one, a conditional skip silently un-tests a spec for its entire life, `toBeTruthy()` accepts almost any value, a swallowed cleanup error surfaces three tests later as something else, and serial mode turns one failure into a wall of red that hides its own cause. `session.until()` exists so the first rule has an honest alternative to point at — see [the document set](docs/document-set.md) for why conventions belong in executable form rather than in a style guide nobody re-reads.
|
|
583
|
+
|
|
584
|
+
## Documentation
|
|
585
|
+
|
|
586
|
+
- [The document set](docs/document-set.md) — the minimal set of documents a project needs, what each one answers, and why hand-maintained cross-reference matrices lose to generated reports plus CI checks.
|
|
587
|
+
|
|
415
588
|
## License
|
|
416
589
|
|
|
417
590
|
MIT
|
package/dist/errors.d.ts
CHANGED
|
@@ -2,4 +2,12 @@ import type { QueuedStep } from "./types.js";
|
|
|
2
2
|
export declare class StepError extends Error {
|
|
3
3
|
constructor(failedStep: QueuedStep, allSteps: QueuedStep[], cause: unknown);
|
|
4
4
|
}
|
|
5
|
+
/**
|
|
6
|
+
* Thrown by an adapter asked for something only a real browser can do.
|
|
7
|
+
* A session wraps it in a StepError, so the chain trace names the verb that
|
|
8
|
+
* could not run and this message says why and what to do instead.
|
|
9
|
+
*/
|
|
10
|
+
export declare class BrowserOnlyVerbError extends Error {
|
|
11
|
+
constructor(verb: string, alternative: string);
|
|
12
|
+
}
|
|
5
13
|
//# sourceMappingURL=errors.d.ts.map
|
package/dist/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,qBAAa,SAAU,SAAQ,KAAK;gBAEhC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,UAAU,EAAE,EACtB,KAAK,EAAE,OAAO;CA4BjB"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,qBAAa,SAAU,SAAQ,KAAK;gBAEhC,UAAU,EAAE,UAAU,EACtB,QAAQ,EAAE,UAAU,EAAE,EACtB,KAAK,EAAE,OAAO;CA4BjB;AAED;;;;GAIG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM;CAO9C"}
|
package/dist/errors.js
CHANGED
|
@@ -19,3 +19,15 @@ export class StepError extends Error {
|
|
|
19
19
|
this.name = "StepError";
|
|
20
20
|
}
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Thrown by an adapter asked for something only a real browser can do.
|
|
24
|
+
* A session wraps it in a StepError, so the chain trace names the verb that
|
|
25
|
+
* could not run and this message says why and what to do instead.
|
|
26
|
+
*/
|
|
27
|
+
export class BrowserOnlyVerbError extends Error {
|
|
28
|
+
constructor(verb, alternative) {
|
|
29
|
+
super(`feather-testing-core: ${verb} is a browser-only verb — this adapter runs ` +
|
|
30
|
+
`in JSDOM, which has no browser to do it. ${alternative}`);
|
|
31
|
+
this.name = "BrowserOnlyVerbError";
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Linter, Rule } from "eslint";
|
|
2
|
+
/**
|
|
3
|
+
* The defect classes an expensive suite audit found by reading, turned into
|
|
4
|
+
* checks. Every message names the fix, so an agent or a reviewer mid-task
|
|
5
|
+
* learns what to do from the error alone.
|
|
6
|
+
*/
|
|
7
|
+
export declare const rules: Record<string, Rule.RuleModule>;
|
|
8
|
+
export declare const recommendedRules: Linter.RulesRecord;
|
|
9
|
+
export interface FeatherTestingPlugin {
|
|
10
|
+
meta: {
|
|
11
|
+
name: string;
|
|
12
|
+
};
|
|
13
|
+
rules: Record<string, Rule.RuleModule>;
|
|
14
|
+
configs: Record<string, Linter.Config>;
|
|
15
|
+
}
|
|
16
|
+
declare const plugin: FeatherTestingPlugin;
|
|
17
|
+
export default plugin;
|
|
18
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/eslint-plugin/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAO3C;;;;GAIG;AACH,eAAO,MAAM,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,UAAU,CAMjD,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,WAQrC,CAAC;AAEF,MAAM,WAAW,oBAAoB;IACnC,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACvB,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,IAAI,CAAC,UAAU,CAAC,CAAC;IACvC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;CACxC;AAED,QAAA,MAAM,MAAM,EAAE,oBAIb,CAAC;AAQF,eAAe,MAAM,CAAC"}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import noWaitForTimeout from "./rules/no-wait-for-timeout.js";
|
|
2
|
+
import noConditionalSkip from "./rules/no-conditional-skip.js";
|
|
3
|
+
import noWeakAssertions from "./rules/no-weak-assertions.js";
|
|
4
|
+
import noSwallowedCleanupCatch from "./rules/no-swallowed-cleanup-catch.js";
|
|
5
|
+
import warnSerialMode from "./rules/warn-serial-mode.js";
|
|
6
|
+
/**
|
|
7
|
+
* The defect classes an expensive suite audit found by reading, turned into
|
|
8
|
+
* checks. Every message names the fix, so an agent or a reviewer mid-task
|
|
9
|
+
* learns what to do from the error alone.
|
|
10
|
+
*/
|
|
11
|
+
export const rules = {
|
|
12
|
+
"no-wait-for-timeout": noWaitForTimeout,
|
|
13
|
+
"no-conditional-skip": noConditionalSkip,
|
|
14
|
+
"no-weak-assertions": noWeakAssertions,
|
|
15
|
+
"no-swallowed-cleanup-catch": noSwallowedCleanupCatch,
|
|
16
|
+
"warn-serial-mode": warnSerialMode,
|
|
17
|
+
};
|
|
18
|
+
export const recommendedRules = {
|
|
19
|
+
"feather-testing/no-wait-for-timeout": "error",
|
|
20
|
+
"feather-testing/no-conditional-skip": "error",
|
|
21
|
+
"feather-testing/no-weak-assertions": "error",
|
|
22
|
+
"feather-testing/no-swallowed-cleanup-catch": "error",
|
|
23
|
+
// Serial mode is sometimes genuinely required; a warning asks for the
|
|
24
|
+
// annotation rather than forbidding the choice.
|
|
25
|
+
"feather-testing/warn-serial-mode": "warn",
|
|
26
|
+
};
|
|
27
|
+
const plugin = {
|
|
28
|
+
meta: { name: "feather-testing" },
|
|
29
|
+
rules,
|
|
30
|
+
configs: {},
|
|
31
|
+
};
|
|
32
|
+
plugin.configs.recommended = {
|
|
33
|
+
name: "feather-testing/recommended",
|
|
34
|
+
plugins: { "feather-testing": plugin },
|
|
35
|
+
rules: recommendedRules,
|
|
36
|
+
};
|
|
37
|
+
export default plugin;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"no-conditional-skip.d.ts","sourceRoot":"","sources":["../../../src/eslint-plugin/rules/no-conditional-skip.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAqBnC,QAAA,MAAM,IAAI,EAAE,IAAI,CAAC,UA6ChB,CAAC;AAEF,eAAe,IAAI,CAAC"}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const RUNNERS = new Set(["test", "it", "describe", "suite"]);
|
|
2
|
+
/** The identifier a member chain like `test.describe.skip` starts from. */
|
|
3
|
+
function rootIdentifier(node) {
|
|
4
|
+
let current = node;
|
|
5
|
+
while (current.type === "MemberExpression")
|
|
6
|
+
current = current.object;
|
|
7
|
+
return current.type === "Identifier" ? current.name : null;
|
|
8
|
+
}
|
|
9
|
+
/** `test.skip("name", fn)` is declarative — the report still lists it. */
|
|
10
|
+
function isDeclarativeSkip(args) {
|
|
11
|
+
const [first] = args;
|
|
12
|
+
return (first !== undefined &&
|
|
13
|
+
(first.type === "Literal" || first.type === "TemplateLiteral"));
|
|
14
|
+
}
|
|
15
|
+
const rule = {
|
|
16
|
+
meta: {
|
|
17
|
+
type: "problem",
|
|
18
|
+
docs: {
|
|
19
|
+
description: "Do not skip tests at runtime — a self-skipping spec is green while proving nothing",
|
|
20
|
+
recommended: true,
|
|
21
|
+
},
|
|
22
|
+
schema: [],
|
|
23
|
+
messages: {
|
|
24
|
+
conditionalSkip: "A runtime skip un-tests this spec silently: the run stays green while " +
|
|
25
|
+
"nothing is checked, sometimes for the life of the file. Make the " +
|
|
26
|
+
"precondition part of the test (set it up, or fail with a message that " +
|
|
27
|
+
"says what is missing), or mark the spec test.fixme(...) so the report " +
|
|
28
|
+
"names it as not running.",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
create(context) {
|
|
32
|
+
return {
|
|
33
|
+
CallExpression(node) {
|
|
34
|
+
const callee = node.callee;
|
|
35
|
+
if (callee.type !== "MemberExpression" ||
|
|
36
|
+
callee.computed ||
|
|
37
|
+
callee.property.type !== "Identifier" ||
|
|
38
|
+
callee.property.name !== "skip") {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
// this.skip() — Mocha's runtime skip.
|
|
42
|
+
if (callee.object.type === "ThisExpression") {
|
|
43
|
+
context.report({ node, messageId: "conditionalSkip" });
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const root = rootIdentifier(callee.object);
|
|
47
|
+
if (root === null || !RUNNERS.has(root))
|
|
48
|
+
return;
|
|
49
|
+
if (isDeclarativeSkip(node.arguments))
|
|
50
|
+
return;
|
|
51
|
+
context.report({ node, messageId: "conditionalSkip" });
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
export default rule;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"no-swallowed-cleanup-catch.d.ts","sourceRoot":"","sources":["../../../src/eslint-plugin/rules/no-swallowed-cleanup-catch.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAmBnC,QAAA,MAAM,IAAI,EAAE,IAAI,CAAC,UAgDhB,CAAC;AAEF,eAAe,IAAI,CAAC"}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** `() => {}`, `() => undefined`, `function () {}` — a handler that does nothing. */
|
|
2
|
+
function isEmptyHandler(node) {
|
|
3
|
+
if (node.type !== "ArrowFunctionExpression" &&
|
|
4
|
+
node.type !== "FunctionExpression") {
|
|
5
|
+
return false;
|
|
6
|
+
}
|
|
7
|
+
const body = node.body;
|
|
8
|
+
if (body.type === "BlockStatement")
|
|
9
|
+
return body.body.length === 0;
|
|
10
|
+
if (body.type === "Identifier")
|
|
11
|
+
return body.name === "undefined";
|
|
12
|
+
if (body.type === "Literal")
|
|
13
|
+
return body.value === null;
|
|
14
|
+
if (body.type === "ObjectExpression")
|
|
15
|
+
return body.properties.length === 0;
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
const rule = {
|
|
19
|
+
meta: {
|
|
20
|
+
type: "problem",
|
|
21
|
+
docs: {
|
|
22
|
+
description: "Do not swallow errors from setup or cleanup — a discarded failure reads as a pass",
|
|
23
|
+
recommended: true,
|
|
24
|
+
},
|
|
25
|
+
schema: [],
|
|
26
|
+
messages: {
|
|
27
|
+
swallowedCatch: "An empty .catch() turns a failed setup or cleanup into a green run, " +
|
|
28
|
+
"and the test that follows fails somewhere else entirely. Assert on the " +
|
|
29
|
+
"error, rethrow it with context, or narrow the catch to the one error " +
|
|
30
|
+
"you expect and say why.",
|
|
31
|
+
emptyCatchBlock: "This catch block discards the error, so a failure here is invisible. " +
|
|
32
|
+
"Assert on it, rethrow it, or leave a comment saying which error is " +
|
|
33
|
+
"expected and why ignoring it is safe.",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
create(context) {
|
|
37
|
+
const sourceCode = context.sourceCode;
|
|
38
|
+
return {
|
|
39
|
+
CallExpression(node) {
|
|
40
|
+
const callee = node.callee;
|
|
41
|
+
if (callee.type !== "MemberExpression" ||
|
|
42
|
+
callee.computed ||
|
|
43
|
+
callee.property.type !== "Identifier" ||
|
|
44
|
+
callee.property.name !== "catch") {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const [handler] = node.arguments;
|
|
48
|
+
if (handler && isEmptyHandler(handler)) {
|
|
49
|
+
context.report({ node, messageId: "swallowedCatch" });
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
CatchClause(node) {
|
|
53
|
+
if (node.body.body.length > 0)
|
|
54
|
+
return;
|
|
55
|
+
// A comment inside is the author annotating a deliberate ignore.
|
|
56
|
+
if (sourceCode.getCommentsInside(node.body).length > 0)
|
|
57
|
+
return;
|
|
58
|
+
context.report({ node, messageId: "emptyCatchBlock" });
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
export default rule;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"no-wait-for-timeout.d.ts","sourceRoot":"","sources":["../../../src/eslint-plugin/rules/no-wait-for-timeout.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AA4CnC,QAAA,MAAM,IAAI,EAAE,IAAI,CAAC,UAqChB,CAAC;AAEF,eAAe,IAAI,CAAC"}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const SLEEP_MESSAGE = "This sleeps for a fixed time instead of waiting for a condition, so it is " +
|
|
2
|
+
"slow when it passes and lying when it fails. Use " +
|
|
3
|
+
"session.until('<what you are waiting for>', fn) — or expect.poll / findBy* " +
|
|
4
|
+
"— so the wait names the thing it is waiting for.";
|
|
5
|
+
function isGlobalSetTimeout(callee) {
|
|
6
|
+
if (callee.type === "Identifier")
|
|
7
|
+
return callee.name === "setTimeout";
|
|
8
|
+
return (callee.type === "MemberExpression" &&
|
|
9
|
+
!callee.computed &&
|
|
10
|
+
callee.property.type === "Identifier" &&
|
|
11
|
+
callee.property.name === "setTimeout" &&
|
|
12
|
+
callee.object.type === "Identifier" &&
|
|
13
|
+
["window", "globalThis", "global"].includes(callee.object.name));
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Only sleeps, not every timer. `setTimeout(resolve, 100)` and a setTimeout
|
|
17
|
+
* inside a `new Promise(...)` executor are the sleep idiom; a timer whose
|
|
18
|
+
* callback does real work (a fixture component, a debounce test) is not.
|
|
19
|
+
*/
|
|
20
|
+
function isSleep(node) {
|
|
21
|
+
const [first] = node.arguments;
|
|
22
|
+
if (first && first.type === "Identifier")
|
|
23
|
+
return true;
|
|
24
|
+
let current = node.parent ?? null;
|
|
25
|
+
for (let depth = 0; current && depth < 4; depth += 1) {
|
|
26
|
+
if (current.type === "NewExpression" &&
|
|
27
|
+
current.callee.type === "Identifier" &&
|
|
28
|
+
current.callee.name === "Promise") {
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
current = current.parent ?? null;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
const rule = {
|
|
36
|
+
meta: {
|
|
37
|
+
type: "problem",
|
|
38
|
+
docs: {
|
|
39
|
+
description: "Wait for a condition instead of sleeping for a fixed duration",
|
|
40
|
+
recommended: true,
|
|
41
|
+
},
|
|
42
|
+
schema: [],
|
|
43
|
+
messages: {
|
|
44
|
+
waitForTimeout: "page.waitForTimeout() sleeps for a fixed time. Use " +
|
|
45
|
+
"session.until('<what you are waiting for>', fn) — or expect.poll / a " +
|
|
46
|
+
"web-first assertion — so the wait names its condition and ends as " +
|
|
47
|
+
"soon as it holds.",
|
|
48
|
+
sleep: SLEEP_MESSAGE,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
create(context) {
|
|
52
|
+
return {
|
|
53
|
+
CallExpression(node) {
|
|
54
|
+
const callee = node.callee;
|
|
55
|
+
if (callee.type === "MemberExpression" &&
|
|
56
|
+
!callee.computed &&
|
|
57
|
+
callee.property.type === "Identifier" &&
|
|
58
|
+
callee.property.name === "waitForTimeout") {
|
|
59
|
+
context.report({ node, messageId: "waitForTimeout" });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (isGlobalSetTimeout(callee) && isSleep(node)) {
|
|
63
|
+
context.report({ node, messageId: "sleep" });
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
export default rule;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"no-weak-assertions.d.ts","sourceRoot":"","sources":["../../../src/eslint-plugin/rules/no-weak-assertions.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AA2BnC,QAAA,MAAM,IAAI,EAAE,IAAI,CAAC,UA4DhB,CAAC;AAEF,eAAe,IAAI,CAAC"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const DEFAULT_MATCHERS = ["toBeTruthy", "toBeDefined"];
|
|
2
|
+
const ALTERNATIVES = {
|
|
3
|
+
toBeTruthy: "assert the value you mean — toBe/toEqual, toHaveLength, toHaveText, or a " +
|
|
4
|
+
"DSL assertion like assertText()",
|
|
5
|
+
toBeDefined: "assert the value you mean — toBe/toEqual, or toBeInstanceOf when the type " +
|
|
6
|
+
"is the point",
|
|
7
|
+
toBeFalsy: "assert the exact falsy value — toBe(false), toBe(0), toBeNull()",
|
|
8
|
+
toBeNull: "assert the value you mean, or pair it with a positive assertion",
|
|
9
|
+
};
|
|
10
|
+
/** Does this member chain hang off an `expect(...)` call? */
|
|
11
|
+
function rootsAtExpect(node) {
|
|
12
|
+
let current = node;
|
|
13
|
+
while (current.type === "MemberExpression")
|
|
14
|
+
current = current.object;
|
|
15
|
+
return (current.type === "CallExpression" &&
|
|
16
|
+
current.callee.type === "Identifier" &&
|
|
17
|
+
current.callee.name === "expect");
|
|
18
|
+
}
|
|
19
|
+
const rule = {
|
|
20
|
+
meta: {
|
|
21
|
+
type: "problem",
|
|
22
|
+
docs: {
|
|
23
|
+
description: "Assert the shape you mean instead of truthiness or definedness",
|
|
24
|
+
recommended: true,
|
|
25
|
+
},
|
|
26
|
+
schema: [
|
|
27
|
+
{
|
|
28
|
+
type: "object",
|
|
29
|
+
properties: {
|
|
30
|
+
matchers: {
|
|
31
|
+
type: "array",
|
|
32
|
+
items: { type: "string" },
|
|
33
|
+
minItems: 1,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
additionalProperties: false,
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
messages: {
|
|
40
|
+
weak: "{{matcher}}() passes for almost any value, so it proves little more " +
|
|
41
|
+
"than that the line ran. Assert the shape you mean with toBe/toEqual " +
|
|
42
|
+
"and friends: {{alternative}}.",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
create(context) {
|
|
46
|
+
const configured = context.options[0];
|
|
47
|
+
const matchers = new Set(configured?.matchers ?? DEFAULT_MATCHERS);
|
|
48
|
+
return {
|
|
49
|
+
CallExpression(node) {
|
|
50
|
+
const callee = node.callee;
|
|
51
|
+
if (callee.type !== "MemberExpression" ||
|
|
52
|
+
callee.computed ||
|
|
53
|
+
callee.property.type !== "Identifier") {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const matcher = callee.property.name;
|
|
57
|
+
if (!matchers.has(matcher))
|
|
58
|
+
return;
|
|
59
|
+
if (!rootsAtExpect(callee.object))
|
|
60
|
+
return;
|
|
61
|
+
context.report({
|
|
62
|
+
node,
|
|
63
|
+
messageId: "weak",
|
|
64
|
+
data: {
|
|
65
|
+
matcher,
|
|
66
|
+
alternative: ALTERNATIVES[matcher] ?? "assert the shape you actually expect",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
export default rule;
|