feather-testing-core 0.3.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 +133 -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 +16 -3
- package/dist/playwright/driver.d.ts.map +1 -1
- package/dist/playwright/driver.js +49 -2
- 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 +28 -4
- package/dist/rtl/driver.d.ts.map +1 -1
- package/dist/rtl/driver.js +78 -2
- 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 +36 -4
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +67 -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,12 @@ 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.
|
|
174
178
|
|
|
175
179
|
#### Interactions address controls **exactly**
|
|
176
180
|
|
|
@@ -200,7 +204,7 @@ If no form was previously interacted with, `submit()` throws an error.
|
|
|
200
204
|
|
|
201
205
|
```ts
|
|
202
206
|
// Standard file input, found by its label
|
|
203
|
-
await session.
|
|
207
|
+
await session.attachFile("Avatar", "fixtures/avatar.png");
|
|
204
208
|
|
|
205
209
|
// Custom drop area (drag-and-drop upload zones)
|
|
206
210
|
await session.dropFile("#dropzone", "fixtures/report.pdf");
|
|
@@ -208,6 +212,10 @@ await session.dropFile("#dropzone", "fixtures/report.pdf");
|
|
|
208
212
|
|
|
209
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.
|
|
210
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
|
+
|
|
211
219
|
### Assertions
|
|
212
220
|
|
|
213
221
|
| Method | Description |
|
|
@@ -219,6 +227,7 @@ In Playwright, `dropFile` reads the real file and dispatches a `drop` event with
|
|
|
219
227
|
| `assertOptions(label, [labels])` | Assert a select offers exactly these options, in order |
|
|
220
228
|
| `assertHas(selector, opts?)` / `refuteHas(...)` | Assert element exists (Playwright only, see options below) |
|
|
221
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) |
|
|
222
231
|
|
|
223
232
|
#### Form-state assertions
|
|
224
233
|
|
|
@@ -293,6 +302,47 @@ await session.assertPath("/search", { queryParams: { q: "hello", page: "1" } });
|
|
|
293
302
|
await session.refutePath("/login");
|
|
294
303
|
```
|
|
295
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
|
+
|
|
296
346
|
### Scoping
|
|
297
347
|
|
|
298
348
|
| Method | Description |
|
|
@@ -309,7 +359,7 @@ await session
|
|
|
309
359
|
.assertText("Dashboard"); // back to full-page scope after within()
|
|
310
360
|
```
|
|
311
361
|
|
|
312
|
-
### Escape
|
|
362
|
+
### Escape hatches: `step(name, fn)` and `raw(label, fn)`
|
|
313
363
|
|
|
314
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:
|
|
315
365
|
|
|
@@ -322,6 +372,26 @@ await session
|
|
|
322
372
|
.assertText("Done (1)");
|
|
323
373
|
```
|
|
324
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
|
+
|
|
325
395
|
### Debug
|
|
326
396
|
|
|
327
397
|
| Method | Description |
|
|
@@ -405,6 +475,9 @@ The RTL adapter runs in JSDOM, which has no real browser. These methods are not
|
|
|
405
475
|
- `visit()` — render the component directly instead
|
|
406
476
|
- `assertPath()` / `refutePath()` — no URL in JSDOM
|
|
407
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.
|
|
408
481
|
|
|
409
482
|
### Extending the RTL adapter
|
|
410
483
|
|
|
@@ -440,13 +513,21 @@ The third constructor argument is a per-lookup timeout in ms; omit it to keep RT
|
|
|
440
513
|
|
|
441
514
|
```ts
|
|
442
515
|
// Core (Session class + types)
|
|
443
|
-
import {
|
|
516
|
+
import {
|
|
517
|
+
Session,
|
|
518
|
+
StepError,
|
|
519
|
+
BrowserOnlyVerbError,
|
|
520
|
+
type TestDriver,
|
|
521
|
+
} from "feather-testing-core";
|
|
444
522
|
|
|
445
523
|
// Playwright adapter
|
|
446
524
|
import { test, createSession, expect } from "feather-testing-core/playwright";
|
|
447
525
|
|
|
448
526
|
// RTL adapter
|
|
449
527
|
import { createSession } from "feather-testing-core/rtl";
|
|
528
|
+
|
|
529
|
+
// ESLint plugin (see below)
|
|
530
|
+
import featherTesting from "feather-testing-core/eslint-plugin";
|
|
450
531
|
```
|
|
451
532
|
|
|
452
533
|
Both adapter subpaths also re-export `Session` and `StepError`, so you can import everything from a single path:
|
|
@@ -456,6 +537,54 @@ import { test, Session, StepError } from "feather-testing-core/playwright";
|
|
|
456
537
|
import { createSession, Session, StepError } from "feather-testing-core/rtl";
|
|
457
538
|
```
|
|
458
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
|
+
|
|
459
588
|
## License
|
|
460
589
|
|
|
461
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;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"warn-serial-mode.d.ts","sourceRoot":"","sources":["../../../src/eslint-plugin/rules/warn-serial-mode.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,QAAQ,CAAC;AAgBnC,QAAA,MAAM,IAAI,EAAE,IAAI,CAAC,UAkDhB,CAAC;AAEF,eAAe,IAAI,CAAC"}
|