@jterrazz/test 16.1.0 → 17.0.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 +45 -171
- package/dist/browser/index.js +2 -2867
- package/dist/browser/services.js +2 -0
- package/dist/browser/stubs.js +2 -0
- package/dist/checker.d.ts +6 -1
- package/dist/checker.js +4551 -2612
- package/dist/config.d.ts +40 -0
- package/dist/config.js +309 -0
- package/dist/in-place.global-setup.d.ts +11 -0
- package/dist/in-place.global-setup.js +21 -0
- package/dist/index.d.ts +1653 -3002
- package/dist/index.js +5226 -3845
- package/dist/mobile-session.global-setup.d.ts +14 -0
- package/dist/mobile-session.global-setup.js +31 -0
- package/dist/oxlint.cjs +6073 -5616
- package/dist/oxlint.d.cts +43 -7212
- package/dist/oxlint.d.ts +43 -7212
- package/dist/oxlint.js +6065 -5609
- package/dist/route-coverage.global-setup.d.ts +42 -0
- package/dist/route-coverage.global-setup.js +2 -0
- package/dist/services.d.ts +3 -0
- package/dist/services.global-setup.d.ts +43 -0
- package/dist/services.global-setup.js +2 -0
- package/dist/services.js +597 -0
- package/dist/shared-appium.adapter.js +428 -0
- package/dist/shared-artifacts.js +32 -0
- package/dist/shared-chapter.js +7 -0
- package/dist/shared-container.port.d.ts +31 -0
- package/dist/shared-derived-projects.js +58 -0
- package/dist/shared-device.port.d.ts +189 -0
- package/dist/shared-exchange.js +447 -0
- package/dist/shared-in-place.js +172 -0
- package/dist/shared-index.browser.js +3191 -0
- package/dist/shared-index.services.d.ts +346 -0
- package/dist/shared-index.stubs.d.ts +287 -0
- package/dist/shared-json-shape.js +17 -0
- package/dist/shared-landmarks.js +71 -0
- package/dist/shared-ledger.js +68 -0
- package/dist/shared-literate-plugin.js +183 -0
- package/dist/shared-manifest.js +1321 -0
- package/dist/shared-pause.js +11 -0
- package/dist/shared-peer.js +88 -0
- package/dist/shared-playwright.adapter.js +330 -0
- package/dist/shared-process.adapter.js +74 -0
- package/dist/shared-refusal.js +35 -0
- package/dist/shared-resolve.js +24 -0
- package/dist/shared-route-coverage.global-setup.js +187 -0
- package/dist/shared-scenario-failure.js +198 -0
- package/dist/shared-serve.js +365 -0
- package/dist/shared-service-images.js +10 -0
- package/dist/shared-services.global-setup.js +179 -0
- package/dist/shared-session-ledger.js +301 -0
- package/dist/shared-setup-entry.js +12 -0
- package/dist/shared-source-scan.js +133 -0
- package/dist/shared-spec-document.js +365 -0
- package/dist/shared-specifications.js +26 -0
- package/dist/shared-stream-body.js +130 -0
- package/dist/shared-stub-server.js +190 -0
- package/dist/shared-temp-root.global-setup.js +80 -0
- package/dist/shared-test-baseline.js +26 -0
- package/dist/shared-test-section.js +58 -0
- package/dist/shared-testcontainers.adapter.js +203 -0
- package/dist/shared-tokens.js +55 -0
- package/dist/shared-yaml-document.js +119 -0
- package/dist/stubs.d.ts +2 -0
- package/dist/stubs.js +657 -0
- package/dist/temp-root.global-setup.d.ts +7 -0
- package/dist/temp-root.global-setup.js +2 -0
- package/dist/vitest.d.ts +97 -163
- package/dist/vitest.js +374 -507
- package/docs/README.md +82 -36
- package/docs/concepts/architecture.md +118 -0
- package/docs/concepts/conventions.md +333 -0
- package/docs/guides/api-specs.md +374 -0
- package/docs/guides/cli-specs.md +564 -0
- package/docs/guides/component-specs.md +252 -0
- package/docs/guides/develop.md +94 -0
- package/docs/guides/integration-specs.md +181 -0
- package/docs/guides/job-specs.md +180 -0
- package/docs/guides/lint-specs.md +174 -0
- package/docs/guides/mobile-specs.md +319 -0
- package/docs/guides/module-tests.md +149 -0
- package/docs/guides/release.md +43 -0
- package/docs/guides/spec-documents.md +189 -0
- package/docs/guides/website-specs.md +413 -0
- package/docs/reference/assertions.md +345 -0
- package/docs/reference/glossary.md +20 -0
- package/docs/reference/linting.md +483 -0
- package/docs/reference/services.md +236 -0
- package/docs/reference/stubs.md +498 -0
- package/docs/reference/time-and-doubles.md +137 -0
- package/docs/reference/tokens.md +258 -0
- package/docs/reference/visitor.md +221 -0
- package/docs/start/getting-started.md +187 -0
- package/docs/start/set-up-a-project.md +214 -0
- package/package.json +50 -30
- package/schema/spec.schema.json +43 -17
- package/dist/appium.adapter.js +0 -489
- package/dist/artifacts.js +0 -32
- package/dist/fixtures.js +0 -110
- package/dist/literate.js +0 -1747
- package/dist/match.js +0 -804
- package/dist/playwright.adapter.js +0 -349
- package/dist/server.js +0 -160
- package/dist/stream.js +0 -321
- package/dist/substring-warning.js +0 -244
- package/docs/01-architecture.md +0 -95
- package/docs/02-developing.md +0 -351
- package/docs/03-testing.md +0 -495
- package/docs/04-operating.md +0 -101
- package/docs/05-module-tests.md +0 -152
- package/docs/06-integration.md +0 -180
- package/docs/07-component.md +0 -246
- package/docs/08-website.md +0 -404
- package/docs/09-mobile.md +0 -270
- package/docs/10-api.md +0 -264
- package/docs/11-jobs.md +0 -157
- package/docs/12-cli.md +0 -677
- package/docs/13-elements.md +0 -242
- package/docs/14-assertions.md +0 -314
- package/docs/15-tokens.md +0 -253
- package/docs/16-contracts.md +0 -438
- package/docs/17-services.md +0 -188
- package/docs/18-conventions.md +0 -305
- package/docs/19-linting.md +0 -510
- package/docs/decisions/001-v9-conventions-on-three-channels.md +0 -50
- package/docs/decisions/002-the-statique-channel-holds-only-what-upstream-does-not.md +0 -49
- package/docs/decisions/003-a-rendered-component-is-a-facet-on-vitest-browser-mode.md +0 -84
- package/docs/decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-facet.md +0 -81
- package/docs/decisions/005-the-doubles-ladder-is-closed.md +0 -86
- package/docs/decisions/006-the-catalogue-reaches-a-file-by-its-role.md +0 -245
- package/docs/decisions/007-compose-mode-leaves-the-package.md +0 -53
- package/docs/decisions/008-vitest-5-node-24-and-the-native-seams-as-optional-peers.md +0 -75
- package/docs/decisions/_template.md +0 -22
- package/docs/reference/classes/Matcher.md +0 -120
- package/docs/reference/classes/ProcessHandle.md +0 -368
- package/docs/reference/functions/defineContract.md +0 -39
- package/docs/reference/functions/defineContracts.md +0 -36
- package/docs/reference/functions/disabled.md +0 -28
- package/docs/reference/functions/enabled.md +0 -20
- package/docs/reference/functions/focused.md +0 -26
- package/docs/reference/functions/mockOf.md +0 -79
- package/docs/reference/functions/postgres.md +0 -31
- package/docs/reference/functions/process.md +0 -33
- package/docs/reference/functions/redis.md +0 -28
- package/docs/reference/functions/required.md +0 -41
- package/docs/reference/functions/selected.md +0 -26
- package/docs/reference/functions/sqlite.md +0 -33
- package/docs/reference/functions/testId.md +0 -20
- package/docs/reference/functions/text.md +0 -40
- package/docs/reference/functions/valued.md +0 -28
- package/docs/reference/functions/waitUntil.md +0 -46
- package/docs/reference/functions/within.md +0 -29
- package/docs/reference/index.md +0 -196
- package/docs/reference/interfaces/BaseResult.md +0 -87
- package/docs/reference/interfaces/CallResult.md +0 -160
- package/docs/reference/interfaces/CaptureScope.md +0 -82
- package/docs/reference/interfaces/CliResult.md +0 -253
- package/docs/reference/interfaces/ContainerAccessor.md +0 -166
- package/docs/reference/interfaces/DirectoryAccessor.md +0 -33
- package/docs/reference/interfaces/FetchResult.md +0 -180
- package/docs/reference/interfaces/FilesystemAccessor.md +0 -46
- package/docs/reference/interfaces/HttpResult.md +0 -126
- package/docs/reference/interfaces/JsonAccessor.md +0 -46
- package/docs/reference/interfaces/PageResult.md +0 -356
- package/docs/reference/interfaces/RenderResult.md +0 -72
- package/docs/reference/interfaces/RenderedText.md +0 -110
- package/docs/reference/interfaces/ResponseAccessor.md +0 -47
- package/docs/reference/interfaces/ScreenResult.md +0 -128
- package/docs/reference/interfaces/TableAccessor.md +0 -26
- package/docs/reference/interfaces/TextAccessor.md +0 -96
- package/docs/reference/type-aliases/ApiHandle.md +0 -68
- package/docs/reference/type-aliases/ApiSpecification.md +0 -213
- package/docs/reference/type-aliases/ApiSpecificationOptions.md +0 -68
- package/docs/reference/type-aliases/BrowserConsoleMessage.md +0 -30
- package/docs/reference/type-aliases/BrowserLinkElement.md +0 -50
- package/docs/reference/type-aliases/BrowserMetaElement.md +0 -40
- package/docs/reference/type-aliases/BrowserOpenOptions.md +0 -87
- package/docs/reference/type-aliases/BrowserPage.md +0 -132
- package/docs/reference/type-aliases/BrowserPort.md +0 -51
- package/docs/reference/type-aliases/CliEnv.md +0 -11
- package/docs/reference/type-aliases/CliHandle.md +0 -80
- package/docs/reference/type-aliases/CliInput.md +0 -36
- package/docs/reference/type-aliases/CliOutput.md +0 -46
- package/docs/reference/type-aliases/CliPort.md +0 -58
- package/docs/reference/type-aliases/CliSpecification.md +0 -144
- package/docs/reference/type-aliases/CliSpecificationOptions.md +0 -134
- package/docs/reference/type-aliases/ComponentChain.md +0 -156
- package/docs/reference/type-aliases/ComponentScenario.md +0 -20
- package/docs/reference/type-aliases/ComponentUi.md +0 -47
- package/docs/reference/type-aliases/ComponentVisitor.md +0 -10
- package/docs/reference/type-aliases/ContainerPort.md +0 -113
- package/docs/reference/type-aliases/Contract.md +0 -68
- package/docs/reference/type-aliases/ContractInput.md +0 -13
- package/docs/reference/type-aliases/ContractRequest.md +0 -95
- package/docs/reference/type-aliases/ContractResponder.md +0 -23
- package/docs/reference/type-aliases/ContractResponse.md +0 -78
- package/docs/reference/type-aliases/ContractResponseValue.md +0 -13
- package/docs/reference/type-aliases/Contracts.md +0 -48
- package/docs/reference/type-aliases/DatabaseKeys.md +0 -16
- package/docs/reference/type-aliases/DatabasePort.md +0 -72
- package/docs/reference/type-aliases/DeviceOpenOptions.md +0 -46
- package/docs/reference/type-aliases/DevicePort.md +0 -51
- package/docs/reference/type-aliases/DeviceScreen.md +0 -32
- package/docs/reference/type-aliases/DeviceTimeouts.md +0 -39
- package/docs/reference/type-aliases/DockerSpecConfig.md +0 -43
- package/docs/reference/type-aliases/DomMount.md +0 -21
- package/docs/reference/type-aliases/ElementKind.md +0 -27
- package/docs/reference/type-aliases/ElementMatch.md +0 -74
- package/docs/reference/type-aliases/ElementOptions.md +0 -29
- package/docs/reference/type-aliases/ElementRef.md +0 -128
- package/docs/reference/type-aliases/ExecOptions.md +0 -37
- package/docs/reference/type-aliases/FileAccessor.md +0 -54
- package/docs/reference/type-aliases/HonoApp.md +0 -31
- package/docs/reference/type-aliases/HttpContractFilter.md +0 -49
- package/docs/reference/type-aliases/HttpResponseInit.md +0 -46
- package/docs/reference/type-aliases/HttpStreamInit.md +0 -58
- package/docs/reference/type-aliases/IntegrationHandle.md +0 -46
- package/docs/reference/type-aliases/IntegrationSpecification.md +0 -111
- package/docs/reference/type-aliases/IntegrationSpecificationOptions.md +0 -42
- package/docs/reference/type-aliases/Intercept.md +0 -19
- package/docs/reference/type-aliases/InterceptOptions.md +0 -28
- package/docs/reference/type-aliases/InterceptScope.md +0 -11
- package/docs/reference/type-aliases/IsolationStrategy.md +0 -75
- package/docs/reference/type-aliases/JobHandle.md +0 -34
- package/docs/reference/type-aliases/JobsHandle.md +0 -57
- package/docs/reference/type-aliases/JobsResult.md +0 -16
- package/docs/reference/type-aliases/JobsSpecification.md +0 -94
- package/docs/reference/type-aliases/JobsSpecificationOptions.md +0 -56
- package/docs/reference/type-aliases/LandmarkKind.md +0 -20
- package/docs/reference/type-aliases/LiterateRunFlags.md +0 -27
- package/docs/reference/type-aliases/LiterateServeRegistration.md +0 -70
- package/docs/reference/type-aliases/MatchFixtureOptions.md +0 -27
- package/docs/reference/type-aliases/MatchableRequest.md +0 -61
- package/docs/reference/type-aliases/MatcherKind.md +0 -34
- package/docs/reference/type-aliases/MobileBackendOptions.md +0 -28
- package/docs/reference/type-aliases/MobileElementKind.md +0 -14
- package/docs/reference/type-aliases/MobileElementMatch.md +0 -60
- package/docs/reference/type-aliases/MobileElementRef.md +0 -20
- package/docs/reference/type-aliases/MobileHandle.md +0 -65
- package/docs/reference/type-aliases/MobileScenario.md +0 -20
- package/docs/reference/type-aliases/MobileSpecification.md +0 -51
- package/docs/reference/type-aliases/MobileSpecificationOptions.md +0 -126
- package/docs/reference/type-aliases/MobileVisitor.md +0 -81
- package/docs/reference/type-aliases/MockOfOptions.md +0 -26
- package/docs/reference/type-aliases/MockPort.md +0 -10
- package/docs/reference/type-aliases/PinnedClock.md +0 -10
- package/docs/reference/type-aliases/PostgresOptions.md +0 -32
- package/docs/reference/type-aliases/ProcessOptions.md +0 -112
- package/docs/reference/type-aliases/RedisOptions.md +0 -20
- package/docs/reference/type-aliases/RenderSubject.md +0 -12
- package/docs/reference/type-aliases/ScreenNode.md +0 -67
- package/docs/reference/type-aliases/ServerPort.md +0 -36
- package/docs/reference/type-aliases/ServerResponse.md +0 -46
- package/docs/reference/type-aliases/ServerSpec.md +0 -21
- package/docs/reference/type-aliases/ServiceHandle.md +0 -216
- package/docs/reference/type-aliases/ServiceRecord.md +0 -12
- package/docs/reference/type-aliases/SpecDocument.md +0 -84
- package/docs/reference/type-aliases/SpecEnvToken.md +0 -21
- package/docs/reference/type-aliases/SpecFileAssertion.md +0 -27
- package/docs/reference/type-aliases/SpecFixture.md +0 -30
- package/docs/reference/type-aliases/SpecKind.md +0 -8
- package/docs/reference/type-aliases/SpecRun.md +0 -108
- package/docs/reference/type-aliases/SpecServeEntry.md +0 -40
- package/docs/reference/type-aliases/SpecStream.md +0 -32
- package/docs/reference/type-aliases/SpecificationConfig.md +0 -290
- package/docs/reference/type-aliases/SqliteOptions.md +0 -36
- package/docs/reference/type-aliases/SseEvent.md +0 -58
- package/docs/reference/type-aliases/StreamBody.md +0 -61
- package/docs/reference/type-aliases/TextFilter.md +0 -22
- package/docs/reference/type-aliases/VisitScenario.md +0 -20
- package/docs/reference/type-aliases/Visitor.md +0 -215
- package/docs/reference/type-aliases/WaitUntilOptions.md +0 -46
- package/docs/reference/type-aliases/WebsiteBackendOptions.md +0 -37
- package/docs/reference/type-aliases/WebsiteHandle.md +0 -51
- package/docs/reference/type-aliases/WebsiteSpecification.md +0 -118
- package/docs/reference/type-aliases/WebsiteSpecificationOptions.md +0 -40
- package/docs/reference/variables/anthropic.md +0 -19
- package/docs/reference/variables/banner.md +0 -10
- package/docs/reference/variables/button.md +0 -10
- package/docs/reference/variables/clock.md +0 -16
- package/docs/reference/variables/complementary.md +0 -10
- package/docs/reference/variables/component.md +0 -10
- package/docs/reference/variables/content.md +0 -10
- package/docs/reference/variables/contentinfo.md +0 -10
- package/docs/reference/variables/dialog.md +0 -10
- package/docs/reference/variables/field.md +0 -10
- package/docs/reference/variables/form.md +0 -10
- package/docs/reference/variables/heading.md +0 -10
- package/docs/reference/variables/http.md +0 -40
- package/docs/reference/variables/intercept.md +0 -11
- package/docs/reference/variables/link.md +0 -10
- package/docs/reference/variables/listitem.md +0 -10
- package/docs/reference/variables/main.md +0 -10
- package/docs/reference/variables/match.md +0 -8
- package/docs/reference/variables/navigation.md +0 -10
- package/docs/reference/variables/openai.md +0 -21
- package/docs/reference/variables/option.md +0 -17
- package/docs/reference/variables/region.md +0 -10
- package/docs/reference/variables/row.md +0 -10
- package/docs/reference/variables/search.md +0 -10
- package/docs/reference/variables/specification.md +0 -8
- package/docs/reference/variables/status.md +0 -10
- package/docs/reference/variables/table.md +0 -10
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# Tokens: dynamic values in fixtures
|
|
2
|
+
|
|
3
|
+
Real systems generate UUIDs, timestamps, ports, and paths. The framework handles them with **one unified token grammar** (rule `no-unknown-token`): the same `{{token}}` vocabulary works in every fixture file, and the same vocabulary is available in code as `match.*`. The vocabulary is fixed (21 tokens, defined, tested, and documented in the package), and an unknown token in a fixture is an error, not a silent literal.
|
|
4
|
+
|
|
5
|
+
## Where tokens work
|
|
6
|
+
|
|
7
|
+
| Fixture kind | Files | Coverage |
|
|
8
|
+
| -------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
9
|
+
| HTTP responses | `__expected__/*.http` | **body and headers** |
|
|
10
|
+
| JSON snapshots | `__expected__/*.json` | any string value |
|
|
11
|
+
| Text snapshots | `__expected__/*.txt` (and other text files under `__expected__/`) | anywhere in the text |
|
|
12
|
+
| Spec documents | `<case>.spec.yaml`, beside the spec ([Spec documents](../guides/spec-documents.md)) | what a document asserts (a run's streams and `files:` texts, a response's status, headers and body, a table's cells, a job's `error:`), **never the description, a command, a request or a body**, which are prose and inputs |
|
|
13
|
+
|
|
14
|
+
```http
|
|
15
|
+
### __expected__/order-created.http: tokens in a header AND the body
|
|
16
|
+
HTTP/1.1 201 Created
|
|
17
|
+
Location: /orders/{{uuid#order}}
|
|
18
|
+
|
|
19
|
+
{ "id": "{{uuid#order}}", "total": "{{number}}", "createdAt": "{{iso8601}}" }
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
### __expected__/help.txt: tokens in a TEXT snapshot
|
|
24
|
+
shoply v{{semver}}
|
|
25
|
+
Started at {{iso8601}} in {{workdir}}
|
|
26
|
+
Done in {{duration}}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
A token in a JSON body is written inside quotes (`"total": "{{number}}"`) because the file must stay JSON, and a quoted numeric token still matches a native JSON number as a whole value: `{{number}}`, `{{int}}`, `{{float}}` and `{{port}}` accept `42` as readily as `"42"`. `{{any}}` matches any type at all.
|
|
30
|
+
|
|
31
|
+
Stream comparisons strip ANSI escape sequences **before** token matching, so fixtures never contain color codes: `.text` is the stripped text, `.raw` the capture as it was.
|
|
32
|
+
|
|
33
|
+
## The vocabulary: all 21 tokens
|
|
34
|
+
|
|
35
|
+
Every token matches one value of its family at its position. The normative definitions (the exact accepted grammar for each) are shipped and tested inside the package; the table below states each token's semantics and canonical accepted forms.
|
|
36
|
+
|
|
37
|
+
### Identifiers
|
|
38
|
+
|
|
39
|
+
| Token | Matches | Example value |
|
|
40
|
+
| ---------- | ---------------------------------------------- | -------------------------------------- |
|
|
41
|
+
| `{{uuid}}` | RFC 4122 UUID, `8-4-4-4-12` hexadecimal groups | `3f2c9b6e-8a41-4c8f-9d2a-1b7e5f0c4d3e` |
|
|
42
|
+
| `{{ulid}}` | ULID, 26 Crockford base32 characters | `01J2X3YV5T8Q4R9K7M6N1P0ZSD` |
|
|
43
|
+
|
|
44
|
+
### Time
|
|
45
|
+
|
|
46
|
+
| Token | Matches | Example value |
|
|
47
|
+
| -------------- | ------------------------------------------------------------- | ---------------------------- |
|
|
48
|
+
| `{{iso8601}}` | Full ISO 8601 timestamp (date + time, `Z` or offset) | `2026-07-16T10:32:05.123Z` |
|
|
49
|
+
| `{{date}}` | Calendar date, `YYYY-MM-DD` | `2026-07-16` |
|
|
50
|
+
| `{{time}}` | Wall-clock time, `HH:MM[:SS]` (seconds and fraction optional) | `10:32:05` |
|
|
51
|
+
| `{{duration}}` | Elapsed-time string as printed by tooling | `340ms`, `1.24s`, `2m`, `3h` |
|
|
52
|
+
|
|
53
|
+
### Numbers
|
|
54
|
+
|
|
55
|
+
| Token | Matches | Example value |
|
|
56
|
+
| ------------ | -------------------------------------- | ------------- |
|
|
57
|
+
| `{{number}}` | Any numeric value (integer or decimal) | `109`, `4.5` |
|
|
58
|
+
| `{{int}}` | Integer only | `42` |
|
|
59
|
+
| `{{float}}` | Decimal with a fractional part | `0.97` |
|
|
60
|
+
|
|
61
|
+
### Versions & hashes
|
|
62
|
+
|
|
63
|
+
| Token | Matches | Example value |
|
|
64
|
+
| ------------ | ------------------------------------------------------------------ | --------------------- |
|
|
65
|
+
| `{{semver}}` | Semantic version (`major.minor.patch`, optional pre-release/build) | `9.0.0`, `9.1.0-rc.1` |
|
|
66
|
+
| `{{sha}}` | Commit/content hash digest (hexadecimal, git-style) | `f690adf...` |
|
|
67
|
+
| `{{hex}}` | Hexadecimal string | `deadbeef` |
|
|
68
|
+
| `{{base64}}` | Base64 string (padding included) | `aGVsbG8=` |
|
|
69
|
+
|
|
70
|
+
### Network
|
|
71
|
+
|
|
72
|
+
| Token | Matches | Example value |
|
|
73
|
+
| ----------- | -------------------------------- | ------------------------------------- |
|
|
74
|
+
| `{{port}}` | TCP/UDP port number (0 to 65535) | `54321` |
|
|
75
|
+
| `{{ip}}` | IP address | `127.0.0.1` |
|
|
76
|
+
| `{{url}}` | Absolute URL with scheme | `https://rates.example.com/v1/latest` |
|
|
77
|
+
| `{{email}}` | Email address | `contact@jterrazz.com` |
|
|
78
|
+
|
|
79
|
+
### Paths
|
|
80
|
+
|
|
81
|
+
| Token | Matches | Example value |
|
|
82
|
+
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
83
|
+
| `{{path}}` | Filesystem path | `/tmp/x/dist/index.js` |
|
|
84
|
+
| `{{workdir}}` | **The exact cwd of the current spec**: the framework knows the temp directory it created, so this is an equality check, not a pattern | `/tmp/jterrazz-test-runs-501/48213-Xk2pQa/spec-command-a1B2c3` |
|
|
85
|
+
|
|
86
|
+
`{{workdir}}` holds the **resolved** cwd, the spelling a child process reports, after any symlink on the way (macOS's `$TMPDIR` sits under `/var`, a link to `/private/var`). The `$WORKDIR` token that `.env()` and a document's `env:` expand is that same resolved path, so a directory the runner hands to the binary comes back in its output matching the token: `.env({ CACHE_DIR: '$WORKDIR/cache' })` is asserted as `{{workdir}}/cache`.
|
|
87
|
+
|
|
88
|
+
### Generic
|
|
89
|
+
|
|
90
|
+
| Token | Matches |
|
|
91
|
+
| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
92
|
+
| `{{string}}` | Any string value |
|
|
93
|
+
| `{{any}}` | Anything: always-true **as a whole value** (matches any type: object, number, `null`). Embedded in a frame (`A {{any}} Z`) it only widens the middle; the surrounding literal still has to match, and text on both sides of it on its line holds it to that line |
|
|
94
|
+
|
|
95
|
+
## `#ref` captures
|
|
96
|
+
|
|
97
|
+
Any token becomes a capture by suffixing `#name`: `{{uuid#order}}`. The **first occurrence captures** the actual value; every subsequent occurrence of the same ref **must be equal**. Refs are scoped to the current spec: one namespace shared by all fixture files and code-side `match.ref()` within that spec, resetting between specs.
|
|
98
|
+
|
|
99
|
+
```http
|
|
100
|
+
### __expected__/order-created.http: {{uuid#order}} appears 3 times, all must be equal
|
|
101
|
+
HTTP/1.1 201 Created
|
|
102
|
+
Location: /orders/{{uuid#order}}
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
"id": "{{uuid#order}}",
|
|
106
|
+
"paymentIntent": { "orderId": "{{uuid#order}}" }
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A ref that re-occurs with a different value fails with both values in the message: the captured one and the conflicting one.
|
|
111
|
+
|
|
112
|
+
## `match.*`: the same vocabulary in code
|
|
113
|
+
|
|
114
|
+
Every token has a code mirror for inline assertions (table rows, primarily):
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
import { match } from '@jterrazz/test';
|
|
118
|
+
|
|
119
|
+
await expect(result.table('orders', { database: 'db' })).toMatch([
|
|
120
|
+
[match.uuid(), 'pending', match.number(), match.iso8601()],
|
|
121
|
+
]);
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
`match.uuid()`, `match.ulid()`, `match.iso8601()`, `match.date()`, `match.time()`, `match.duration()`, `match.number()`, `match.int()`, `match.float()`, `match.semver()`, `match.sha()`, `match.hex()`, `match.base64()`, `match.port()`, `match.ip()`, `match.url()`, `match.email()`, `match.path()`, `match.workdir()`, `match.string()`, `match.any()`, plus four code-only forms:
|
|
125
|
+
|
|
126
|
+
| Code-only | Meaning |
|
|
127
|
+
| --------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
128
|
+
| `match.regex(/.../)` | Arbitrary pattern, the escape hatch when no token fits |
|
|
129
|
+
| `match.ref(name)` | Capture on first use, require equality afterwards (same namespace as `{{token#name}}` refs) |
|
|
130
|
+
| `match.ref(name, { not: other })` | Additionally require **inequality** with another captured ref |
|
|
131
|
+
| `match.includes(substring)` | Containment: the value holds the substring; used mostly in a stub's request filter |
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
// intent.order_id === orders.id, but intent.id ≠ orders.id
|
|
135
|
+
await expect(result.table('orders', { database: 'db' })).toMatch([[match.ref('order'), 'pending']]);
|
|
136
|
+
await expect(result.table('payment_intents', { database: 'db' })).toMatch([
|
|
137
|
+
[match.ref('intent', { not: 'order' }), match.ref('order')],
|
|
138
|
+
]);
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Update mode: tokens are preserved
|
|
142
|
+
|
|
143
|
+
`TEST_UPDATE=1` (or `vitest -u`) rewrites fixtures from actual output, but it writes **tokens, not values** (rule `no-volatile-literal`):
|
|
144
|
+
|
|
145
|
+
- Segments already covered by a placeholder are **preserved**: a fixture line `Done in {{duration}}` stays `Done in {{duration}}` after an update, no matter what duration the run printed.
|
|
146
|
+
- Values the framework knows to be dynamic are substituted automatically: the spec's cwd becomes `{{workdir}}` in the written fixture without you doing anything.
|
|
147
|
+
- A golden that does not exist yet, or holds nothing but whitespace, is **recorded** from the output: a `.http`, a `.json` and a text golden alike, and a document's `response:` file too: a run that records stands without the goldens it is about to write. A golden is started by naming it, never by stubbing it.
|
|
148
|
+
|
|
149
|
+
Example, starting from actual output:
|
|
150
|
+
|
|
151
|
+
```text
|
|
152
|
+
shoply v9.1.0
|
|
153
|
+
Started at 2026-07-17T09:12:44.031Z in /tmp/jterrazz-test-runs-501/48213-Xk2pQa/spec-command-x9Y8z7
|
|
154
|
+
Done in 0.83s
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
with an existing fixture:
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
shoply v{{semver}}
|
|
161
|
+
Started at {{iso8601}} in {{workdir}}
|
|
162
|
+
Done in {{duration}}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`TEST_UPDATE=1` leaves the fixture byte-identical: every changed segment was already covered by a token. If the CLI adds a new line `Cache dir: /tmp/shoply-spec-x9y8z7/.cache`, the update writes `Cache dir: {{workdir}}/.cache` (`{{workdir}}` is substituted automatically); a timestamp in a _new_ line would be written literally, for you to tokenize by hand (or it will fail on the next run, which is the signal).
|
|
166
|
+
|
|
167
|
+
The same preservation and `{{workdir}}` substitution apply identically across every fixture kind: a value written in place (below), text snapshots (`__expected__/*.txt`), JSON goldens (`__expected__/*.json`), `.http` response bodies (`__expected__/*.http`), and the streams of a `<case>.spec.yaml` (whose ground, commands and `files:` an update never rewrites). A cwd embedded in a JSON string value is written back as `{{workdir}}` exactly as it is in a text line, so an updated JSON golden matches the next run's (different) cwd instead of pinning a run-specific temp path.
|
|
168
|
+
|
|
169
|
+
### A value written in place is rewritten in the test
|
|
170
|
+
|
|
171
|
+
A `toMatch(<value>)` is rewritten where it stands, in the test file, the gesture vitest's `toMatchInlineSnapshot` makes: the call is found from the stack of the assertion, and its argument (up to its comma or its closing parenthesis, whatever template, string or nested call it holds) is replaced by the actual result, merged with the tokens the old value had where the result still matches them. A text is written as a template literal indented one level deeper than its call, a json as its JSON, a table one row per line. An empty value, `toMatch('')`, is the one an author leaves to be recorded: outside update mode it fails naming the run that fills it. A value that matches is never touched, and neither is a `{ frozen: true }` one or a `.not`: a negation asserts a mismatch, and records nothing. Every rewrite of one file during a run is laid on the source the run loaded, so a second rewrite lands where its stack says even when a first one moved the lines below it. Under the preset the test file is written when the run ENDS, after its report: vitest frames a failure by reading the file on disk at the line the run loaded, so a file rewritten mid-run would frame a later failure at another test's code. The preset's global setup hands each project a pending folder under `.artifacts/vitest/in-place/` and writes what waits there once the report is printed (in watch mode, when the watcher exits); a run outside the preset writes each file at once. A component's value is rewritten the same way through a server command, the page's stack mapped back to the test's source by the browser server. What update mode writes comes out in the repository's style: an integer of five digits or more is grouped by threes (`1_775_034_000_000`, as the linter's numeric separators ask), and when the run ends each rewritten test file is laid out by the oxfmt the project installs, with the project's own config: its quotes, its keys, its trailing commas. Outside the preset, or where no oxfmt is installed, the file stays as written, and `npx typescript fix` formats it.
|
|
172
|
+
|
|
173
|
+
This is why `transform` on `specification.cli` is only an escape hatch (rule `no-transform-duplicating-tokens`): the token grammar plus ANSI stripping covers the standard sources of nondeterminism, and a transform that merely re-implements standard tokens is refused by `no-transform-duplicating-tokens` ([no-transform-duplicating-tokens](linting.md#no-transform-duplicating-tokens)).
|
|
174
|
+
|
|
175
|
+
### Frozen fixtures: opting a negative fixture out of the rewrite
|
|
176
|
+
|
|
177
|
+
Update mode rewrites a mismatching fixture with the actual output. That is exactly wrong for a **deliberately-wrong fixture**, one whose _mismatch_ is the behaviour under test (a spec that asserts the diff, or that a missing fixture errors). Under `TEST_UPDATE=1` an unfrozen negative fixture is silently overwritten with the real output, and the assertion stops testing anything.
|
|
178
|
+
|
|
179
|
+
Pass `{ frozen: true }` as the second argument to opt a single fixture out:
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
// A deliberately-wrong fixture: its diff rendering is the subject.
|
|
183
|
+
expect(() =>
|
|
184
|
+
expect(result.response).toMatch(file('./__expected__/wrong-body.http'), { frozen: true }),
|
|
185
|
+
).toThrow(/Response mismatch/);
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
A frozen fixture is **never written** in update mode: a frozen mismatch still throws its diff, and a frozen _missing_ fixture still throws "does not exist" (never created). Its failure says so rather than pointing at update mode: write the file by hand, or drop `{ frozen }` for one `TEST_UPDATE=1` run and put it back. It is available on every golden subject (`result.response`, `result.stdout`/`result.stderr`, `result.json`, `result.directory`, `result.filesystem`). The static rule `negative-golden-frozen` flags a golden `toMatch(file(...))`/`toMatch(directory(...))` wrapped in `expect(() => ...).toThrow()` / `.rejects` that omits it.
|
|
189
|
+
|
|
190
|
+
### The discipline: update mode writes, it does not judge
|
|
191
|
+
|
|
192
|
+
A rewritten fixture is not a passing test: it is this run's output, made into the expectation. Three steps, in this order, and the middle one is the whole point.
|
|
193
|
+
|
|
194
|
+
1. **Run it.** `TEST_UPDATE=1 npx vitest --run --project <kind>`, narrowed to the project whose goldens moved, never the whole suite, because a broad update rewrites files nobody looked at.
|
|
195
|
+
2. **Read the diff.** `git diff` is the review: every line that changed is a behaviour that changed. A golden diff that nobody can explain is a bug that just became the expectation, and this is the only moment it is cheap to catch.
|
|
196
|
+
3. **Run again, without the flag.** The second run is the one that proves anything. It reads the file as any other run would, and it is what a reviewer's machine will do.
|
|
197
|
+
|
|
198
|
+
The round-trip is not a step the suite performs for you, and deliberately so: update mode writes the file from THIS run's actual value, so re-comparing inside the same run matches by construction and proves nothing. The real round-trip already exists: CI runs the same goldens on another machine, which is exactly the question "does this file still hold when the run is not the one that wrote it?"
|
|
199
|
+
|
|
200
|
+
**The frozen two-pass.** A frozen fixture is never written, so changing one on purpose is two deliberate passes: drop `{ frozen: true }`, run once under `TEST_UPDATE=1`, read the diff, and put the option back in the same commit. The friction is the feature: a negative fixture should cost something to change.
|
|
201
|
+
|
|
202
|
+
**What update mode never touches.** The ground of a `<case>.spec.yaml` (its `description:`, its commands, its `env:`/`serve:`/`fixture:` and its `files:`) is authored, never rewritten; only the expected streams move. Its `files:` are still judged under update, so a document that passes a recorded run is one a normal run passes.
|
|
203
|
+
|
|
204
|
+
**What update mode refuses to record.** An expectation that cannot fail is no expectation. Update mode writes nothing over a result whose call threw: the value of a `.call()` that failed is `null`, and a recorded `toMatch(null)` stays green while every call fails: assert the error, or fix the call. It rewrites a literal only: a `toMatch(` handed a name or a call stands for values the rewrite would replace by the last one it saw (a table's rows are recorded by hand, one by one). And `toMatch('')` on a string (a stream's `.text`, a file's `.content`) is vitest's substring match, true of any text; the rule `no-empty-substring-match` refuses it. A `__requests__/*.http` is an input, not an expectation, and is never written either.
|
|
205
|
+
|
|
206
|
+
## Known limitations
|
|
207
|
+
|
|
208
|
+
The grammar is deliberately small; three edges are worth knowing:
|
|
209
|
+
|
|
210
|
+
- **No escaping for literal `{{...}}` text.** There is no escape sequence. A `{{uuid}}` written in a fixture is _always_ interpreted as the token; you cannot assert a literal, un-substituted `{{uuid}}` string in expected output. (An unknown name like `{{widget}}` is caught by the conventions checker (rule `no-unknown-token`) as an error: the vocabulary is frozen, so an unknown token is never a usable literal escape.) For genuinely custom shapes, use `match.regex()` in code.
|
|
211
|
+
- **Update-merge pairs one previous line with one actual line.** Placeholder preservation is by **pattern**: the aligned line is tried first, then what is left over is re-paired by matching each remaining placeholder line against each unresolved actual line, in file order. A mid-file insertion is therefore safe: the shifted `{{duration}}` line is recognised where it landed. What stays out of reach: a previous line is spent **once**, so when the output grows a SECOND line the same placeholder could cover, the first keeps the token and the new one is written as a raw value, for you to tokenize.
|
|
212
|
+
- **`{{float}}` cannot require a decimal on a JSON number.** In JSON there is no distinction between `42` and `42.0`, so as a whole JSON value `{{float}}` accepts any finite number (an integer passes). The decimal requirement only holds in **text** contexts (`4.2` matches, `42` does not). This is a JSON limitation, not a token choice.
|
|
213
|
+
|
|
214
|
+
## Appendix: canonical accepted forms (normative, as implemented)
|
|
215
|
+
|
|
216
|
+
The exact grammar of each token, one tested regular expression per token
|
|
217
|
+
(defined in [`src/expectations/tokens/structural.ts`](https://github.com/jterrazz/package-test/blob/main/src/expectations/tokens/structural.ts), the vocabulary list in `match.ts` beside it). In embedded positions the pattern is
|
|
218
|
+
anchored to its surroundings; as a whole JSON value, numeric tokens also
|
|
219
|
+
accept native JSON numbers.
|
|
220
|
+
|
|
221
|
+
| Token | Accepted form (regex) | Notes |
|
|
222
|
+
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
223
|
+
| `{{uuid}}` | `[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}` | any RFC 4122 variant |
|
|
224
|
+
| `{{ulid}}` | `[0-9A-HJKMNP-TV-Z]{26}` | Crockford base32, uppercase |
|
|
225
|
+
| `{{iso8601}}` | `\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z\|[+-]\d{2}:\d{2})` | date + time, `Z` or offset required |
|
|
226
|
+
| `{{date}}` | `\d{4}-\d{2}-\d{2}` | |
|
|
227
|
+
| `{{time}}` | `\d{2}:\d{2}(:\d{2}(\.\d+)?)?` | seconds and fraction optional |
|
|
228
|
+
| `{{duration}}` | `\d+(\.\d+)?(ms\|s\|m\|h)` | strict: `ms`, `s`, `m`, `h` suffixes only |
|
|
229
|
+
| `{{number}}` | `-?\d+(\.\d+)?([eE][+-]?\d+)?` | JSON numbers also accepted as whole values |
|
|
230
|
+
| `{{int}}` | `-?\d+` | whole JSON value: `Number.isInteger` |
|
|
231
|
+
| `{{float}}` | `-?\d+\.\d+` | whole JSON value: any finite number |
|
|
232
|
+
| `{{semver}}` | `\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?` | |
|
|
233
|
+
| `{{sha}}` | `[0-9a-f]{7,64}` | 7 to 64 lowercase hex chars (short SHA up to SHA-512) |
|
|
234
|
+
| `{{hex}}` | `[0-9a-fA-F]+` | |
|
|
235
|
+
| `{{base64}}` | `[A-Za-z0-9+/]+={0,2}` | |
|
|
236
|
+
| `{{port}}` | `\d{1,5}` | range-checked to 0 to 65535 (embedded placeholders reject out-of-range values like `99999`) |
|
|
237
|
+
| `{{ip}}` | `(\d{1,3}\.){3}\d{1,3}` | IPv4 only for now |
|
|
238
|
+
| `{{url}}` | `https?://[^\s"'<>]+` | http(s) schemes only |
|
|
239
|
+
| `{{email}}` | `[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+` | |
|
|
240
|
+
| `{{path}}` | `\.{0,2}/[^\s"'<>]*` | absolute (`/...`) or relative (`./...`, `../...`) |
|
|
241
|
+
| `{{workdir}}` | none (exact string equality with the spec's cwd, realpath form) | never matches when the framework does not know the cwd |
|
|
242
|
+
| `{{string}}` | any string; embedded: `[^\n]*?` (single line) | |
|
|
243
|
+
| `{{any}}` | anything; embedded: `[\s\S]*?` (crosses lines) where its line holds no text on one side of it (alone on a line, opening it or closing it); `[^\n]*?` where text frames it on its line (`"msg":"{{any}}",`) | |
|
|
244
|
+
|
|
245
|
+
## Pitfalls
|
|
246
|
+
|
|
247
|
+
- **Inventing a token.** The vocabulary is fixed; `{{timestamp}}` in a fixture is an error (rule `no-unknown-token`). Use `{{iso8601}}`, or `match.regex()` in code for genuinely custom shapes.
|
|
248
|
+
- **Expecting `{{workdir}}` to be a pattern.** It matches the _exact_ cwd of the current spec; a path from another spec's cwd fails.
|
|
249
|
+
- **Assuming refs span the suite.** Ref scope is the current spec; `{{uuid#order}}` in two different tests are unrelated captures.
|
|
250
|
+
- **Using `{{any}}` where a precise token exists.** `{{any}}` matches garbage too; every widening of a fixture weakens the spec.
|
|
251
|
+
- **Reading "always-true" as unframed.** `{{any}}` is only unconditional as the _whole_ value. Embedded (`A {{any}} Z`), the frame around it must still match, and a non-string/number actual cannot satisfy an embedded token at all.
|
|
252
|
+
- **Hand-writing values that update mode would tokenize.** Run `TEST_UPDATE=1` first, then tighten: placeholders you wrote once are preserved forever after.
|
|
253
|
+
- **Escaping into `transform` for tokenizable noise.** Only application noise _not_ covered by the grammar justifies a transform (rule `no-transform-duplicating-tokens`); one a token already names is refused by `no-transform-duplicating-tokens`.
|
|
254
|
+
- **Leaving a deliberately-wrong fixture unfrozen.** A negative test whose golden matcher asserts a mismatch (wrapped in `expect(() => ...).toThrow()` / `.rejects`) must pass `{ frozen: true }`; otherwise `TEST_UPDATE=1` overwrites the wrong fixture with the real output and the assertion silently stops testing anything (rule `negative-golden-frozen`).
|
|
255
|
+
|
|
256
|
+
## Related
|
|
257
|
+
|
|
258
|
+
[Assertions](assertions.md) · [API specs](../guides/api-specs.md) · [CLI specs](../guides/cli-specs.md)
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# The visitor
|
|
2
|
+
|
|
3
|
+
Three kinds of test drive something a person can see: a served page ([Website specs](../guides/website-specs.md)), a mounted component ([Component specs](../guides/component-specs.md)) and a screen on a simulator ([Mobile specs](../guides/mobile-specs.md)). Each hands its scenario a **visitor**, which acts on the surface the way a person would:
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
await visitor.fill(field('Email'), 'visitor@site.test');
|
|
7
|
+
await visitor.click(button('Subscribe'));
|
|
8
|
+
await visitor.see(content('Thanks for subscribing'));
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The visitor has one vocabulary on all three surfaces, and this page owns it: the **verbs** (`fill`, `click`, `see` ...), the **targets** they act on (`field()`, `button()`, `content()` ...), and the **matching** that turns a target into exactly one element. A target names what a person would name: a role and an accessible name. There is no CSS selector, no XPath and no raw predicate; the one escape hatch, `testId()`, must say in a comment what the element lacks (rule `test-id-states-what-is-missing`).
|
|
12
|
+
|
|
13
|
+
## Verbs
|
|
14
|
+
|
|
15
|
+
A scenario is the When: pure interaction, no `expect()` inside it (rule `no-expect-in-scenario`). Every verb auto-waits; there are no sleeps anywhere in the framework.
|
|
16
|
+
|
|
17
|
+
| Verb | Does |
|
|
18
|
+
| ------------------------ | ------------------------------------------------------------------ |
|
|
19
|
+
| `click(target)` | Click the element |
|
|
20
|
+
| `tap(target)` | Tap the element, the device's word for the same act |
|
|
21
|
+
| `fill(target, value)` | Fill a form field |
|
|
22
|
+
| `press(key)` | Press a key (e.g. `'Enter'`) |
|
|
23
|
+
| `select(target, option)` | Select an option in a select field |
|
|
24
|
+
| `check(target)` | Check a checkbox or radio |
|
|
25
|
+
| `hover(target)` | Hover the element |
|
|
26
|
+
| `goto(path)` | Navigate to another path of the site under test |
|
|
27
|
+
| `rerender(ui)` | What a PARENT does: new props, same mount |
|
|
28
|
+
| `unmount()` | What a parent does when it takes the thing off the screen |
|
|
29
|
+
| `see(target)` | **The synchronization primitive**: retries until visible |
|
|
30
|
+
| `gone(target)` | The absence primitive: retries until hidden or out of the document |
|
|
31
|
+
|
|
32
|
+
The website's and the component's visitors carry the same eight page verbs by construction (one declaration, `VisitorVerbs` in `src/visitor/verbs.ts`, each surface adding only its own words), and the device's visitor takes its `see` and `fill` from it. `goto()` does not exist on a component, because a component has no address. `rerender`/`unmount` exist nowhere else, because only a component has a parent inside the test. `gone()` is absent on mobile until the adapter can answer for absence honestly, which an XCUITest tree does not do yet.
|
|
33
|
+
|
|
34
|
+
### `see()` and `gone()` are the synchronization
|
|
35
|
+
|
|
36
|
+
`see()` is how a scenario waits. It resolves through render work (a deep link into a Metro cold bundle, a network round-trip, an animation, a suspended boundary), and it absorbs them all without a single sleep. `gone()` is the same primitive pointed the other way: it retries until the element is hidden or out of the document, which is how a dismissal is asserted without guessing at an animation's length.
|
|
37
|
+
|
|
38
|
+
Both take a modifier, so `see(focused(button('Open')))` waits for the keyboard to land and `gone(disabled(button('Save')))` waits for a control to become usable again.
|
|
39
|
+
|
|
40
|
+
`see()` ACTS ON NOTHING, and on the mobile specification that changes what `target-matches-one-element` asks of it: any visible match satisfies it, because an XCUITest tree legitimately exposes the same label twice (a container and its child both carry it), and a synchronization primitive refusing on that would punish honest screens. On the web and in a component, `see()` holds the same cardinality as every other verb.
|
|
41
|
+
|
|
42
|
+
### The per-specification matrix
|
|
43
|
+
|
|
44
|
+
Which surface declares which verb, target and modifier is generated from the code: [Test § The capability matrix](https://github.com/jterrazz/package-test/blob/main/docs/guides/test.md#the-capability-matrix), its **Verb** and **Target** sections, an `n/a` for a word the specification does not declare. `within()` and `{ exact: false }` reach every surface. A blank cell is not a hole to be filled by a CSS selector; it is a boundary the adapter refuses at runtime, with a message naming it.
|
|
45
|
+
|
|
46
|
+
The ARIA landmarks are web-only: passing one to a mobile verb refuses at runtime with a message naming the boundary: an iOS screen has no ARIA regions, and inventing an equivalent would be a second vocabulary wearing the first one's words.
|
|
47
|
+
|
|
48
|
+
## Targets
|
|
49
|
+
|
|
50
|
+
The four every surface has:
|
|
51
|
+
|
|
52
|
+
| Target | Locates by |
|
|
53
|
+
| --------------- | ------------------------------------------------------------------------------------ |
|
|
54
|
+
| `button(name)` | accessible name, button role |
|
|
55
|
+
| `field(label)` | accessible name, input roles, or the label that names it |
|
|
56
|
+
| `content(text)` | any element whose text IS the text, whole (`{ exact: false }` to contain) |
|
|
57
|
+
| `testId(id)` | `data-testid` on the web, the accessibility identifier on a device: the escape hatch |
|
|
58
|
+
|
|
59
|
+
The two a browser adds, on the website and the component:
|
|
60
|
+
|
|
61
|
+
| Target | Locates by |
|
|
62
|
+
| --------------- | ----------------------------- |
|
|
63
|
+
| `link(name)` | accessible name, link role |
|
|
64
|
+
| `heading(name)` | accessible name, heading role |
|
|
65
|
+
|
|
66
|
+
Five more roles a visitor reads and acts on, each optionally named because a surface carries several of them and one of them usually carries no name at all:
|
|
67
|
+
|
|
68
|
+
| Target | Locates by |
|
|
69
|
+
| ----------------- | ------------------------------------------------------- |
|
|
70
|
+
| `dialog(name?)` | a `<dialog>` or `role="dialog"`. A closed one is absent |
|
|
71
|
+
| `status(name?)` | a live region announcing a result |
|
|
72
|
+
| `table(name?)` | a table, by its caption or accessible name |
|
|
73
|
+
| `row(name?)` | a row of a table or grid, by the text of its cells |
|
|
74
|
+
| `listitem(name?)` | an item of a list, the answer to an unnamed `<li>` |
|
|
75
|
+
|
|
76
|
+
And one role that is read where it SITS rather than where it shows:
|
|
77
|
+
|
|
78
|
+
| Target | Locates by |
|
|
79
|
+
| -------------- | ---------------------------------------------------------------------------------------------------- |
|
|
80
|
+
| `option(name)` | an option of a select or a listbox, by its label, named inside the field holding it, with `within()` |
|
|
81
|
+
|
|
82
|
+
`see(option('LinkedIn'))` asks whether the option is OFFERED, not whether it is on the screen: the options of a collapsed `<select>` are in the document and in the accessibility tree, and not one of them has a box until a visitor opens it. Which one the field is on is the `selected` modifier.
|
|
83
|
+
|
|
84
|
+
The **landmarks** are the ARIA landmark set and nothing more: a closed vocabulary keeps `within()` from becoming a second selector language. Each takes an optional accessible name, for surfaces carrying several of the same region, and `region` and `form` want one in practice, since an unnamed `<section>` or `<form>` is no landmark at all:
|
|
85
|
+
|
|
86
|
+
| Landmark | Matches |
|
|
87
|
+
| ---------------------- | ---------------------------------------- |
|
|
88
|
+
| `banner(name?)` | the page header |
|
|
89
|
+
| `navigation(name?)` | a `<nav>`; name it when several |
|
|
90
|
+
| `main(name?)` | the primary content |
|
|
91
|
+
| `complementary(name?)` | an `<aside>`, a sidebar |
|
|
92
|
+
| `contentinfo(name?)` | the page footer |
|
|
93
|
+
| `region(name?)` | a `<section>` with an accessible name |
|
|
94
|
+
| `form(name?)` | a named form landmark |
|
|
95
|
+
| `search(name?)` | a `<search>` element, or `role="search"` |
|
|
96
|
+
|
|
97
|
+
### Modifiers
|
|
98
|
+
|
|
99
|
+
Four, and none is a target of its own: each narrows a target by a STATE the vocabulary can already name the element of, and each is accepted by both `see` and `gone`.
|
|
100
|
+
|
|
101
|
+
| Modifier | Asks |
|
|
102
|
+
| -------------------------- | ---------------------------------------------------------------------------- |
|
|
103
|
+
| `focused(element)` | Where the keyboard is |
|
|
104
|
+
| `disabled(element)` | Whether the control refuses input; `enabled(element)` is the other direction |
|
|
105
|
+
| `selected(element)` | Which option the field is on |
|
|
106
|
+
| `valued(element, 'value')` | What the field holds: its live value, never its `value` attribute |
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
await visitor.press('Escape');
|
|
110
|
+
await visitor.gone(dialog('Settings'));
|
|
111
|
+
await visitor.see(focused(button('Open')));
|
|
112
|
+
await visitor.see(disabled(button('Publish')));
|
|
113
|
+
await visitor.see(selected(option('LinkedIn')));
|
|
114
|
+
await visitor.see(valued(field('Title'), 'Launch teaser'));
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
FOCUS is a verb's business and never a golden's: the accessibility tree carries none, so a tree snapshot of a surface that handed the keyboard back and one that did not are byte-identical. ENABLEMENT the tree does carry (a disabled control reads `button "Publish" [disabled]`, a selected option `[selected]`), so a golden pins it for a whole outline and `see(disabled(...))` names the one control a test is about. The modifier also buys a direction a behavioural substitute cannot reach: clicking a disabled control is a timeout, not an answer.
|
|
118
|
+
|
|
119
|
+
Scope an option to its field when a surface carries several selects, and NAME the target when you do: three constructors inside a verb is one call too deep for `unicorn/max-nested-calls`, and the name reads better besides.
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
const channel = within(field('Channel'), selected(option('LinkedIn')));
|
|
123
|
+
await visitor.see(channel);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### `within(scope, target)`
|
|
127
|
+
|
|
128
|
+
Search inside a scope, the way a person would say _"the Articles link **in the nav**"_:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
await visitor.click(within(navigation(), link('Articles')));
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Scopes compose outside-in, and any target works as one, including `testId()` when a container has no landmark role to stand on, which is every container on a device:
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
await visitor.click(within(main(), within(region('Series'), link('Part 2'))));
|
|
138
|
+
await visitor.tap(within(testId('event-list'), button('Bookmark')));
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The scope is checked first, and every scope level must itself resolve to exactly one element: if the scope is the ambiguous level, the refusal names **it** rather than the target, so the fix lands on the right target.
|
|
142
|
+
|
|
143
|
+
### `testId()`: the one escape hatch
|
|
144
|
+
|
|
145
|
+
`testId(id)` reads `data-testid` on the web and the accessibility identifier on a device. It is the ONLY target that names something a user cannot perceive, and rule `test-id-states-what-is-missing` is an error on it: a `testId()` carries a `// testId: <what is missing>` comment on its own line or the one directly above.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
// testId: the row has no accessible name; the cells carry the data
|
|
149
|
+
await visitor.click(within(testId('row-3'), button('Delete')));
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The comment is an INVARIANT, not a rationale: it states the property of the markup that makes the escape hatch necessary, so the day the element gains a name the comment is false and the target changes with it. A `testId` with no such line is a selector, and a selector is what this vocabulary exists to refuse.
|
|
153
|
+
|
|
154
|
+
## Matching
|
|
155
|
+
|
|
156
|
+
A verb acts on one element, and matching is how a target becomes it: the target is resolved on the surface, every scope level first, and the result must be exactly one element whose accessible name is the name the target wrote.
|
|
157
|
+
|
|
158
|
+
### Designating exactly one element
|
|
159
|
+
|
|
160
|
+
**A target must match exactly one element (rule `target-matches-one-element`).** When several match, the framework refuses the action instead of taking the first one:
|
|
161
|
+
|
|
162
|
+
```text
|
|
163
|
+
found: Ambiguous element: link("Articles") matched 2 elements on http://site.test/ambiguous:
|
|
164
|
+
1. <a href="/articles">Articles</a> in <nav>
|
|
165
|
+
2. <a href="/articles">Articles</a> in <footer>
|
|
166
|
+
why: A verb acts on exactly ONE element: a target that matches several is refused, never settled by the first.
|
|
167
|
+
fix: designate one of them:
|
|
168
|
+
• scope it within(navigation(), link("Articles")) [leaves 1 of 2] [also here: contentinfo()]
|
|
169
|
+
• other element a heading(), button() or field() may name one thing where this does not
|
|
170
|
+
read: docs/reference/linting.md#target-matches-one-element
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The refusal is a lesson in the four parts every message carries ([Linting](linting.md#what-a-finding-says)), one part per line.
|
|
174
|
+
|
|
175
|
+
Every suggestion carries what it LEAVES, so a rewrite that would keep the spec ambiguous never reads as a fix: a landmark holding every candidate is not offered at all. A candidate inside a named `<section>` is offered its region by name (`within(region("Orders"), ...)`, the name read from the section's `aria-label` or from what its `aria-labelledby` points at), since a page holds several regions; an unnamed `<section>` is no region, so it stays evidence (`in <section>`) and is never offered. The evidence also names the ACCESSIBLE name a candidate matched on when that is not its text: a role target matches the name, so `button('Delete post')` finds an `aria-label="Delete Post a"` too, and printing only the text would send you after something that never matched.
|
|
176
|
+
|
|
177
|
+
On a device the same refusal reads in the device's terms: there are no landmarks to offer, so the evidence offers the accessibility identifiers it found:
|
|
178
|
+
|
|
179
|
+
```text
|
|
180
|
+
found: Ambiguous element: button("Bookmark") matched 3 elements on the screen:
|
|
181
|
+
1. Button "Bookmark" [testId: event-1-bookmark]
|
|
182
|
+
2. Button "Bookmark" [testId: event-2-bookmark]
|
|
183
|
+
3. Button "Bookmark all"
|
|
184
|
+
why: A verb acts on exactly ONE element: a target that matches several is refused, never settled by the first.
|
|
185
|
+
fix: designate one of them:
|
|
186
|
+
• scope it within(testId('...'), button("Bookmark")): a screen has no landmarks, and any target works as the scope
|
|
187
|
+
• test id testId("event-1-bookmark") [also here: event-2-bookmark]
|
|
188
|
+
• other element a button() or field() may name one thing where this does not
|
|
189
|
+
read: docs/reference/linting.md#target-matches-one-element
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Taking "the first match" is the failure this rule exists to prevent: the spec stays green while the visitor acts on a different element, and nothing ever reports it. Ambiguity is an authoring mistake, not something DOM order should arbitrate.
|
|
193
|
+
|
|
194
|
+
### A name designates the accessible name WHOLE
|
|
195
|
+
|
|
196
|
+
`link('Articles')` does not reach "Read Articles". A substring default is the shape that lets a test pass for years against the element beside the one it named, and the refusal never comes, because there is only ever one match.
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
await visitor.click(link('Articles')); // exactly "Articles"
|
|
200
|
+
await visitor.click(link('Articles', { exact: false })); // also "Read Articles"
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Every named target accepts the option (`button`, `link`, `field`, `heading`, `content`, `option`, and the named landmarks) on every surface: the web compiles it to a whole-string match, the device to `==` and, opted out, to `CONTAINS`.
|
|
204
|
+
|
|
205
|
+
Reach for the opt-out only when the name genuinely carries a variable part (a count, a user's name); scope with `within()` when the problem is that the same name appears twice.
|
|
206
|
+
|
|
207
|
+
A name only part of an accessible name designates nothing, on every surface and at every level of a chain: `within(button('Proof of authorship'), content('Verified'))` against a button named "Proof of authorship Verified" fails on the SCOPE. The name to write is the ACCESSIBLE name, whole: what the ARIA tree (`result.tree`) prints for the element, never its text content: a button holding two inline children reads as `Experiments9` through its text and is named "Experiments 9", and a field's name is its label, which its text carries nothing of. On a device it is the element's label.
|
|
208
|
+
|
|
209
|
+
## Pitfalls
|
|
210
|
+
|
|
211
|
+
- **Asserting focus with a golden.** The tree carries no focus; see [§ Modifiers](#modifiers).
|
|
212
|
+
- **Expecting a name to match a prefix.** It does not: write the name whole, read off the ARIA tree, or scope it with `within(...)`.
|
|
213
|
+
- **Answering an ambiguity with `{ exact: false }`.** The option widens the match; ambiguity wants narrowing. Scope it.
|
|
214
|
+
- **Reaching for `testId()` first.** It is the last rung, and `test-id-states-what-is-missing` asks for the line saying why. A heading, a button or a field usually names the thing already.
|
|
215
|
+
- **Putting an `expect()` inside a scenario.** `no-expect-in-scenario` refuses it: a scenario is interaction, the Then is on the result.
|
|
216
|
+
- **Passing a landmark to a mobile verb.** It refuses at runtime, naming the boundary. Scope with `within(testId(...), ...)` instead.
|
|
217
|
+
- **Nesting three constructors in a verb call.** `unicorn/max-nested-calls` refuses it, and a named `const` reads better.
|
|
218
|
+
|
|
219
|
+
## Related
|
|
220
|
+
|
|
221
|
+
[Component specs](../guides/component-specs.md) · [Website specs](../guides/website-specs.md) · [Mobile specs](../guides/mobile-specs.md) · [Assertions](assertions.md) · [Tokens](tokens.md) · [Conventions](../concepts/conventions.md) · [Linting](linting.md)
|