@itmetsam/samurai-framework 0.1.0 → 0.2.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 +129 -0
- package/bin/samurai.mjs +31 -0
- package/dist/api.js +3 -0
- package/dist/assert/assertion-error.d.ts +23 -0
- package/dist/assert/assertion-error.js +45 -0
- package/dist/assert/expect.d.ts +55 -0
- package/dist/assert/expect.js +188 -0
- package/dist/browser/browser-context.d.ts +91 -0
- package/dist/browser/browser-context.js +146 -0
- package/dist/browser/browser-finder.d.ts +2 -0
- package/dist/browser/browser-finder.js +95 -0
- package/dist/browser/browser.d.ts +95 -0
- package/dist/browser/browser.js +372 -0
- package/dist/browser/context-tree.d.ts +23 -0
- package/dist/browser/context-tree.js +60 -0
- package/dist/browser/dialog.d.ts +37 -0
- package/dist/browser/dialog.js +61 -0
- package/dist/browser/download.d.ts +71 -0
- package/dist/browser/download.js +137 -0
- package/dist/browser/downloads-dir.d.ts +8 -0
- package/dist/browser/downloads-dir.js +38 -0
- package/dist/browser/emulation.d.ts +61 -0
- package/dist/browser/emulation.js +144 -0
- package/dist/browser/file-chooser.d.ts +39 -0
- package/dist/browser/file-chooser.js +86 -0
- package/dist/browser/file-paths.d.ts +4 -0
- package/dist/browser/file-paths.js +18 -0
- package/dist/browser/index.d.ts +4 -0
- package/dist/browser/index.js +4 -0
- package/dist/browser/navigation-error.d.ts +13 -0
- package/dist/browser/navigation-error.js +19 -0
- package/dist/browser/page-logs.d.ts +86 -0
- package/dist/browser/page-logs.js +149 -0
- package/dist/browser/page-wait.d.ts +9 -0
- package/dist/browser/page-wait.js +17 -0
- package/dist/browser/page.d.ts +370 -0
- package/dist/browser/page.js +1038 -0
- package/dist/browser/pdf.d.ts +72 -0
- package/dist/browser/pdf.js +37 -0
- package/dist/browser/screenshot.d.ts +29 -0
- package/dist/browser/screenshot.js +42 -0
- package/dist/cli/args.d.ts +35 -0
- package/dist/cli/args.js +69 -0
- package/dist/cli/cli.cli-test.d.ts +1 -0
- package/dist/cli/cli.cli-test.js +170 -0
- package/dist/cli/console-reporter.d.ts +15 -0
- package/dist/cli/console-reporter.js +52 -0
- package/dist/cli/init.d.ts +7 -0
- package/dist/cli/init.js +61 -0
- package/dist/cli/main.d.ts +12 -0
- package/dist/cli/main.js +156 -0
- package/dist/config/config.d.ts +24 -0
- package/dist/config/config.js +42 -0
- package/dist/config/default.d.ts +2 -0
- package/dist/config/default.js +3 -0
- package/dist/config/mask.d.ts +11 -0
- package/dist/config/mask.js +85 -0
- package/dist/config/run-settings.d.ts +28 -0
- package/dist/config/run-settings.js +61 -0
- package/dist/config/secrets.d.ts +10 -0
- package/dist/config/secrets.js +67 -0
- package/dist/config/variables.d.ts +11 -0
- package/dist/config/variables.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +15 -0
- package/dist/locator/action-timeout-error.d.ts +25 -0
- package/dist/locator/action-timeout-error.js +35 -0
- package/dist/locator/element-state.d.ts +44 -0
- package/dist/locator/element-state.js +109 -0
- package/dist/locator/label-locator.d.ts +13 -0
- package/dist/locator/label-locator.js +49 -0
- package/dist/locator/locator.d.ts +168 -0
- package/dist/locator/locator.js +591 -0
- package/dist/locator/selector-errors.d.ts +9 -0
- package/dist/locator/selector-errors.js +15 -0
- package/dist/locator/selector.d.ts +60 -0
- package/dist/locator/selector.js +113 -0
- package/dist/locator/text-locator.d.ts +13 -0
- package/dist/logger/index.d.ts +2 -0
- package/dist/logger/index.js +30 -0
- package/dist/network/data-collector.d.ts +26 -0
- package/dist/network/data-collector.js +77 -0
- package/dist/network/network-tracker.d.ts +94 -0
- package/dist/network/network-tracker.js +198 -0
- package/dist/network/network-wait.d.ts +16 -0
- package/dist/network/network-wait.js +24 -0
- package/dist/network/response.d.ts +22 -0
- package/dist/network/response.js +42 -0
- package/dist/network/router.d.ts +120 -0
- package/dist/network/router.js +343 -0
- package/dist/network/url-match.d.ts +8 -0
- package/dist/network/url-match.js +38 -0
- package/dist/recorder/apply.d.ts +3 -0
- package/dist/recorder/apply.js +10 -0
- package/dist/recorder/capture.d.ts +18 -0
- package/dist/recorder/capture.js +198 -0
- package/dist/recorder/index.d.ts +3 -0
- package/dist/recorder/index.js +3 -0
- package/dist/recorder/normalise.d.ts +68 -0
- package/dist/recorder/normalise.js +104 -0
- package/dist/recorder/ranking.d.ts +9 -0
- package/dist/recorder/ranking.js +21 -0
- package/dist/recorder/recorder.d.ts +52 -0
- package/dist/recorder/recorder.js +143 -0
- package/dist/recorder/session.d.ts +49 -0
- package/dist/recorder/session.js +113 -0
- package/dist/runner/module-type.d.ts +6 -0
- package/dist/runner/module-type.js +29 -0
- package/dist/runner/page-logs-report.d.ts +40 -0
- package/dist/runner/page-logs-report.js +44 -0
- package/dist/runner/prepare-run.d.ts +14 -0
- package/dist/runner/prepare-run.js +20 -0
- package/dist/runner/project.d.ts +17 -0
- package/dist/runner/project.js +33 -0
- package/dist/runner/registry.d.ts +7 -0
- package/dist/runner/registry.js +40 -0
- package/dist/runner/reporter.d.ts +43 -0
- package/dist/runner/reporter.js +83 -0
- package/dist/runner/run.d.ts +27 -0
- package/dist/runner/run.js +34 -0
- package/dist/runner/test-error.d.ts +3 -0
- package/dist/runner/test-error.js +18 -0
- package/dist/runner/test-runner.d.ts +44 -0
- package/dist/runner/test-runner.js +205 -0
- package/dist/script/call-function.d.ts +34 -0
- package/dist/script/call-function.js +71 -0
- package/dist/script/element-handle.d.ts +10 -0
- package/{src/script/element-handle.ts → dist/script/element-handle.js} +4 -4
- package/dist/script/helpers.d.ts +28 -0
- package/dist/script/helpers.js +50 -0
- package/dist/script/preload.d.ts +31 -0
- package/dist/script/preload.js +71 -0
- package/dist/script/serialize.d.ts +29 -0
- package/dist/script/serialize.js +207 -0
- package/dist/steps/build.d.ts +5 -0
- package/dist/steps/build.js +38 -0
- package/dist/steps/edit.d.ts +30 -0
- package/dist/steps/edit.js +80 -0
- package/dist/steps/emit.d.ts +6 -0
- package/dist/steps/emit.js +73 -0
- package/dist/steps/index.d.ts +5 -0
- package/dist/steps/index.js +4 -0
- package/dist/steps/model.d.ts +103 -0
- package/dist/steps/model.js +1 -0
- package/dist/steps/parse.d.ts +3 -0
- package/dist/steps/parse.js +272 -0
- package/dist/transport/bidi-connection.d.ts +46 -0
- package/dist/transport/bidi-connection.js +176 -0
- package/dist/transport/bidi-error.d.ts +10 -0
- package/dist/transport/bidi-error.js +10 -0
- package/dist/transport/subscriptions.d.ts +36 -0
- package/dist/transport/subscriptions.js +81 -0
- package/{src → dist}/types/test.d.ts +10 -4
- package/dist/wait/wait-until.d.ts +20 -0
- package/dist/wait/wait-until.js +63 -0
- package/package.json +44 -7
- package/.claude/settings.local.json +0 -7
- package/.env.example +0 -3
- package/.github/actions/setup/action.yml +0 -16
- package/.github/workflows/ci.yml +0 -71
- package/.trunk/configs/.markdownlint.yaml +0 -2
- package/.trunk/configs/.yamllint.yaml +0 -7
- package/.trunk/configs/svgo.config.mjs +0 -14
- package/.trunk/trunk.yaml +0 -34
- package/docs/superpowers/bidi-roadmap.md +0 -60
- package/docs/superpowers/plans/2026-09-29-auto-wait.md +0 -1827
- package/docs/superpowers/plans/2026-09-29-expect.md +0 -1350
- package/docs/superpowers/plans/2026-10-01-ci.md +0 -350
- package/docs/superpowers/plans/2026-10-01-test-api-alignment.md +0 -1821
- package/docs/superpowers/recorder-roadmap.md +0 -52
- package/docs/superpowers/specs/2026-09-29-auto-wait-design.md +0 -185
- package/docs/superpowers/specs/2026-09-29-bidi-foundation-design.md +0 -130
- package/docs/superpowers/specs/2026-09-29-contexts-and-storage-design.md +0 -88
- package/docs/superpowers/specs/2026-09-29-downloads-and-file-dialogs-design.md +0 -94
- package/docs/superpowers/specs/2026-09-29-emulation-design.md +0 -88
- package/docs/superpowers/specs/2026-09-29-expect-design.md +0 -130
- package/docs/superpowers/specs/2026-09-29-locate-nodes-design.md +0 -90
- package/docs/superpowers/specs/2026-09-29-logs-and-errors-design.md +0 -87
- package/docs/superpowers/specs/2026-09-29-navigation-waits-design.md +0 -83
- package/docs/superpowers/specs/2026-09-29-network-data-design.md +0 -78
- package/docs/superpowers/specs/2026-09-29-network-interception-design.md +0 -91
- package/docs/superpowers/specs/2026-09-29-network-tracking-design.md +0 -86
- package/docs/superpowers/specs/2026-09-29-page-api-design.md +0 -90
- package/docs/superpowers/specs/2026-09-29-preload-scripts-design.md +0 -82
- package/docs/superpowers/specs/2026-09-29-script-call-function-design.md +0 -107
- package/docs/superpowers/specs/2026-09-29-set-files-design.md +0 -62
- package/docs/superpowers/specs/2026-09-29-user-prompts-design.md +0 -75
- package/docs/superpowers/specs/2026-10-01-ci-design.md +0 -106
- package/docs/superpowers/specs/2026-10-01-test-api-alignment-design.md +0 -241
- package/public/logo-dark.svg +0 -236
- package/public/logo-light.svg +0 -262
- package/samurai.config.ts +0 -7
- package/src/assert/assertion-error.test.ts +0 -55
- package/src/assert/assertion-error.ts +0 -62
- package/src/assert/expect-locator.test.ts +0 -217
- package/src/assert/expect.test.ts +0 -73
- package/src/assert/expect.ts +0 -299
- package/src/browser/browser-context.browser-test.ts +0 -201
- package/src/browser/browser-context.test.ts +0 -407
- package/src/browser/browser-context.ts +0 -208
- package/src/browser/browser-finder.ts +0 -110
- package/src/browser/browser.test.ts +0 -88
- package/src/browser/browser.ts +0 -515
- package/src/browser/context-tree.test.ts +0 -86
- package/src/browser/context-tree.ts +0 -70
- package/src/browser/dialog.browser-test.ts +0 -144
- package/src/browser/dialog.test.ts +0 -322
- package/src/browser/dialog.ts +0 -83
- package/src/browser/download.test.ts +0 -161
- package/src/browser/download.ts +0 -172
- package/src/browser/downloads-dir.test.ts +0 -75
- package/src/browser/downloads-dir.ts +0 -39
- package/src/browser/emulation.browser-test.ts +0 -186
- package/src/browser/emulation.test.ts +0 -200
- package/src/browser/emulation.ts +0 -204
- package/src/browser/file-chooser.test.ts +0 -142
- package/src/browser/file-chooser.ts +0 -99
- package/src/browser/file-paths.ts +0 -18
- package/src/browser/goto.browser-test.ts +0 -44
- package/src/browser/navigation-error.ts +0 -26
- package/src/browser/navigation.browser-test.ts +0 -172
- package/src/browser/page-api.browser-test.ts +0 -228
- package/src/browser/page-logs.browser-test.ts +0 -119
- package/src/browser/page-logs.test.ts +0 -212
- package/src/browser/page-logs.ts +0 -208
- package/src/browser/page-wait.ts +0 -19
- package/src/browser/page.test.ts +0 -1567
- package/src/browser/page.ts +0 -1176
- package/src/browser/pdf.ts +0 -62
- package/src/browser/screenshot.ts +0 -65
- package/src/browser/transfers.browser-test.ts +0 -180
- package/src/config/config.ts +0 -33
- package/src/config/default.ts +0 -5
- package/src/config/fixtures.test.ts +0 -137
- package/src/config/mask.test.ts +0 -143
- package/src/config/mask.ts +0 -87
- package/src/config/run-settings.test.ts +0 -163
- package/src/config/run-settings.ts +0 -114
- package/src/config/secrets.ts +0 -70
- package/src/config/variables.ts +0 -31
- package/src/index.ts +0 -10
- package/src/locator/action-timeout-error.test.ts +0 -83
- package/src/locator/action-timeout-error.ts +0 -72
- package/src/locator/element-state.test.ts +0 -181
- package/src/locator/element-state.ts +0 -159
- package/src/locator/locate-nodes.browser-test.ts +0 -169
- package/src/locator/locator-actions.test.ts +0 -355
- package/src/locator/locator-chain.test.ts +0 -292
- package/src/locator/locator.browser-test.ts +0 -137
- package/src/locator/locator.test.ts +0 -179
- package/src/locator/locator.ts +0 -656
- package/src/locator/selector-errors.ts +0 -18
- package/src/locator/selector.test.ts +0 -124
- package/src/locator/selector.ts +0 -107
- package/src/locator/set-files.browser-test.ts +0 -141
- package/src/locator/set-files.test.ts +0 -191
- package/src/locator/text-locator.test.ts +0 -118
- package/src/logger/index.test.ts +0 -28
- package/src/logger/index.ts +0 -40
- package/src/network/data-collector.test.ts +0 -111
- package/src/network/data-collector.ts +0 -92
- package/src/network/network-data.browser-test.ts +0 -192
- package/src/network/network-tracker.browser-test.ts +0 -151
- package/src/network/network-tracker.test.ts +0 -307
- package/src/network/network-tracker.ts +0 -286
- package/src/network/network-wait.ts +0 -28
- package/src/network/response.test.ts +0 -93
- package/src/network/response.ts +0 -58
- package/src/network/router.browser-test.ts +0 -260
- package/src/network/router.test.ts +0 -528
- package/src/network/router.ts +0 -420
- package/src/network/url-match.test.ts +0 -69
- package/src/network/url-match.ts +0 -41
- package/src/runner/page-logs-report.test.ts +0 -144
- package/src/runner/page-logs-report.ts +0 -82
- package/src/runner/prepare-run.test.ts +0 -33
- package/src/runner/prepare-run.ts +0 -36
- package/src/runner/registry.test.ts +0 -59
- package/src/runner/registry.ts +0 -46
- package/src/runner/reporter.test.ts +0 -73
- package/src/runner/reporter.ts +0 -109
- package/src/runner/test-error.test.ts +0 -39
- package/src/runner/test-error.ts +0 -20
- package/src/runner/test-runner.ts +0 -184
- package/src/script/call-function.browser-test.ts +0 -112
- package/src/script/call-function.test.ts +0 -174
- package/src/script/call-function.ts +0 -113
- package/src/script/helpers.test.ts +0 -116
- package/src/script/helpers.ts +0 -68
- package/src/script/preload.browser-test.ts +0 -144
- package/src/script/preload.test.ts +0 -180
- package/src/script/preload.ts +0 -102
- package/src/script/serialize.test.ts +0 -182
- package/src/script/serialize.ts +0 -203
- package/src/testing/browser-fixture.ts +0 -18
- package/src/testing/fake-websocket.ts +0 -72
- package/src/testing/stub-connector.ts +0 -154
- package/src/tests/index.spec.ts +0 -30
- package/src/transport/bidi-connection.test.ts +0 -155
- package/src/transport/bidi-connection.ts +0 -256
- package/src/transport/bidi-error.ts +0 -23
- package/src/transport/foundation.browser-test.ts +0 -73
- package/src/transport/subscriptions.test.ts +0 -140
- package/src/transport/subscriptions.ts +0 -110
- package/src/types/bidi-commands.test-d.ts +0 -55
- package/src/wait/wait-until.test.ts +0 -117
- package/src/wait/wait-until.ts +0 -86
- package/tsconfig.json +0 -44
- /package/{src/api.ts → dist/api.d.ts} +0 -0
- /package/{src/locator/text-locator.ts → dist/locator/text-locator.js} +0 -0
- /package/{src → dist}/types/bidi-modules/browser.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/browsing-context.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/emulation.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/input.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/log.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/network.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/permissions.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/script.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/session.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/storage.d.ts +0 -0
- /package/{src → dist}/types/bidi-modules/web-extensions.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/command.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/error.d.ts +0 -0
- /package/{src → dist}/types/bidi-protocols/event.d.ts +0 -0
- /package/{src → dist}/types/bidi.d.ts +0 -0
- /package/{src → dist}/types/browser.d.ts +0 -0
- /package/{src → dist}/types/config.d.ts +0 -0
- /package/{src → dist}/types/env.d.ts +0 -0
|
@@ -1,1350 +0,0 @@
|
|
|
1
|
-
# `expect` Assertions Implementation Plan
|
|
2
|
-
|
|
3
|
-
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
-
|
|
5
|
-
**Goal:** Add a single `expect()` API with auto-retrying locator matchers and synchronous value matchers; failures throw `AssertionError` and are reported as `type: "assertion"`.
|
|
6
|
-
|
|
7
|
-
**Architecture:** `expect(x)` dispatches on `x instanceof Locator`: locators get `LocatorAssertions` (async, polling `Locator` read methods every 100 ms until a timeout), everything else gets `ValueAssertions` (sync, check once). Both throw `AssertionError`, which the runner maps to a `type: "assertion"` test error. A module-level registry of running locator assertions lets the runner fail tests that forgot to `await` one.
|
|
8
|
-
|
|
9
|
-
**Tech Stack:** TypeScript (`module: nodenext`, `strict`, `verbatimModuleSyntax`, `noUncheckedIndexedAccess`), tsx, Node 26 built-in `node:test` + `node:assert/strict`, WebDriver BiDi `script.evaluate`.
|
|
10
|
-
|
|
11
|
-
**Spec:** `docs/superpowers/specs/2026-09-29-expect-design.md`
|
|
12
|
-
|
|
13
|
-
## Global Constraints
|
|
14
|
-
|
|
15
|
-
- No new dependencies. Unit tests use `node:test` via `tsx --test`.
|
|
16
|
-
- Relative imports use the `.js` extension (e.g. `"./assertion-error.js"`), as everywhere in `src/`.
|
|
17
|
-
- Type-only imports use `import type` (`verbatimModuleSyntax` is on).
|
|
18
|
-
- Locator retry: poll interval `100` ms; timeout resolution per-call `{ timeout }` → config `expect.timeout` → default `5000` ms; at least one read even with `timeout: 0`.
|
|
19
|
-
- Errors thrown while reading (evaluate exception, closed socket) are rethrown immediately, never retried.
|
|
20
|
-
- `AssertionError` message format (locator):
|
|
21
|
-
```
|
|
22
|
-
expect(locator).toHaveText
|
|
23
|
-
locator: //h1
|
|
24
|
-
expected: "Contact"
|
|
25
|
-
received: "Home"
|
|
26
|
-
```
|
|
27
|
-
For values the first line is `expect(value).<matcher>` and there is no `locator:` line. Negated matchers are named `not.<matcher>`.
|
|
28
|
-
- Value formatting: strings quoted (`JSON.stringify`), RegExp as `/src/flags`, everything else `util.inspect(value, { depth: 2 })`.
|
|
29
|
-
- Type misuse of a value matcher throws `TypeError`, not `AssertionError`.
|
|
30
|
-
- Report: `type: "assertion"` with `expected` / `actual`; a RegExp is stored as its formatted string.
|
|
31
|
-
- `tsc --noEmit -p .` must show no errors other than the pre-existing `src/config/default.ts(3,14): error TS2739`.
|
|
32
|
-
- Leave `result/report.json` and `bun.lock` uncommitted; never `git add -A`.
|
|
33
|
-
|
|
34
|
-
---
|
|
35
|
-
|
|
36
|
-
## File Structure
|
|
37
|
-
|
|
38
|
-
| File | Status | Responsibility |
|
|
39
|
-
|---|---|---|
|
|
40
|
-
| `package.json` | modify | `test` script → `tsx --test "src/**/*.test.ts"` |
|
|
41
|
-
| `src/assert/assertion-error.ts` | create | `AssertionError`, `format()`, `toReportValue()` |
|
|
42
|
-
| `src/assert/assertion-error.test.ts` | create | unit tests for the above |
|
|
43
|
-
| `src/assert/expect.ts` | create (currently empty) | `expect()` overloads, `ValueAssertions`, `LocatorAssertions`, polling, pending-assertion registry |
|
|
44
|
-
| `src/assert/expect.test.ts` | create | value matcher tests |
|
|
45
|
-
| `src/assert/expect-locator.test.ts` | create | locator matcher + registry tests |
|
|
46
|
-
| `src/locator/locator.ts` | modify | public reads: `selector`, `isVisible`, `textContent`, `inputValue`, `getAttribute`, `count` |
|
|
47
|
-
| `src/locator/locator.test.ts` | create | unit tests for the reads |
|
|
48
|
-
| `src/testing/stub-connector.ts` | create | fake `BiDiConnector` answering `script.evaluate` from a queue |
|
|
49
|
-
| `src/types/config.d.ts` | modify | `expect?: { timeout?: number }` |
|
|
50
|
-
| `src/types/test.d.ts` | modify | `TestError.type` adds `"assertion"`, optional `expected` / `actual` |
|
|
51
|
-
| `src/runner/test-error.ts` | create | `toTestError(err)` mapping thrown values to `TestError` |
|
|
52
|
-
| `src/runner/test-error.test.ts` | create | unit tests for `toTestError` |
|
|
53
|
-
| `src/runner/test-runner.ts` | modify | use `toTestError`, missing-await guard, reset registry per test |
|
|
54
|
-
| `src/tests/index.spec.ts` | modify | end-to-end assertions |
|
|
55
|
-
|
|
56
|
-
---
|
|
57
|
-
|
|
58
|
-
### Task 1: Test script, `AssertionError` and `format`
|
|
59
|
-
|
|
60
|
-
**Files:**
|
|
61
|
-
- Modify: `package.json` (`scripts.test`)
|
|
62
|
-
- Create: `src/assert/assertion-error.ts`
|
|
63
|
-
- Test: `src/assert/assertion-error.test.ts`
|
|
64
|
-
|
|
65
|
-
**Interfaces:**
|
|
66
|
-
- Consumes: nothing.
|
|
67
|
-
- Produces:
|
|
68
|
-
- `export function format(value: unknown): string`
|
|
69
|
-
- `export function toReportValue(value: unknown): unknown` — RegExp → `format(value)`, anything else unchanged.
|
|
70
|
-
- `export interface AssertionErrorDetails { matcher: string; expected: unknown; actual: unknown; locator?: string | undefined }`
|
|
71
|
-
- `export class AssertionError extends Error { readonly matcher: string; readonly expected: unknown; readonly actual: unknown; readonly locator: string | undefined; constructor(details: AssertionErrorDetails) }` with `name === "AssertionError"`.
|
|
72
|
-
|
|
73
|
-
- [ ] **Step 1: Point the test script at tsx**
|
|
74
|
-
|
|
75
|
-
In `package.json` replace
|
|
76
|
-
|
|
77
|
-
```json
|
|
78
|
-
"test": "echo \"Error: no test specified\" && exit 1"
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
with
|
|
82
|
-
|
|
83
|
-
```json
|
|
84
|
-
"test": "tsx --test \"src/**/*.test.ts\""
|
|
85
|
-
```
|
|
86
|
-
|
|
87
|
-
- [ ] **Step 2: Write the failing test**
|
|
88
|
-
|
|
89
|
-
Create `src/assert/assertion-error.test.ts`:
|
|
90
|
-
|
|
91
|
-
```ts
|
|
92
|
-
import { test } from "node:test";
|
|
93
|
-
import assert from "node:assert/strict";
|
|
94
|
-
import { AssertionError, format, toReportValue } from "./assertion-error.js";
|
|
95
|
-
|
|
96
|
-
test("format quotes strings, prints regexps and inspects other values", () => {
|
|
97
|
-
assert.equal(format("Home"), '"Home"');
|
|
98
|
-
assert.equal(format(/err/i), "/err/i");
|
|
99
|
-
assert.equal(format(42), "42");
|
|
100
|
-
assert.equal(format(null), "null");
|
|
101
|
-
assert.equal(format({ a: 1 }), "{ a: 1 }");
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
test("toReportValue stringifies regexps and keeps other values", () => {
|
|
105
|
-
assert.equal(toReportValue(/err/i), "/err/i");
|
|
106
|
-
assert.equal(toReportValue("Home"), "Home");
|
|
107
|
-
assert.deepEqual(toReportValue({ a: 1 }), { a: 1 });
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test("locator assertion message includes the locator line", () => {
|
|
111
|
-
const err = new AssertionError({
|
|
112
|
-
matcher: "toHaveText",
|
|
113
|
-
expected: "Contact",
|
|
114
|
-
actual: "Home",
|
|
115
|
-
locator: "//h1",
|
|
116
|
-
});
|
|
117
|
-
assert.ok(err instanceof Error);
|
|
118
|
-
assert.equal(err.name, "AssertionError");
|
|
119
|
-
assert.equal(
|
|
120
|
-
err.message,
|
|
121
|
-
'expect(locator).toHaveText\n locator: //h1\n expected: "Contact"\n received: "Home"',
|
|
122
|
-
);
|
|
123
|
-
assert.equal(err.matcher, "toHaveText");
|
|
124
|
-
assert.equal(err.expected, "Contact");
|
|
125
|
-
assert.equal(err.actual, "Home");
|
|
126
|
-
assert.equal(err.locator, "//h1");
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
test("value assertion message has no locator line", () => {
|
|
130
|
-
const err = new AssertionError({ matcher: "toBe", expected: 2, actual: 1 });
|
|
131
|
-
assert.equal(err.message, "expect(value).toBe\n expected: 2\n received: 1");
|
|
132
|
-
assert.equal(err.locator, undefined);
|
|
133
|
-
});
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
- [ ] **Step 3: Run it to verify it fails**
|
|
137
|
-
|
|
138
|
-
Run: `npm test`
|
|
139
|
-
Expected: FAIL — `Cannot find module '.../src/assert/assertion-error.js'` (or `ERR_MODULE_NOT_FOUND`), `ℹ fail 1`.
|
|
140
|
-
|
|
141
|
-
- [ ] **Step 4: Implement**
|
|
142
|
-
|
|
143
|
-
Create `src/assert/assertion-error.ts`:
|
|
144
|
-
|
|
145
|
-
```ts
|
|
146
|
-
import { inspect } from "node:util";
|
|
147
|
-
|
|
148
|
-
/** Formats a value for assertion messages */
|
|
149
|
-
export function format(value: unknown): string {
|
|
150
|
-
if (typeof value === "string") return JSON.stringify(value);
|
|
151
|
-
if (value instanceof RegExp) return value.toString();
|
|
152
|
-
return inspect(value, { depth: 2 });
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/** Converts a value into something JSON can store in the report */
|
|
156
|
-
export function toReportValue(value: unknown): unknown {
|
|
157
|
-
return value instanceof RegExp ? format(value) : value;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export interface AssertionErrorDetails {
|
|
161
|
-
/** Matcher name, prefixed with `not.` when negated */
|
|
162
|
-
matcher: string;
|
|
163
|
-
expected: unknown;
|
|
164
|
-
actual: unknown;
|
|
165
|
-
/** XPath of the asserted locator, only for locator assertions */
|
|
166
|
-
locator?: string | undefined;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export class AssertionError extends Error {
|
|
170
|
-
public readonly matcher: string;
|
|
171
|
-
public readonly expected: unknown;
|
|
172
|
-
public readonly actual: unknown;
|
|
173
|
-
public readonly locator: string | undefined;
|
|
174
|
-
|
|
175
|
-
constructor({ matcher, expected, actual, locator }: AssertionErrorDetails) {
|
|
176
|
-
const subject = locator === undefined ? "value" : "locator";
|
|
177
|
-
const lines = [`expect(${subject}).${matcher}`];
|
|
178
|
-
if (locator !== undefined) {
|
|
179
|
-
lines.push(` locator: ${locator}`);
|
|
180
|
-
}
|
|
181
|
-
lines.push(` expected: ${format(expected)}`, ` received: ${format(actual)}`);
|
|
182
|
-
|
|
183
|
-
super(lines.join("\n"));
|
|
184
|
-
this.name = "AssertionError";
|
|
185
|
-
this.matcher = matcher;
|
|
186
|
-
this.expected = expected;
|
|
187
|
-
this.actual = actual;
|
|
188
|
-
this.locator = locator;
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
- [ ] **Step 5: Run tests to verify they pass**
|
|
194
|
-
|
|
195
|
-
Run: `npm test`
|
|
196
|
-
Expected: `ℹ pass 4`, `ℹ fail 0`.
|
|
197
|
-
|
|
198
|
-
- [ ] **Step 6: Type-check**
|
|
199
|
-
|
|
200
|
-
Run: `npx tsc --noEmit -p .`
|
|
201
|
-
Expected: only `src/config/default.ts(3,14): error TS2739`.
|
|
202
|
-
|
|
203
|
-
- [ ] **Step 7: Commit**
|
|
204
|
-
|
|
205
|
-
```bash
|
|
206
|
-
git add package.json src/assert/assertion-error.ts src/assert/assertion-error.test.ts
|
|
207
|
-
git commit -m "feat(assert): add AssertionError and node:test script"
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
---
|
|
211
|
-
|
|
212
|
-
### Task 2: Value matchers
|
|
213
|
-
|
|
214
|
-
**Files:**
|
|
215
|
-
- Modify: `src/assert/expect.ts` (currently empty)
|
|
216
|
-
- Test: `src/assert/expect.test.ts`
|
|
217
|
-
|
|
218
|
-
**Interfaces:**
|
|
219
|
-
- Consumes: `AssertionError` from `./assertion-error.js` (Task 1).
|
|
220
|
-
- Produces:
|
|
221
|
-
- `export function expect<T>(value: T): ValueAssertions<T>` (Task 4 turns this into overloads)
|
|
222
|
-
- `export class ValueAssertions<T>` with `get not(): ValueAssertions<T>` and matchers returning `void`: `toBe(expected: unknown)`, `toEqual(expected: unknown)`, `toBeTruthy()`, `toBeFalsy()`, `toContain(item: unknown)`, `toMatch(pattern: string | RegExp)`, `toBeGreaterThan(expected: number | bigint)`, `toBeGreaterThanOrEqual(...)`, `toBeLessThan(...)`, `toBeLessThanOrEqual(...)`.
|
|
223
|
-
- `function matchesText(actual: string, expected: string | RegExp, mode: "exact" | "contains"): boolean` (module-private; reused by Task 4).
|
|
224
|
-
|
|
225
|
-
- [ ] **Step 1: Write the failing test**
|
|
226
|
-
|
|
227
|
-
Create `src/assert/expect.test.ts`:
|
|
228
|
-
|
|
229
|
-
```ts
|
|
230
|
-
import { test } from "node:test";
|
|
231
|
-
import assert from "node:assert/strict";
|
|
232
|
-
import { expect } from "./expect.js";
|
|
233
|
-
import { AssertionError } from "./assertion-error.js";
|
|
234
|
-
|
|
235
|
-
/** Asserts `fn` throws an AssertionError with the given matcher/expected/actual */
|
|
236
|
-
function assertFails(
|
|
237
|
-
fn: () => void,
|
|
238
|
-
matcher: string,
|
|
239
|
-
expected: unknown,
|
|
240
|
-
actual: unknown,
|
|
241
|
-
) {
|
|
242
|
-
assert.throws(fn, (err: unknown) => {
|
|
243
|
-
assert.ok(err instanceof AssertionError, "expected an AssertionError");
|
|
244
|
-
assert.equal(err.matcher, matcher);
|
|
245
|
-
assert.deepEqual(err.expected, expected);
|
|
246
|
-
assert.deepEqual(err.actual, actual);
|
|
247
|
-
return true;
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
test("toBe uses Object.is", () => {
|
|
252
|
-
expect(1).toBe(1);
|
|
253
|
-
expect(NaN).toBe(NaN);
|
|
254
|
-
assertFails(() => expect(1).toBe(2), "toBe", 2, 1);
|
|
255
|
-
assertFails(() => expect({ a: 1 }).toBe({ a: 1 }), "toBe", { a: 1 }, { a: 1 });
|
|
256
|
-
expect(1).not.toBe(2);
|
|
257
|
-
assertFails(() => expect(1).not.toBe(1), "not.toBe", 1, 1);
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
test("toEqual compares deeply", () => {
|
|
261
|
-
expect({ a: [1, 2] }).toEqual({ a: [1, 2] });
|
|
262
|
-
assertFails(() => expect({ a: 1 }).toEqual({ a: 2 }), "toEqual", { a: 2 }, { a: 1 });
|
|
263
|
-
expect({ a: 1 }).not.toEqual({ a: 2 });
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
test("toBeTruthy / toBeFalsy", () => {
|
|
267
|
-
expect(1).toBeTruthy();
|
|
268
|
-
expect("").toBeFalsy();
|
|
269
|
-
assertFails(() => expect(0).toBeTruthy(), "toBeTruthy", "truthy", 0);
|
|
270
|
-
assertFails(() => expect("x").toBeFalsy(), "toBeFalsy", "falsy", "x");
|
|
271
|
-
expect(0).not.toBeTruthy();
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
test("toContain works on arrays and strings", () => {
|
|
275
|
-
expect([1, 2, 3]).toContain(2);
|
|
276
|
-
expect("hello world").toContain("world");
|
|
277
|
-
assertFails(() => expect([1, 2]).toContain(3), "toContain", 3, [1, 2]);
|
|
278
|
-
expect("abc").not.toContain("z");
|
|
279
|
-
assert.throws(() => expect(42).toContain(4), TypeError);
|
|
280
|
-
assert.throws(() => expect("abc").toContain(1), TypeError);
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
test("toMatch accepts substrings and regexps", () => {
|
|
284
|
-
expect("Contact us").toMatch("Contact");
|
|
285
|
-
expect("Contact us").toMatch(/contact/i);
|
|
286
|
-
expect("aaa").toMatch(/a/g);
|
|
287
|
-
expect("aaa").toMatch(/a/g); // global regexps must not keep lastIndex state
|
|
288
|
-
assertFails(() => expect("Home").toMatch(/contact/), "toMatch", /contact/, "Home");
|
|
289
|
-
expect("Home").not.toMatch("Contact");
|
|
290
|
-
assert.throws(() => expect(1).toMatch("1"), TypeError);
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
test("number comparisons", () => {
|
|
294
|
-
expect(3).toBeGreaterThan(2);
|
|
295
|
-
expect(3).toBeGreaterThanOrEqual(3);
|
|
296
|
-
expect(2).toBeLessThan(3);
|
|
297
|
-
expect(3).toBeLessThanOrEqual(3);
|
|
298
|
-
expect(3n).toBeGreaterThan(2);
|
|
299
|
-
assertFails(() => expect(2).toBeGreaterThan(3), "toBeGreaterThan", 3, 2);
|
|
300
|
-
assertFails(() => expect(3).not.toBeLessThanOrEqual(3), "not.toBeLessThanOrEqual", 3, 3);
|
|
301
|
-
assert.throws(() => expect("3").toBeGreaterThan(2), TypeError);
|
|
302
|
-
});
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
- [ ] **Step 2: Run it to verify it fails**
|
|
306
|
-
|
|
307
|
-
Run: `npm test`
|
|
308
|
-
Expected: FAIL — `expect.test.ts` errors with `does not provide an export named 'expect'` (the file is empty); Task 1 tests still pass.
|
|
309
|
-
|
|
310
|
-
- [ ] **Step 3: Implement**
|
|
311
|
-
|
|
312
|
-
Write `src/assert/expect.ts`:
|
|
313
|
-
|
|
314
|
-
```ts
|
|
315
|
-
import { isDeepStrictEqual } from "node:util";
|
|
316
|
-
import { AssertionError } from "./assertion-error.js";
|
|
317
|
-
|
|
318
|
-
function matchesText(
|
|
319
|
-
actual: string,
|
|
320
|
-
expected: string | RegExp,
|
|
321
|
-
mode: "exact" | "contains",
|
|
322
|
-
) {
|
|
323
|
-
// search() ignores lastIndex, so global regexps behave the same on every call
|
|
324
|
-
if (expected instanceof RegExp) return actual.search(expected) !== -1;
|
|
325
|
-
return mode === "exact" ? actual === expected : actual.includes(expected);
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
function isNumeric(value: unknown): value is number | bigint {
|
|
329
|
-
return typeof value === "number" || typeof value === "bigint";
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
export class ValueAssertions<T> {
|
|
333
|
-
private readonly actual: T;
|
|
334
|
-
private readonly negated: boolean;
|
|
335
|
-
|
|
336
|
-
constructor(actual: T, negated = false) {
|
|
337
|
-
this.actual = actual;
|
|
338
|
-
this.negated = negated;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
/** Inverts the next matcher */
|
|
342
|
-
get not(): ValueAssertions<T> {
|
|
343
|
-
return new ValueAssertions(this.actual, !this.negated);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
private assert(pass: boolean, matcher: string, expected: unknown) {
|
|
347
|
-
if (pass !== this.negated) return;
|
|
348
|
-
throw new AssertionError({
|
|
349
|
-
matcher: this.negated ? `not.${matcher}` : matcher,
|
|
350
|
-
expected,
|
|
351
|
-
actual: this.actual,
|
|
352
|
-
});
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
private compare(
|
|
356
|
-
matcher: string,
|
|
357
|
-
expected: number | bigint,
|
|
358
|
-
check: (actual: number | bigint, expected: number | bigint) => boolean,
|
|
359
|
-
) {
|
|
360
|
-
const actual = this.actual;
|
|
361
|
-
if (!isNumeric(actual) || !isNumeric(expected)) {
|
|
362
|
-
throw new TypeError(
|
|
363
|
-
`${matcher} expects numbers, received ${typeof actual} and ${typeof expected}`,
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
this.assert(check(actual, expected), matcher, expected);
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/** Strict identity (`Object.is`) */
|
|
370
|
-
toBe(expected: unknown) {
|
|
371
|
-
this.assert(Object.is(this.actual, expected), "toBe", expected);
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/** Deep structural equality */
|
|
375
|
-
toEqual(expected: unknown) {
|
|
376
|
-
this.assert(isDeepStrictEqual(this.actual, expected), "toEqual", expected);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
toBeTruthy() {
|
|
380
|
-
this.assert(Boolean(this.actual), "toBeTruthy", "truthy");
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
toBeFalsy() {
|
|
384
|
-
this.assert(!this.actual, "toBeFalsy", "falsy");
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/** Array includes `item`, or string contains substring `item` */
|
|
388
|
-
toContain(item: unknown) {
|
|
389
|
-
const actual = this.actual;
|
|
390
|
-
if (typeof actual === "string") {
|
|
391
|
-
if (typeof item !== "string") {
|
|
392
|
-
throw new TypeError(`toContain on a string expects a string, received ${typeof item}`);
|
|
393
|
-
}
|
|
394
|
-
this.assert(actual.includes(item), "toContain", item);
|
|
395
|
-
return;
|
|
396
|
-
}
|
|
397
|
-
if (Array.isArray(actual)) {
|
|
398
|
-
this.assert(actual.includes(item), "toContain", item);
|
|
399
|
-
return;
|
|
400
|
-
}
|
|
401
|
-
throw new TypeError(`toContain expects an array or string, received ${typeof actual}`);
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
/** String contains `pattern` (string) or matches it (RegExp) */
|
|
405
|
-
toMatch(pattern: string | RegExp) {
|
|
406
|
-
const actual = this.actual;
|
|
407
|
-
if (typeof actual !== "string") {
|
|
408
|
-
throw new TypeError(`toMatch expects a string, received ${typeof actual}`);
|
|
409
|
-
}
|
|
410
|
-
this.assert(matchesText(actual, pattern, "contains"), "toMatch", pattern);
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
toBeGreaterThan(expected: number | bigint) {
|
|
414
|
-
this.compare("toBeGreaterThan", expected, (a, b) => a > b);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
toBeGreaterThanOrEqual(expected: number | bigint) {
|
|
418
|
-
this.compare("toBeGreaterThanOrEqual", expected, (a, b) => a >= b);
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
toBeLessThan(expected: number | bigint) {
|
|
422
|
-
this.compare("toBeLessThan", expected, (a, b) => a < b);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
toBeLessThanOrEqual(expected: number | bigint) {
|
|
426
|
-
this.compare("toBeLessThanOrEqual", expected, (a, b) => a <= b);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
export function expect<T>(value: T): ValueAssertions<T> {
|
|
431
|
-
return new ValueAssertions(value);
|
|
432
|
-
}
|
|
433
|
-
```
|
|
434
|
-
|
|
435
|
-
- [ ] **Step 4: Run tests to verify they pass**
|
|
436
|
-
|
|
437
|
-
Run: `npm test`
|
|
438
|
-
Expected: `ℹ pass 10`, `ℹ fail 0`.
|
|
439
|
-
|
|
440
|
-
- [ ] **Step 5: Type-check**
|
|
441
|
-
|
|
442
|
-
Run: `npx tsc --noEmit -p .`
|
|
443
|
-
Expected: only the pre-existing `src/config/default.ts` error.
|
|
444
|
-
|
|
445
|
-
- [ ] **Step 6: Commit**
|
|
446
|
-
|
|
447
|
-
```bash
|
|
448
|
-
git add src/assert/expect.ts src/assert/expect.test.ts
|
|
449
|
-
git commit -m "feat(assert): add value matchers"
|
|
450
|
-
```
|
|
451
|
-
|
|
452
|
-
---
|
|
453
|
-
|
|
454
|
-
### Task 3: Locator read methods + stub connector
|
|
455
|
-
|
|
456
|
-
**Files:**
|
|
457
|
-
- Create: `src/testing/stub-connector.ts`
|
|
458
|
-
- Modify: `src/locator/locator.ts` (`buildExpression` at lines 51-75; new methods after `getBoundingClientRect`, before `wait`)
|
|
459
|
-
- Test: `src/locator/locator.test.ts`
|
|
460
|
-
|
|
461
|
-
**Interfaces:**
|
|
462
|
-
- Consumes: existing `Locator` constructor `(xpath: string, biDiConnector: BiDiConnector, contextId: BrowsingContext, options?: LocatorOptions)` and private `evaluate(expression: string): Promise<RemoteValue>` (throws `Failed to locate element <xpath>. Details: <text>` on an evaluate exception).
|
|
463
|
-
- Produces:
|
|
464
|
-
- `stubConnector(...responses: StubResponse[]): { connector: BiDiConnector; expressions: string[] }` — each `script.evaluate` consumes the next response; the last one repeats. `type StubResponse = RemoteValue | { exception: string }`.
|
|
465
|
-
- On `Locator`:
|
|
466
|
-
- `get selector(): string` — normalised XPath (`"h1"` → `"//h1"`).
|
|
467
|
-
- `isVisible(): Promise<boolean>`
|
|
468
|
-
- `textContent(): Promise<string | null>` (trimmed)
|
|
469
|
-
- `inputValue(): Promise<string | null>`
|
|
470
|
-
- `getAttribute(name: string): Promise<string | null>`
|
|
471
|
-
- `count(): Promise<number>`
|
|
472
|
-
|
|
473
|
-
- [ ] **Step 1: Create the stub connector**
|
|
474
|
-
|
|
475
|
-
Create `src/testing/stub-connector.ts`:
|
|
476
|
-
|
|
477
|
-
```ts
|
|
478
|
-
import type { BiDiConnector } from "../transport/bidi-connection.js";
|
|
479
|
-
import type { RemoteValue } from "../types/bidi-modules/script.js";
|
|
480
|
-
|
|
481
|
-
export type StubResponse = RemoteValue | { exception: string };
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
* Fake BiDiConnector for unit tests. Every `script.evaluate` answers with the next
|
|
485
|
-
* queued response; the last response repeats. Evaluated expressions are recorded.
|
|
486
|
-
*/
|
|
487
|
-
export function stubConnector(...responses: StubResponse[]) {
|
|
488
|
-
if (responses.length === 0) {
|
|
489
|
-
throw new Error("stubConnector needs at least one response");
|
|
490
|
-
}
|
|
491
|
-
const expressions: string[] = [];
|
|
492
|
-
let calls = 0;
|
|
493
|
-
|
|
494
|
-
const connector = {
|
|
495
|
-
async send(method: string, params: { expression: string }) {
|
|
496
|
-
if (method !== "script.evaluate") {
|
|
497
|
-
throw new Error(`stubConnector does not handle ${method}`);
|
|
498
|
-
}
|
|
499
|
-
expressions.push(params.expression);
|
|
500
|
-
const response = responses[Math.min(calls++, responses.length - 1)]!;
|
|
501
|
-
if ("exception" in response) {
|
|
502
|
-
return {
|
|
503
|
-
type: "exception",
|
|
504
|
-
exceptionDetails: { text: response.exception },
|
|
505
|
-
realm: "stub",
|
|
506
|
-
};
|
|
507
|
-
}
|
|
508
|
-
return { type: "success", result: response, realm: "stub" };
|
|
509
|
-
},
|
|
510
|
-
};
|
|
511
|
-
|
|
512
|
-
return { connector: connector as unknown as BiDiConnector, expressions };
|
|
513
|
-
}
|
|
514
|
-
```
|
|
515
|
-
|
|
516
|
-
- [ ] **Step 2: Write the failing test**
|
|
517
|
-
|
|
518
|
-
Create `src/locator/locator.test.ts`:
|
|
519
|
-
|
|
520
|
-
```ts
|
|
521
|
-
import { test } from "node:test";
|
|
522
|
-
import assert from "node:assert/strict";
|
|
523
|
-
import Locator from "./locator.js";
|
|
524
|
-
import { stubConnector, type StubResponse } from "../testing/stub-connector.js";
|
|
525
|
-
|
|
526
|
-
function locatorWith(xpath: string, ...responses: StubResponse[]) {
|
|
527
|
-
const { connector, expressions } = stubConnector(...responses);
|
|
528
|
-
return { locator: new Locator(xpath, connector, "ctx"), expressions };
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
test("selector normalises the xpath", () => {
|
|
532
|
-
assert.equal(locatorWith("h1", { type: "null" }).locator.selector, "//h1");
|
|
533
|
-
assert.equal(locatorWith("//div", { type: "null" }).locator.selector, "//div");
|
|
534
|
-
assert.equal(
|
|
535
|
-
locatorWith("a[text()='Start']", { type: "null" }).locator.selector,
|
|
536
|
-
'//a[text()="Start"]',
|
|
537
|
-
);
|
|
538
|
-
});
|
|
539
|
-
|
|
540
|
-
test("textContent returns the string, or null when the element is missing", async () => {
|
|
541
|
-
const found = locatorWith("h1", { type: "string", value: "Contact" });
|
|
542
|
-
assert.equal(await found.locator.textContent(), "Contact");
|
|
543
|
-
assert.match(found.expressions[0]!, /\/\/h1/);
|
|
544
|
-
assert.match(found.expressions[0]!, /FIRST_ORDERED_NODE_TYPE/);
|
|
545
|
-
|
|
546
|
-
const missing = locatorWith("h1", { type: "null" });
|
|
547
|
-
assert.equal(await missing.locator.textContent(), null);
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
test("inputValue returns the value, or null", async () => {
|
|
551
|
-
assert.equal(
|
|
552
|
-
await locatorWith("input", { type: "string", value: "test" }).locator.inputValue(),
|
|
553
|
-
"test",
|
|
554
|
-
);
|
|
555
|
-
assert.equal(await locatorWith("input", { type: "null" }).locator.inputValue(), null);
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
test("getAttribute passes the attribute name as a JSON string", async () => {
|
|
559
|
-
const { locator, expressions } = locatorWith("a", { type: "string", value: "/home" });
|
|
560
|
-
assert.equal(await locator.getAttribute("href"), "/home");
|
|
561
|
-
assert.match(expressions[0]!, /getAttribute\("href"\)/);
|
|
562
|
-
});
|
|
563
|
-
|
|
564
|
-
test("isVisible returns the boolean result", async () => {
|
|
565
|
-
assert.equal(await locatorWith("div", { type: "boolean", value: true }).locator.isVisible(), true);
|
|
566
|
-
assert.equal(await locatorWith("div", { type: "boolean", value: false }).locator.isVisible(), false);
|
|
567
|
-
});
|
|
568
|
-
|
|
569
|
-
test("count evaluates an XPath count()", async () => {
|
|
570
|
-
const { locator, expressions } = locatorWith("li", { type: "number", value: 3 });
|
|
571
|
-
assert.equal(await locator.count(), 3);
|
|
572
|
-
assert.match(expressions[0]!, /count\(\/\/li\)/);
|
|
573
|
-
assert.match(expressions[0]!, /NUMBER_TYPE/);
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
test("reads throw on an evaluate exception", async () => {
|
|
577
|
-
const { locator } = locatorWith("h1[", { exception: "SyntaxError: bad xpath" });
|
|
578
|
-
await assert.rejects(locator.textContent(), /Failed to locate element/);
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
test("reads reject unexpected result types", async () => {
|
|
582
|
-
const { locator } = locatorWith("h1", { type: "number", value: 1 });
|
|
583
|
-
await assert.rejects(locator.textContent(), /Expected a string or null/);
|
|
584
|
-
});
|
|
585
|
-
```
|
|
586
|
-
|
|
587
|
-
- [ ] **Step 3: Run it to verify it fails**
|
|
588
|
-
|
|
589
|
-
Run: `npm test`
|
|
590
|
-
Expected: FAIL — `locator.test.ts` fails with `TypeError: ... is not a function` / `selector` is `undefined` (`ℹ fail` ≥ 7). Tasks 1-2 tests still pass.
|
|
591
|
-
|
|
592
|
-
- [ ] **Step 4: Implement**
|
|
593
|
-
|
|
594
|
-
In `src/locator/locator.ts`, replace the start of `buildExpression`:
|
|
595
|
-
|
|
596
|
-
```ts
|
|
597
|
-
/** Builds the expression to evaluate on */
|
|
598
|
-
private buildExpression(action?: string) {
|
|
599
|
-
let parsedXpath = this.xpath.startsWith("//")
|
|
600
|
-
? this.xpath
|
|
601
|
-
: "//" + this.xpath;
|
|
602
|
-
parsedXpath = parsedXpath.replaceAll("'", '"');
|
|
603
|
-
|
|
604
|
-
const xpathResultType = this.useMultiple
|
|
605
|
-
```
|
|
606
|
-
|
|
607
|
-
with
|
|
608
|
-
|
|
609
|
-
```ts
|
|
610
|
-
/** Normalises the xpath: always starts with `//` and uses double quotes */
|
|
611
|
-
private parsedXpath() {
|
|
612
|
-
const xpath = this.xpath.startsWith("//") ? this.xpath : "//" + this.xpath;
|
|
613
|
-
return xpath.replaceAll("'", '"');
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/** The normalised xpath this locator evaluates */
|
|
617
|
-
public get selector(): string {
|
|
618
|
-
return this.parsedXpath();
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/** Builds the expression to evaluate on */
|
|
622
|
-
private buildExpression(action?: string) {
|
|
623
|
-
const parsedXpath = this.parsedXpath();
|
|
624
|
-
|
|
625
|
-
const xpathResultType = this.useMultiple
|
|
626
|
-
```
|
|
627
|
-
|
|
628
|
-
(the rest of `buildExpression` is unchanged; it still uses `parsedXpath`).
|
|
629
|
-
|
|
630
|
-
Then insert after `getBoundingClientRect()` (before the `/** Wait for certain time` comment):
|
|
631
|
-
|
|
632
|
-
```ts
|
|
633
|
-
/** Evaluates `body` with `el` bound to the first matching element, or null when nothing matches */
|
|
634
|
-
private readElement(body: string) {
|
|
635
|
-
return this.evaluate(`(() => {
|
|
636
|
-
const el = document.evaluate(
|
|
637
|
-
'${this.parsedXpath()}',
|
|
638
|
-
document,
|
|
639
|
-
null,
|
|
640
|
-
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
|
641
|
-
null
|
|
642
|
-
).singleNodeValue;
|
|
643
|
-
${body}
|
|
644
|
-
})()`);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
private stringOrNull(value: RemoteValue): string | null {
|
|
648
|
-
if (value.type === "string") return value.value;
|
|
649
|
-
if (value.type === "null") return null;
|
|
650
|
-
throw new Error(`Expected a string or null but received "${value.type}"`);
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/** Whether the element exists, has a non-empty box and is not hidden by CSS */
|
|
654
|
-
public async isVisible(): Promise<boolean> {
|
|
655
|
-
const result = await this.readElement(`
|
|
656
|
-
if (!el) return false;
|
|
657
|
-
const rect = el.getBoundingClientRect();
|
|
658
|
-
const style = getComputedStyle(el);
|
|
659
|
-
return rect.width > 0 && rect.height > 0 &&
|
|
660
|
-
style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0";
|
|
661
|
-
`);
|
|
662
|
-
return result.type === "boolean" && result.value;
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/** Trimmed text content, or null when the element is missing */
|
|
666
|
-
public async textContent(): Promise<string | null> {
|
|
667
|
-
return this.stringOrNull(
|
|
668
|
-
await this.readElement(`return el ? el.textContent.trim() : null;`),
|
|
669
|
-
);
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/** Value of an input/textarea/select, or null when missing */
|
|
673
|
-
public async inputValue(): Promise<string | null> {
|
|
674
|
-
return this.stringOrNull(
|
|
675
|
-
await this.readElement(`return el && typeof el.value === "string" ? el.value : null;`),
|
|
676
|
-
);
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/** Attribute value, or null when the element or attribute is missing */
|
|
680
|
-
public async getAttribute(name: string): Promise<string | null> {
|
|
681
|
-
return this.stringOrNull(
|
|
682
|
-
await this.readElement(`return el ? el.getAttribute(${JSON.stringify(name)}) : null;`),
|
|
683
|
-
);
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
/** Number of elements matching the xpath */
|
|
687
|
-
public async count(): Promise<number> {
|
|
688
|
-
const result = await this.evaluate(
|
|
689
|
-
`document.evaluate('count(${this.parsedXpath()})', document, null, XPathResult.NUMBER_TYPE, null).numberValue`,
|
|
690
|
-
);
|
|
691
|
-
if (result.type !== "number") {
|
|
692
|
-
throw new Error(`Expected a number but received "${result.type}"`);
|
|
693
|
-
}
|
|
694
|
-
return result.value;
|
|
695
|
-
}
|
|
696
|
-
```
|
|
697
|
-
|
|
698
|
-
`RemoteValue` is already imported as a type at the top of `locator.ts`.
|
|
699
|
-
|
|
700
|
-
- [ ] **Step 5: Run tests to verify they pass**
|
|
701
|
-
|
|
702
|
-
Run: `npm test`
|
|
703
|
-
Expected: `ℹ pass 18`, `ℹ fail 0`.
|
|
704
|
-
|
|
705
|
-
- [ ] **Step 6: Type-check**
|
|
706
|
-
|
|
707
|
-
Run: `npx tsc --noEmit -p .`
|
|
708
|
-
Expected: only the pre-existing `src/config/default.ts` error.
|
|
709
|
-
|
|
710
|
-
- [ ] **Step 7: Commit**
|
|
711
|
-
|
|
712
|
-
```bash
|
|
713
|
-
git add src/testing/stub-connector.ts src/locator/locator.ts src/locator/locator.test.ts
|
|
714
|
-
git commit -m "feat(locator): add null-safe read methods"
|
|
715
|
-
```
|
|
716
|
-
|
|
717
|
-
---
|
|
718
|
-
|
|
719
|
-
### Task 4: Locator matchers, polling and pending-assertion registry
|
|
720
|
-
|
|
721
|
-
**Files:**
|
|
722
|
-
- Modify: `src/types/config.d.ts` (add `expect` to `SamuraiTestConfig`)
|
|
723
|
-
- Modify: `src/assert/expect.ts`
|
|
724
|
-
- Test: `src/assert/expect-locator.test.ts`
|
|
725
|
-
|
|
726
|
-
**Interfaces:**
|
|
727
|
-
- Consumes: `Locator` reads + `selector` (Task 3), `AssertionError` (Task 1), `matchesText` + `ValueAssertions` (Task 2), `stubConnector` (Task 3), `readConfig(key)` from `src/config/config.ts` (`readConfig<K extends keyof SamuraiTestConfig>(key: K): Promise<SamuraiTestConfig[K]>`).
|
|
728
|
-
- Produces:
|
|
729
|
-
- `expect(locator: Locator): LocatorAssertions` and `expect<T>(value: T): ValueAssertions<T>` (overloads)
|
|
730
|
-
- `export interface LocatorAssertionOptions { timeout?: number }`
|
|
731
|
-
- `export class LocatorAssertions` with `get not(): LocatorAssertions` and matchers returning `Promise<void>`: `toBeVisible(options?)`, `toHaveText(expected: string | RegExp, options?)`, `toContainText(expected: string | RegExp, options?)`, `toHaveValue(expected: string | RegExp, options?)`, `toHaveAttribute(name: string, expected: string | RegExp, options?)`, `toHaveCount(expected: number, options?)`
|
|
732
|
-
- `export function takePendingAssertions(): string[]` — matcher names (e.g. `"not.toBeVisible"`) of locator assertions still running; clears the registry.
|
|
733
|
-
|
|
734
|
-
- [ ] **Step 1: Add the config option**
|
|
735
|
-
|
|
736
|
-
In `src/types/config.d.ts`, inside `SamuraiTestConfig` after `groups?: SamuraiGroup[];` add:
|
|
737
|
-
|
|
738
|
-
```ts
|
|
739
|
-
/** Options for `expect` assertions */
|
|
740
|
-
expect?: {
|
|
741
|
-
/**
|
|
742
|
-
* Time (ms) locator assertions keep retrying before failing
|
|
743
|
-
* @default 5000
|
|
744
|
-
*/
|
|
745
|
-
timeout?: number;
|
|
746
|
-
};
|
|
747
|
-
```
|
|
748
|
-
|
|
749
|
-
- [ ] **Step 2: Write the failing test**
|
|
750
|
-
|
|
751
|
-
Create `src/assert/expect-locator.test.ts`. Every call passes an explicit `timeout` so the tests never read `samurai.config.ts`.
|
|
752
|
-
|
|
753
|
-
```ts
|
|
754
|
-
import { test } from "node:test";
|
|
755
|
-
import assert from "node:assert/strict";
|
|
756
|
-
import Locator from "../locator/locator.js";
|
|
757
|
-
import { stubConnector, type StubResponse } from "../testing/stub-connector.js";
|
|
758
|
-
import { expect, takePendingAssertions } from "./expect.js";
|
|
759
|
-
import { AssertionError } from "./assertion-error.js";
|
|
760
|
-
|
|
761
|
-
function locatorWith(xpath: string, ...responses: StubResponse[]) {
|
|
762
|
-
const { connector, expressions } = stubConnector(...responses);
|
|
763
|
-
return { locator: new Locator(xpath, connector, "ctx"), expressions };
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
const text = (value: string): StubResponse => ({ type: "string", value });
|
|
767
|
-
const missing: StubResponse = { type: "null" };
|
|
768
|
-
const bool = (value: boolean): StubResponse => ({ type: "boolean", value });
|
|
769
|
-
|
|
770
|
-
test("toHaveText retries until the text matches", async () => {
|
|
771
|
-
const { locator, expressions } = locatorWith("h1", missing, missing, text("Contact"));
|
|
772
|
-
await expect(locator).toHaveText("Contact", { timeout: 2000 });
|
|
773
|
-
assert.equal(expressions.length, 3);
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
test("times out with the last received value", async () => {
|
|
777
|
-
const { locator } = locatorWith("h1", text("Loading"), text("Home"));
|
|
778
|
-
const start = Date.now();
|
|
779
|
-
await assert.rejects(expect(locator).toHaveText("Contact", { timeout: 250 }), (err) => {
|
|
780
|
-
assert.ok(err instanceof AssertionError);
|
|
781
|
-
assert.equal(err.matcher, "toHaveText");
|
|
782
|
-
assert.equal(err.expected, "Contact");
|
|
783
|
-
assert.equal(err.actual, "Home");
|
|
784
|
-
assert.equal(err.locator, "//h1");
|
|
785
|
-
return true;
|
|
786
|
-
});
|
|
787
|
-
const elapsed = Date.now() - start;
|
|
788
|
-
assert.ok(elapsed >= 250 && elapsed < 1000, `elapsed ${elapsed}ms`);
|
|
789
|
-
});
|
|
790
|
-
|
|
791
|
-
test("timeout 0 reads exactly once", async () => {
|
|
792
|
-
const { locator, expressions } = locatorWith("h1", text("Home"));
|
|
793
|
-
await assert.rejects(expect(locator).toHaveText("Contact", { timeout: 0 }), AssertionError);
|
|
794
|
-
assert.equal(expressions.length, 1);
|
|
795
|
-
});
|
|
796
|
-
|
|
797
|
-
test("read errors are rethrown without retrying", async () => {
|
|
798
|
-
const { locator, expressions } = locatorWith("h1[", { exception: "SyntaxError" });
|
|
799
|
-
await assert.rejects(expect(locator).toBeVisible({ timeout: 2000 }), (err) => {
|
|
800
|
-
assert.ok(!(err instanceof AssertionError));
|
|
801
|
-
assert.match((err as Error).message, /Failed to locate element/);
|
|
802
|
-
return true;
|
|
803
|
-
});
|
|
804
|
-
assert.equal(expressions.length, 1);
|
|
805
|
-
});
|
|
806
|
-
|
|
807
|
-
test("not.toBeVisible waits until the element is hidden", async () => {
|
|
808
|
-
const { locator, expressions } = locatorWith("div", bool(true), bool(false));
|
|
809
|
-
await expect(locator).not.toBeVisible({ timeout: 2000 });
|
|
810
|
-
assert.equal(expressions.length, 2);
|
|
811
|
-
});
|
|
812
|
-
|
|
813
|
-
test("negated failure is named not.<matcher>", async () => {
|
|
814
|
-
const { locator } = locatorWith("h1", text("Contact"));
|
|
815
|
-
await assert.rejects(expect(locator).not.toHaveText("Contact", { timeout: 0 }), (err) => {
|
|
816
|
-
assert.ok(err instanceof AssertionError);
|
|
817
|
-
assert.equal(err.matcher, "not.toHaveText");
|
|
818
|
-
return true;
|
|
819
|
-
});
|
|
820
|
-
});
|
|
821
|
-
|
|
822
|
-
test("toHaveText is exact, toContainText is a substring, both accept regexps", async () => {
|
|
823
|
-
const { locator } = locatorWith("p", text("Hello world"));
|
|
824
|
-
await assert.rejects(expect(locator).toHaveText("Hello", { timeout: 0 }), AssertionError);
|
|
825
|
-
await expect(locator).toContainText("Hello", { timeout: 0 });
|
|
826
|
-
await expect(locator).toHaveText(/world$/, { timeout: 0 });
|
|
827
|
-
await expect(locator).toContainText(/WORLD/i, { timeout: 0 });
|
|
828
|
-
});
|
|
829
|
-
|
|
830
|
-
test("a missing element never matches text", async () => {
|
|
831
|
-
const { locator } = locatorWith("p", missing);
|
|
832
|
-
await assert.rejects(expect(locator).toContainText("", { timeout: 0 }), (err) => {
|
|
833
|
-
assert.ok(err instanceof AssertionError);
|
|
834
|
-
assert.equal(err.actual, null);
|
|
835
|
-
return true;
|
|
836
|
-
});
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
test("toHaveValue, toHaveAttribute and toHaveCount", async () => {
|
|
840
|
-
await expect(locatorWith("input", text("test")).locator).toHaveValue("test", { timeout: 0 });
|
|
841
|
-
await expect(locatorWith("a", text("/home")).locator).toHaveAttribute("href", /home/, { timeout: 0 });
|
|
842
|
-
await expect(locatorWith("li", { type: "number", value: 3 }).locator).toHaveCount(3, { timeout: 0 });
|
|
843
|
-
await assert.rejects(
|
|
844
|
-
expect(locatorWith("li", { type: "number", value: 2 }).locator).toHaveCount(3, { timeout: 0 }),
|
|
845
|
-
AssertionError,
|
|
846
|
-
);
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
test("expect(value) still returns value matchers", () => {
|
|
850
|
-
expect(1).toBe(1);
|
|
851
|
-
assert.throws(() => expect("a").toBe("b"), AssertionError);
|
|
852
|
-
});
|
|
853
|
-
|
|
854
|
-
test("takePendingAssertions lists assertions that were not awaited", async () => {
|
|
855
|
-
takePendingAssertions();
|
|
856
|
-
const { locator } = locatorWith("div", bool(false));
|
|
857
|
-
const pending = expect(locator).not.toBeVisible({ timeout: 200 });
|
|
858
|
-
const failing = expect(locator).toBeVisible({ timeout: 200 });
|
|
859
|
-
assert.deepEqual(takePendingAssertions(), ["not.toBeVisible", "toBeVisible"]);
|
|
860
|
-
assert.deepEqual(takePendingAssertions(), []);
|
|
861
|
-
await pending;
|
|
862
|
-
await assert.rejects(failing, AssertionError);
|
|
863
|
-
});
|
|
864
|
-
|
|
865
|
-
test("awaited assertions are not pending", async () => {
|
|
866
|
-
takePendingAssertions();
|
|
867
|
-
await expect(locatorWith("div", bool(true)).locator).toBeVisible({ timeout: 0 });
|
|
868
|
-
await assert.rejects(
|
|
869
|
-
expect(locatorWith("div", bool(false)).locator).toBeVisible({ timeout: 0 }),
|
|
870
|
-
AssertionError,
|
|
871
|
-
);
|
|
872
|
-
assert.deepEqual(takePendingAssertions(), []);
|
|
873
|
-
});
|
|
874
|
-
```
|
|
875
|
-
|
|
876
|
-
- [ ] **Step 3: Run it to verify it fails**
|
|
877
|
-
|
|
878
|
-
Run: `npm test`
|
|
879
|
-
Expected: FAIL — `does not provide an export named 'takePendingAssertions'`. Earlier test files still pass.
|
|
880
|
-
|
|
881
|
-
- [ ] **Step 4: Implement**
|
|
882
|
-
|
|
883
|
-
In `src/assert/expect.ts`:
|
|
884
|
-
|
|
885
|
-
a) Replace the imports at the top with:
|
|
886
|
-
|
|
887
|
-
```ts
|
|
888
|
-
import { isDeepStrictEqual } from "node:util";
|
|
889
|
-
import { setTimeout as sleep } from "node:timers/promises";
|
|
890
|
-
import { AssertionError } from "./assertion-error.js";
|
|
891
|
-
import Locator from "../locator/locator.js";
|
|
892
|
-
import { readConfig } from "../config/config.js";
|
|
893
|
-
|
|
894
|
-
const DEFAULT_EXPECT_TIMEOUT = 5000;
|
|
895
|
-
const POLL_INTERVAL = 100;
|
|
896
|
-
|
|
897
|
-
/** Locator assertions still running, mapped to their matcher name */
|
|
898
|
-
const pendingAssertions = new Map<Promise<void>, string>();
|
|
899
|
-
|
|
900
|
-
/**
|
|
901
|
-
* Returns the matcher names of locator assertions that are still running and stops
|
|
902
|
-
* tracking them. The runner calls this after a test to detect a missing `await`.
|
|
903
|
-
*/
|
|
904
|
-
export function takePendingAssertions(): string[] {
|
|
905
|
-
const names = [...pendingAssertions.values()];
|
|
906
|
-
pendingAssertions.clear();
|
|
907
|
-
return names;
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
function track(matcher: string, assertion: Promise<void>): Promise<void> {
|
|
911
|
-
pendingAssertions.set(assertion, matcher);
|
|
912
|
-
// Handling both outcomes also keeps an un-awaited failure from becoming an unhandled rejection
|
|
913
|
-
const untrack = () => {
|
|
914
|
-
pendingAssertions.delete(assertion);
|
|
915
|
-
};
|
|
916
|
-
assertion.then(untrack, untrack);
|
|
917
|
-
return assertion;
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
export interface LocatorAssertionOptions {
|
|
921
|
-
/** Time (ms) to keep retrying. Defaults to config `expect.timeout`, then 5000 */
|
|
922
|
-
timeout?: number;
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
async function resolveTimeout(options?: LocatorAssertionOptions) {
|
|
926
|
-
if (options?.timeout !== undefined) return options.timeout;
|
|
927
|
-
return (await readConfig("expect"))?.timeout ?? DEFAULT_EXPECT_TIMEOUT;
|
|
928
|
-
}
|
|
929
|
-
```
|
|
930
|
-
|
|
931
|
-
b) Keep `matchesText`, `isNumeric` and `ValueAssertions` from Task 2 unchanged.
|
|
932
|
-
|
|
933
|
-
c) Add after `ValueAssertions`:
|
|
934
|
-
|
|
935
|
-
```ts
|
|
936
|
-
export class LocatorAssertions {
|
|
937
|
-
private readonly locator: Locator;
|
|
938
|
-
private readonly negated: boolean;
|
|
939
|
-
|
|
940
|
-
constructor(locator: Locator, negated = false) {
|
|
941
|
-
this.locator = locator;
|
|
942
|
-
this.negated = negated;
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
/** Inverts the next matcher: it retries until the condition is false */
|
|
946
|
-
get not(): LocatorAssertions {
|
|
947
|
-
return new LocatorAssertions(this.locator, !this.negated);
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
/** Re-reads the element every POLL_INTERVAL ms until `check` passes or the timeout expires */
|
|
951
|
-
private assertEventually<V>(
|
|
952
|
-
matcher: string,
|
|
953
|
-
expected: unknown,
|
|
954
|
-
read: () => Promise<V>,
|
|
955
|
-
check: (actual: V) => boolean,
|
|
956
|
-
options?: LocatorAssertionOptions,
|
|
957
|
-
): Promise<void> {
|
|
958
|
-
const name = this.negated ? `not.${matcher}` : matcher;
|
|
959
|
-
|
|
960
|
-
return track(
|
|
961
|
-
name,
|
|
962
|
-
(async () => {
|
|
963
|
-
const timeout = await resolveTimeout(options);
|
|
964
|
-
const start = Date.now();
|
|
965
|
-
while (true) {
|
|
966
|
-
const actual = await read();
|
|
967
|
-
if (check(actual) !== this.negated) return;
|
|
968
|
-
if (Date.now() - start >= timeout) {
|
|
969
|
-
throw new AssertionError({
|
|
970
|
-
matcher: name,
|
|
971
|
-
expected,
|
|
972
|
-
actual,
|
|
973
|
-
locator: this.locator.selector,
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
await sleep(POLL_INTERVAL);
|
|
977
|
-
}
|
|
978
|
-
})(),
|
|
979
|
-
);
|
|
980
|
-
}
|
|
981
|
-
|
|
982
|
-
toBeVisible(options?: LocatorAssertionOptions) {
|
|
983
|
-
return this.assertEventually(
|
|
984
|
-
"toBeVisible",
|
|
985
|
-
true,
|
|
986
|
-
() => this.locator.isVisible(),
|
|
987
|
-
(visible) => visible,
|
|
988
|
-
options,
|
|
989
|
-
);
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
/** Trimmed text content equals `expected` (string) or matches it (RegExp) */
|
|
993
|
-
toHaveText(expected: string | RegExp, options?: LocatorAssertionOptions) {
|
|
994
|
-
return this.assertEventually(
|
|
995
|
-
"toHaveText",
|
|
996
|
-
expected,
|
|
997
|
-
() => this.locator.textContent(),
|
|
998
|
-
(text) => text !== null && matchesText(text, expected, "exact"),
|
|
999
|
-
options,
|
|
1000
|
-
);
|
|
1001
|
-
}
|
|
1002
|
-
|
|
1003
|
-
/** Text content contains `expected` (string) or matches it (RegExp) */
|
|
1004
|
-
toContainText(expected: string | RegExp, options?: LocatorAssertionOptions) {
|
|
1005
|
-
return this.assertEventually(
|
|
1006
|
-
"toContainText",
|
|
1007
|
-
expected,
|
|
1008
|
-
() => this.locator.textContent(),
|
|
1009
|
-
(text) => text !== null && matchesText(text, expected, "contains"),
|
|
1010
|
-
options,
|
|
1011
|
-
);
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
toHaveValue(expected: string | RegExp, options?: LocatorAssertionOptions) {
|
|
1015
|
-
return this.assertEventually(
|
|
1016
|
-
"toHaveValue",
|
|
1017
|
-
expected,
|
|
1018
|
-
() => this.locator.inputValue(),
|
|
1019
|
-
(value) => value !== null && matchesText(value, expected, "exact"),
|
|
1020
|
-
options,
|
|
1021
|
-
);
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
toHaveAttribute(
|
|
1025
|
-
name: string,
|
|
1026
|
-
expected: string | RegExp,
|
|
1027
|
-
options?: LocatorAssertionOptions,
|
|
1028
|
-
) {
|
|
1029
|
-
return this.assertEventually(
|
|
1030
|
-
"toHaveAttribute",
|
|
1031
|
-
expected,
|
|
1032
|
-
() => this.locator.getAttribute(name),
|
|
1033
|
-
(value) => value !== null && matchesText(value, expected, "exact"),
|
|
1034
|
-
options,
|
|
1035
|
-
);
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
toHaveCount(expected: number, options?: LocatorAssertionOptions) {
|
|
1039
|
-
return this.assertEventually(
|
|
1040
|
-
"toHaveCount",
|
|
1041
|
-
expected,
|
|
1042
|
-
() => this.locator.count(),
|
|
1043
|
-
(count) => count === expected,
|
|
1044
|
-
options,
|
|
1045
|
-
);
|
|
1046
|
-
}
|
|
1047
|
-
}
|
|
1048
|
-
```
|
|
1049
|
-
|
|
1050
|
-
d) Replace the Task 2 `expect` function with the overloads:
|
|
1051
|
-
|
|
1052
|
-
```ts
|
|
1053
|
-
/** Auto-retrying assertions on a page element */
|
|
1054
|
-
export function expect(locator: Locator): LocatorAssertions;
|
|
1055
|
-
/** Synchronous assertions on a plain value */
|
|
1056
|
-
export function expect<T>(value: T): ValueAssertions<T>;
|
|
1057
|
-
export function expect(value: unknown) {
|
|
1058
|
-
return value instanceof Locator
|
|
1059
|
-
? new LocatorAssertions(value)
|
|
1060
|
-
: new ValueAssertions(value);
|
|
1061
|
-
}
|
|
1062
|
-
```
|
|
1063
|
-
|
|
1064
|
-
- [ ] **Step 5: Run tests to verify they pass**
|
|
1065
|
-
|
|
1066
|
-
Run: `npm test`
|
|
1067
|
-
Expected: `ℹ pass 30`, `ℹ fail 0`, and no `unhandledRejection` warnings in the output.
|
|
1068
|
-
|
|
1069
|
-
- [ ] **Step 6: Type-check**
|
|
1070
|
-
|
|
1071
|
-
Run: `npx tsc --noEmit -p .`
|
|
1072
|
-
Expected: only the pre-existing `src/config/default.ts` error.
|
|
1073
|
-
|
|
1074
|
-
- [ ] **Step 7: Commit**
|
|
1075
|
-
|
|
1076
|
-
```bash
|
|
1077
|
-
git add src/types/config.d.ts src/assert/expect.ts src/assert/expect-locator.test.ts
|
|
1078
|
-
git commit -m "feat(assert): add auto-retrying locator matchers"
|
|
1079
|
-
```
|
|
1080
|
-
|
|
1081
|
-
---
|
|
1082
|
-
|
|
1083
|
-
### Task 5: Report assertion failures and missing awaits
|
|
1084
|
-
|
|
1085
|
-
**Files:**
|
|
1086
|
-
- Modify: `src/types/test.d.ts:40-44` (`TestError`)
|
|
1087
|
-
- Create: `src/runner/test-error.ts`
|
|
1088
|
-
- Test: `src/runner/test-error.test.ts`
|
|
1089
|
-
- Modify: `src/runner/test-runner.ts` (`executeTestCase`, currently lines 70-130)
|
|
1090
|
-
|
|
1091
|
-
**Interfaces:**
|
|
1092
|
-
- Consumes: `AssertionError`, `toReportValue` (Task 1); `takePendingAssertions` (Task 4).
|
|
1093
|
-
- Produces:
|
|
1094
|
-
- `TestError` = `{ message: string; type: "timeout" | "error" | "assertion"; stack?: string | undefined; expected?: unknown; actual?: unknown }`
|
|
1095
|
-
- `export function toTestError(err: unknown): TestError`
|
|
1096
|
-
|
|
1097
|
-
- [ ] **Step 1: Extend `TestError`**
|
|
1098
|
-
|
|
1099
|
-
In `src/types/test.d.ts` replace
|
|
1100
|
-
|
|
1101
|
-
```ts
|
|
1102
|
-
interface TestError {
|
|
1103
|
-
message: string;
|
|
1104
|
-
type: "timeout" | "error";
|
|
1105
|
-
stack?: string | undefined;
|
|
1106
|
-
}
|
|
1107
|
-
```
|
|
1108
|
-
|
|
1109
|
-
with
|
|
1110
|
-
|
|
1111
|
-
```ts
|
|
1112
|
-
interface TestError {
|
|
1113
|
-
message: string;
|
|
1114
|
-
type: "timeout" | "error" | "assertion";
|
|
1115
|
-
stack?: string | undefined;
|
|
1116
|
-
/** Only for `type: "assertion"` */
|
|
1117
|
-
expected?: unknown;
|
|
1118
|
-
/** Only for `type: "assertion"` */
|
|
1119
|
-
actual?: unknown;
|
|
1120
|
-
}
|
|
1121
|
-
```
|
|
1122
|
-
|
|
1123
|
-
- [ ] **Step 2: Write the failing test**
|
|
1124
|
-
|
|
1125
|
-
Create `src/runner/test-error.test.ts`:
|
|
1126
|
-
|
|
1127
|
-
```ts
|
|
1128
|
-
import { test } from "node:test";
|
|
1129
|
-
import assert from "node:assert/strict";
|
|
1130
|
-
import { toTestError } from "./test-error.js";
|
|
1131
|
-
import { AssertionError } from "../assert/assertion-error.js";
|
|
1132
|
-
|
|
1133
|
-
test("AssertionError becomes an assertion error with expected/actual", () => {
|
|
1134
|
-
const err = new AssertionError({
|
|
1135
|
-
matcher: "toHaveText",
|
|
1136
|
-
expected: /contact/i,
|
|
1137
|
-
actual: "Home",
|
|
1138
|
-
locator: "//h1",
|
|
1139
|
-
});
|
|
1140
|
-
const result = toTestError(err);
|
|
1141
|
-
assert.equal(result.type, "assertion");
|
|
1142
|
-
assert.equal(result.message, err.message);
|
|
1143
|
-
assert.equal(result.stack, err.stack);
|
|
1144
|
-
assert.equal(result.expected, "/contact/i");
|
|
1145
|
-
assert.equal(result.actual, "Home");
|
|
1146
|
-
});
|
|
1147
|
-
|
|
1148
|
-
test("other errors stay type error", () => {
|
|
1149
|
-
const err = new Error("boom");
|
|
1150
|
-
assert.deepEqual(toTestError(err), { message: "boom", stack: err.stack, type: "error" });
|
|
1151
|
-
});
|
|
1152
|
-
|
|
1153
|
-
test("thrown strings stay type error", () => {
|
|
1154
|
-
assert.deepEqual(toTestError("PORT 9223 is already in use!"), {
|
|
1155
|
-
message: "PORT 9223 is already in use!",
|
|
1156
|
-
stack: undefined,
|
|
1157
|
-
type: "error",
|
|
1158
|
-
});
|
|
1159
|
-
});
|
|
1160
|
-
```
|
|
1161
|
-
|
|
1162
|
-
- [ ] **Step 3: Run it to verify it fails**
|
|
1163
|
-
|
|
1164
|
-
Run: `npm test`
|
|
1165
|
-
Expected: FAIL — `Cannot find module '.../src/runner/test-error.js'`.
|
|
1166
|
-
|
|
1167
|
-
- [ ] **Step 4: Implement `toTestError`**
|
|
1168
|
-
|
|
1169
|
-
Create `src/runner/test-error.ts`:
|
|
1170
|
-
|
|
1171
|
-
```ts
|
|
1172
|
-
import { AssertionError, toReportValue } from "../assert/assertion-error.js";
|
|
1173
|
-
import type { TestError } from "../types/test.js";
|
|
1174
|
-
|
|
1175
|
-
/** Maps anything a test threw to the error stored in the report */
|
|
1176
|
-
export function toTestError(err: unknown): TestError {
|
|
1177
|
-
if (err instanceof AssertionError) {
|
|
1178
|
-
return {
|
|
1179
|
-
message: err.message,
|
|
1180
|
-
stack: err.stack,
|
|
1181
|
-
type: "assertion",
|
|
1182
|
-
expected: toReportValue(err.expected),
|
|
1183
|
-
actual: toReportValue(err.actual),
|
|
1184
|
-
};
|
|
1185
|
-
}
|
|
1186
|
-
return {
|
|
1187
|
-
message: err instanceof Error ? err.message : String(err),
|
|
1188
|
-
stack: err instanceof Error ? err.stack : undefined,
|
|
1189
|
-
type: "error",
|
|
1190
|
-
};
|
|
1191
|
-
}
|
|
1192
|
-
```
|
|
1193
|
-
|
|
1194
|
-
- [ ] **Step 5: Run tests to verify they pass**
|
|
1195
|
-
|
|
1196
|
-
Run: `npm test`
|
|
1197
|
-
Expected: `ℹ pass 33`, `ℹ fail 0`.
|
|
1198
|
-
|
|
1199
|
-
- [ ] **Step 6: Wire it into the runner**
|
|
1200
|
-
|
|
1201
|
-
In `src/runner/test-runner.ts`:
|
|
1202
|
-
|
|
1203
|
-
a) Add imports next to the existing ones:
|
|
1204
|
-
|
|
1205
|
-
```ts
|
|
1206
|
-
import { takePendingAssertions } from "../assert/expect.js";
|
|
1207
|
-
import { toTestError } from "./test-error.js";
|
|
1208
|
-
```
|
|
1209
|
-
|
|
1210
|
-
b) In `executeTestCase`, replace
|
|
1211
|
-
|
|
1212
|
-
```ts
|
|
1213
|
-
await test?.function(launched.page, launched.browser);
|
|
1214
|
-
// Already reported as timed out
|
|
1215
|
-
if (timeoutController.signal.aborted) return resolve();
|
|
1216
|
-
reporter.onTestEnd(test);
|
|
1217
|
-
resolve();
|
|
1218
|
-
} catch (err) {
|
|
1219
|
-
if (timeoutController.signal.aborted) return resolve();
|
|
1220
|
-
reporter.onTestEnd(test, {
|
|
1221
|
-
message: err instanceof Error ? err.message : (err as string),
|
|
1222
|
-
stack: err instanceof Error ? err.stack : undefined,
|
|
1223
|
-
type: "error",
|
|
1224
|
-
});
|
|
1225
|
-
resolve();
|
|
1226
|
-
}
|
|
1227
|
-
```
|
|
1228
|
-
|
|
1229
|
-
with
|
|
1230
|
-
|
|
1231
|
-
```ts
|
|
1232
|
-
await test?.function(launched.page, launched.browser);
|
|
1233
|
-
// Already reported as timed out
|
|
1234
|
-
if (timeoutController.signal.aborted) return resolve();
|
|
1235
|
-
const unawaited = takePendingAssertions();
|
|
1236
|
-
if (unawaited.length > 0) {
|
|
1237
|
-
reporter.onTestEnd(test, {
|
|
1238
|
-
message: unawaited
|
|
1239
|
-
.map((matcher) => `expect(locator).${matcher}() was not awaited`)
|
|
1240
|
-
.join("\n"),
|
|
1241
|
-
type: "assertion",
|
|
1242
|
-
});
|
|
1243
|
-
return resolve();
|
|
1244
|
-
}
|
|
1245
|
-
reporter.onTestEnd(test);
|
|
1246
|
-
resolve();
|
|
1247
|
-
} catch (err) {
|
|
1248
|
-
if (timeoutController.signal.aborted) return resolve();
|
|
1249
|
-
reporter.onTestEnd(test, toTestError(err));
|
|
1250
|
-
resolve();
|
|
1251
|
-
}
|
|
1252
|
-
```
|
|
1253
|
-
|
|
1254
|
-
c) After the race, reset the registry so leftovers from a failed or timed-out test cannot fail the next one. Replace
|
|
1255
|
-
|
|
1256
|
-
```ts
|
|
1257
|
-
clearTimeout(timeoutTimer);
|
|
1258
|
-
```
|
|
1259
|
-
|
|
1260
|
-
with
|
|
1261
|
-
|
|
1262
|
-
```ts
|
|
1263
|
-
clearTimeout(timeoutTimer);
|
|
1264
|
-
takePendingAssertions();
|
|
1265
|
-
```
|
|
1266
|
-
|
|
1267
|
-
- [ ] **Step 7: Type-check and run unit tests**
|
|
1268
|
-
|
|
1269
|
-
Run: `npx tsc --noEmit -p .`
|
|
1270
|
-
Expected: only the pre-existing `src/config/default.ts` error.
|
|
1271
|
-
|
|
1272
|
-
Run: `npm test`
|
|
1273
|
-
Expected: `ℹ pass 33`, `ℹ fail 0`.
|
|
1274
|
-
|
|
1275
|
-
- [ ] **Step 8: Commit**
|
|
1276
|
-
|
|
1277
|
-
```bash
|
|
1278
|
-
git add src/types/test.d.ts src/runner/test-error.ts src/runner/test-error.test.ts src/runner/test-runner.ts
|
|
1279
|
-
git commit -m "feat(runner): report assertion failures and missing awaits"
|
|
1280
|
-
```
|
|
1281
|
-
|
|
1282
|
-
---
|
|
1283
|
-
|
|
1284
|
-
### Task 6: End-to-end verification against Firefox
|
|
1285
|
-
|
|
1286
|
-
**Files:**
|
|
1287
|
-
- Modify: `src/tests/index.spec.ts`
|
|
1288
|
-
|
|
1289
|
-
**Interfaces:**
|
|
1290
|
-
- Consumes: `expect` (Task 4); runner reporting (Task 5).
|
|
1291
|
-
- Produces: nothing new.
|
|
1292
|
-
|
|
1293
|
-
Note: `bun run dev` launches real Firefox against `https://itmetsam.nl` and needs network access. Run it from a normal terminal (not a restricted sandbox). The report is written to `result/report.json`.
|
|
1294
|
-
|
|
1295
|
-
- [ ] **Step 1: Add assertions to the spec**
|
|
1296
|
-
|
|
1297
|
-
`src/tests/index.spec.ts` currently has an uncommitted blank line before the commented-out submit block; keep it. Change the import line to:
|
|
1298
|
-
|
|
1299
|
-
```ts
|
|
1300
|
-
import { test } from "../runner/test-runner.js";
|
|
1301
|
-
import { expect } from "../assert/expect.js";
|
|
1302
|
-
```
|
|
1303
|
-
|
|
1304
|
-
and insert directly after the `messageField.fill(...)` call:
|
|
1305
|
-
|
|
1306
|
-
```ts
|
|
1307
|
-
await expect(nameField).toBeVisible();
|
|
1308
|
-
await expect(nameField).toHaveValue("test");
|
|
1309
|
-
await expect(emailField).toHaveValue("sammiedegroot@gmail.com");
|
|
1310
|
-
await expect(subjectField).toHaveValue("Test subject");
|
|
1311
|
-
await expect(messageField).toHaveValue(/very long message/);
|
|
1312
|
-
```
|
|
1313
|
-
|
|
1314
|
-
- [ ] **Step 2: Run end-to-end, expect success**
|
|
1315
|
-
|
|
1316
|
-
Run: `bun run dev; echo "exit $?"; grep -E '"(status|type|message)"' result/report.json`
|
|
1317
|
-
Expected: `exit 0`; report shows `"status": "success"` for the summary and the test.
|
|
1318
|
-
If a `toHaveValue` fails, the report's `actual` shows what `fill()` really typed — that is a genuine finding about `fill()`; report it rather than loosening the assertion.
|
|
1319
|
-
|
|
1320
|
-
- [ ] **Step 3: Verify an assertion failure is reported (temporary change)**
|
|
1321
|
-
|
|
1322
|
-
Temporarily change `toHaveValue("test")` to `toHaveValue("wrong", { timeout: 1000 })`.
|
|
1323
|
-
Run: `bun run dev; grep -E '"(status|type|message|expected|actual)"' result/report.json`
|
|
1324
|
-
Expected: test `"status": "failed"`, `"type": "assertion"`, `"expected": "wrong"`, `"actual": "test"`, message starts with `expect(locator).toHaveValue\n locator: //input[@id=\"contact-name\"]`.
|
|
1325
|
-
Revert the change.
|
|
1326
|
-
|
|
1327
|
-
- [ ] **Step 4: Verify the missing-await guard (temporary change)**
|
|
1328
|
-
|
|
1329
|
-
Temporarily add, as the last line of the test body, without `await`:
|
|
1330
|
-
|
|
1331
|
-
```ts
|
|
1332
|
-
expect(page.locator("div[@id='does-not-exist']")).toBeVisible({ timeout: 3000 });
|
|
1333
|
-
```
|
|
1334
|
-
|
|
1335
|
-
Run: `bun run dev; grep -E '"(status|type|message)"' result/report.json`
|
|
1336
|
-
Expected: `"type": "assertion"`, `"message": "expect(locator).toBeVisible() was not awaited"`, and the process still exits by itself.
|
|
1337
|
-
Remove the line.
|
|
1338
|
-
|
|
1339
|
-
- [ ] **Step 5: Final checks**
|
|
1340
|
-
|
|
1341
|
-
Run: `npm test` → `ℹ fail 0`.
|
|
1342
|
-
Run: `npx tsc --noEmit -p .` → only the pre-existing `src/config/default.ts` error.
|
|
1343
|
-
Run: `git diff src/tests/index.spec.ts` → only the import, the five assertions and the pre-existing blank line.
|
|
1344
|
-
|
|
1345
|
-
- [ ] **Step 6: Commit**
|
|
1346
|
-
|
|
1347
|
-
```bash
|
|
1348
|
-
git add src/tests/index.spec.ts
|
|
1349
|
-
git commit -m "test: assert filled contact form values end-to-end"
|
|
1350
|
-
```
|