@jterrazz/test 16.0.0 → 16.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/appium.adapter.js +59 -4
- package/dist/browser/index.js +203 -42
- package/dist/checker.js +305 -63
- package/dist/index.d.ts +35 -4
- package/dist/index.js +47 -24
- package/dist/oxlint.cjs +123 -28
- package/dist/oxlint.js +123 -28
- package/dist/playwright.adapter.js +88 -50
- package/dist/substring-warning.js +282 -0
- package/dist/vitest.js +91 -12
- package/docs/01-architecture.md +95 -0
- package/docs/02-developing.md +351 -0
- package/docs/03-testing.md +495 -0
- package/docs/04-operating.md +101 -0
- package/docs/05-module-tests.md +152 -0
- package/docs/06-integration.md +180 -0
- package/docs/07-component.md +246 -0
- package/docs/08-website.md +404 -0
- package/docs/09-mobile.md +270 -0
- package/docs/10-api.md +264 -0
- package/docs/11-jobs.md +157 -0
- package/docs/12-cli.md +677 -0
- package/docs/13-elements.md +248 -0
- package/docs/14-assertions.md +314 -0
- package/docs/15-tokens.md +253 -0
- package/docs/16-contracts.md +438 -0
- package/docs/17-services.md +188 -0
- package/docs/18-conventions.md +332 -0
- package/docs/19-linting.md +512 -0
- package/docs/README.md +36 -0
- package/docs/decisions/001-v9-conventions-on-three-channels.md +50 -0
- package/docs/decisions/002-the-statique-channel-holds-only-what-upstream-does-not.md +49 -0
- package/docs/decisions/003-a-rendered-component-is-a-facet-on-vitest-browser-mode.md +84 -0
- package/docs/decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-facet.md +81 -0
- package/docs/decisions/005-the-doubles-ladder-is-closed.md +86 -0
- package/docs/decisions/006-the-catalogue-reaches-a-file-by-its-role.md +245 -0
- package/docs/decisions/007-compose-mode-leaves-the-package.md +53 -0
- package/docs/decisions/008-vitest-5-node-24-and-the-native-seams-as-optional-peers.md +75 -0
- package/docs/decisions/_template.md +22 -0
- package/docs/reference/classes/Matcher.md +120 -0
- package/docs/reference/classes/ProcessHandle.md +368 -0
- package/docs/reference/functions/defineContract.md +39 -0
- package/docs/reference/functions/defineContracts.md +36 -0
- package/docs/reference/functions/disabled.md +28 -0
- package/docs/reference/functions/enabled.md +20 -0
- package/docs/reference/functions/focused.md +26 -0
- package/docs/reference/functions/mockOf.md +79 -0
- package/docs/reference/functions/postgres.md +31 -0
- package/docs/reference/functions/process.md +33 -0
- package/docs/reference/functions/redis.md +28 -0
- package/docs/reference/functions/required.md +41 -0
- package/docs/reference/functions/selected.md +26 -0
- package/docs/reference/functions/sqlite.md +33 -0
- package/docs/reference/functions/testId.md +20 -0
- package/docs/reference/functions/text.md +40 -0
- package/docs/reference/functions/valued.md +28 -0
- package/docs/reference/functions/waitUntil.md +46 -0
- package/docs/reference/functions/within.md +29 -0
- package/docs/reference/index.md +196 -0
- package/docs/reference/interfaces/BaseResult.md +87 -0
- package/docs/reference/interfaces/CallResult.md +160 -0
- package/docs/reference/interfaces/CaptureScope.md +82 -0
- package/docs/reference/interfaces/CliResult.md +253 -0
- package/docs/reference/interfaces/ContainerAccessor.md +166 -0
- package/docs/reference/interfaces/DirectoryAccessor.md +33 -0
- package/docs/reference/interfaces/FetchResult.md +180 -0
- package/docs/reference/interfaces/FilesystemAccessor.md +46 -0
- package/docs/reference/interfaces/HttpResult.md +126 -0
- package/docs/reference/interfaces/JsonAccessor.md +46 -0
- package/docs/reference/interfaces/PageResult.md +356 -0
- package/docs/reference/interfaces/RenderResult.md +72 -0
- package/docs/reference/interfaces/RenderedText.md +110 -0
- package/docs/reference/interfaces/ResponseAccessor.md +47 -0
- package/docs/reference/interfaces/ScreenResult.md +128 -0
- package/docs/reference/interfaces/TableAccessor.md +26 -0
- package/docs/reference/interfaces/TextAccessor.md +96 -0
- package/docs/reference/type-aliases/ApiHandle.md +68 -0
- package/docs/reference/type-aliases/ApiSpecification.md +213 -0
- package/docs/reference/type-aliases/ApiSpecificationOptions.md +68 -0
- package/docs/reference/type-aliases/BrowserConsoleMessage.md +30 -0
- package/docs/reference/type-aliases/BrowserLinkElement.md +50 -0
- package/docs/reference/type-aliases/BrowserMetaElement.md +40 -0
- package/docs/reference/type-aliases/BrowserOpenOptions.md +87 -0
- package/docs/reference/type-aliases/BrowserPage.md +132 -0
- package/docs/reference/type-aliases/BrowserPort.md +51 -0
- package/docs/reference/type-aliases/CliEnv.md +11 -0
- package/docs/reference/type-aliases/CliHandle.md +80 -0
- package/docs/reference/type-aliases/CliInput.md +36 -0
- package/docs/reference/type-aliases/CliOutput.md +46 -0
- package/docs/reference/type-aliases/CliPort.md +58 -0
- package/docs/reference/type-aliases/CliSpecification.md +144 -0
- package/docs/reference/type-aliases/CliSpecificationOptions.md +134 -0
- package/docs/reference/type-aliases/ComponentChain.md +156 -0
- package/docs/reference/type-aliases/ComponentScenario.md +20 -0
- package/docs/reference/type-aliases/ComponentUi.md +47 -0
- package/docs/reference/type-aliases/ComponentVisitor.md +10 -0
- package/docs/reference/type-aliases/ContainerPort.md +113 -0
- package/docs/reference/type-aliases/Contract.md +68 -0
- package/docs/reference/type-aliases/ContractInput.md +13 -0
- package/docs/reference/type-aliases/ContractRequest.md +95 -0
- package/docs/reference/type-aliases/ContractResponder.md +23 -0
- package/docs/reference/type-aliases/ContractResponse.md +78 -0
- package/docs/reference/type-aliases/ContractResponseValue.md +13 -0
- package/docs/reference/type-aliases/Contracts.md +48 -0
- package/docs/reference/type-aliases/DatabaseKeys.md +16 -0
- package/docs/reference/type-aliases/DatabasePort.md +72 -0
- package/docs/reference/type-aliases/DeviceOpenOptions.md +46 -0
- package/docs/reference/type-aliases/DevicePort.md +51 -0
- package/docs/reference/type-aliases/DeviceScreen.md +32 -0
- package/docs/reference/type-aliases/DeviceTimeouts.md +39 -0
- package/docs/reference/type-aliases/DockerSpecConfig.md +43 -0
- package/docs/reference/type-aliases/DomMount.md +21 -0
- package/docs/reference/type-aliases/ElementKind.md +27 -0
- package/docs/reference/type-aliases/ElementMatch.md +74 -0
- package/docs/reference/type-aliases/ElementOptions.md +29 -0
- package/docs/reference/type-aliases/ElementRef.md +128 -0
- package/docs/reference/type-aliases/ExecOptions.md +37 -0
- package/docs/reference/type-aliases/FileAccessor.md +54 -0
- package/docs/reference/type-aliases/HonoApp.md +31 -0
- package/docs/reference/type-aliases/HttpContractFilter.md +49 -0
- package/docs/reference/type-aliases/HttpResponseInit.md +46 -0
- package/docs/reference/type-aliases/HttpStreamInit.md +58 -0
- package/docs/reference/type-aliases/IntegrationHandle.md +46 -0
- package/docs/reference/type-aliases/IntegrationSpecification.md +111 -0
- package/docs/reference/type-aliases/IntegrationSpecificationOptions.md +42 -0
- package/docs/reference/type-aliases/Intercept.md +19 -0
- package/docs/reference/type-aliases/InterceptOptions.md +28 -0
- package/docs/reference/type-aliases/InterceptScope.md +11 -0
- package/docs/reference/type-aliases/IsolationStrategy.md +75 -0
- package/docs/reference/type-aliases/JobHandle.md +34 -0
- package/docs/reference/type-aliases/JobsHandle.md +57 -0
- package/docs/reference/type-aliases/JobsResult.md +16 -0
- package/docs/reference/type-aliases/JobsSpecification.md +94 -0
- package/docs/reference/type-aliases/JobsSpecificationOptions.md +56 -0
- package/docs/reference/type-aliases/LandmarkKind.md +20 -0
- package/docs/reference/type-aliases/LiterateRunFlags.md +27 -0
- package/docs/reference/type-aliases/LiterateServeRegistration.md +70 -0
- package/docs/reference/type-aliases/MatchFixtureOptions.md +27 -0
- package/docs/reference/type-aliases/MatchableRequest.md +61 -0
- package/docs/reference/type-aliases/MatcherKind.md +34 -0
- package/docs/reference/type-aliases/MobileBackendOptions.md +28 -0
- package/docs/reference/type-aliases/MobileElementKind.md +14 -0
- package/docs/reference/type-aliases/MobileElementMatch.md +60 -0
- package/docs/reference/type-aliases/MobileElementRef.md +20 -0
- package/docs/reference/type-aliases/MobileHandle.md +65 -0
- package/docs/reference/type-aliases/MobileScenario.md +20 -0
- package/docs/reference/type-aliases/MobileSpecification.md +51 -0
- package/docs/reference/type-aliases/MobileSpecificationOptions.md +126 -0
- package/docs/reference/type-aliases/MobileVisitor.md +81 -0
- package/docs/reference/type-aliases/MockOfOptions.md +26 -0
- package/docs/reference/type-aliases/MockPort.md +10 -0
- package/docs/reference/type-aliases/PinnedClock.md +10 -0
- package/docs/reference/type-aliases/PostgresOptions.md +32 -0
- package/docs/reference/type-aliases/ProcessOptions.md +112 -0
- package/docs/reference/type-aliases/RedisOptions.md +20 -0
- package/docs/reference/type-aliases/RenderSubject.md +12 -0
- package/docs/reference/type-aliases/ScreenNode.md +67 -0
- package/docs/reference/type-aliases/ServerPort.md +36 -0
- package/docs/reference/type-aliases/ServerResponse.md +46 -0
- package/docs/reference/type-aliases/ServerSpec.md +21 -0
- package/docs/reference/type-aliases/ServiceHandle.md +216 -0
- package/docs/reference/type-aliases/ServiceRecord.md +12 -0
- package/docs/reference/type-aliases/SpecDocument.md +84 -0
- package/docs/reference/type-aliases/SpecEnvToken.md +21 -0
- package/docs/reference/type-aliases/SpecFileAssertion.md +27 -0
- package/docs/reference/type-aliases/SpecFixture.md +30 -0
- package/docs/reference/type-aliases/SpecKind.md +8 -0
- package/docs/reference/type-aliases/SpecRun.md +108 -0
- package/docs/reference/type-aliases/SpecServeEntry.md +40 -0
- package/docs/reference/type-aliases/SpecStream.md +32 -0
- package/docs/reference/type-aliases/SpecificationConfig.md +290 -0
- package/docs/reference/type-aliases/SqliteOptions.md +36 -0
- package/docs/reference/type-aliases/SseEvent.md +58 -0
- package/docs/reference/type-aliases/StreamBody.md +61 -0
- package/docs/reference/type-aliases/TextFilter.md +22 -0
- package/docs/reference/type-aliases/VisitScenario.md +20 -0
- package/docs/reference/type-aliases/Visitor.md +215 -0
- package/docs/reference/type-aliases/WaitUntilOptions.md +46 -0
- package/docs/reference/type-aliases/WebsiteBackendOptions.md +37 -0
- package/docs/reference/type-aliases/WebsiteHandle.md +51 -0
- package/docs/reference/type-aliases/WebsiteSpecification.md +118 -0
- package/docs/reference/type-aliases/WebsiteSpecificationOptions.md +40 -0
- package/docs/reference/variables/anthropic.md +19 -0
- package/docs/reference/variables/banner.md +10 -0
- package/docs/reference/variables/button.md +10 -0
- package/docs/reference/variables/clock.md +16 -0
- package/docs/reference/variables/complementary.md +10 -0
- package/docs/reference/variables/component.md +10 -0
- package/docs/reference/variables/content.md +10 -0
- package/docs/reference/variables/contentinfo.md +10 -0
- package/docs/reference/variables/dialog.md +10 -0
- package/docs/reference/variables/field.md +10 -0
- package/docs/reference/variables/form.md +10 -0
- package/docs/reference/variables/heading.md +10 -0
- package/docs/reference/variables/http.md +40 -0
- package/docs/reference/variables/intercept.md +11 -0
- package/docs/reference/variables/link.md +10 -0
- package/docs/reference/variables/listitem.md +10 -0
- package/docs/reference/variables/main.md +10 -0
- package/docs/reference/variables/match.md +8 -0
- package/docs/reference/variables/navigation.md +10 -0
- package/docs/reference/variables/openai.md +21 -0
- package/docs/reference/variables/option.md +17 -0
- package/docs/reference/variables/region.md +10 -0
- package/docs/reference/variables/row.md +10 -0
- package/docs/reference/variables/search.md +10 -0
- package/docs/reference/variables/specification.md +8 -0
- package/docs/reference/variables/status.md +10 -0
- package/docs/reference/variables/table.md +10 -0
- package/package.json +3 -2
- package/dist/ambiguity.js +0 -146
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# 13 — Elements: one vocabulary, three surfaces
|
|
2
|
+
|
|
3
|
+
Three facets drive something a person can see — a served page ([08](08-website.md)), a mounted unit ([07](07-component.md)), a screen on a simulator ([09](09-mobile.md)). They name what they act on with **one vocabulary**, owned here and restated nowhere: the same descriptors, the same modifiers, the same ambiguity refusal, the same matching rule. A facet chapter says what its surface adds; it does not redefine a word.
|
|
4
|
+
|
|
5
|
+
The vocabulary is user-facing by construction (rule W2): there is no CSS surface, no XPath and no raw predicate. A test names what a person would name.
|
|
6
|
+
|
|
7
|
+
| The shape | Held below |
|
|
8
|
+
| -------------------- | ------------------------------------------------------------------------------------------- |
|
|
9
|
+
| The words | [Descriptors](#descriptors) |
|
|
10
|
+
| The states | [Modifiers](#modifiers) |
|
|
11
|
+
| The actions | [Verbs](#verbs) |
|
|
12
|
+
| Which facet has what | [The per-facet matrix](#the-per-facet-matrix) |
|
|
13
|
+
| Waiting | [`see()` and `gone()` are the synchronization](#see-and-gone-are-the-synchronization) |
|
|
14
|
+
| One element, exactly | [Designating exactly one element](#designating-exactly-one-element) |
|
|
15
|
+
| Narrowing | [`within(scope, target)`](#withinscope-target) |
|
|
16
|
+
| Matching | [A name designates the accessible name WHOLE](#a-name-designates-the-accessible-name-whole) |
|
|
17
|
+
| The escape hatch | [`testId()`](#testid--the-one-escape-hatch) |
|
|
18
|
+
|
|
19
|
+
## Descriptors
|
|
20
|
+
|
|
21
|
+
The six every surface has:
|
|
22
|
+
|
|
23
|
+
| Descriptor | Locates by |
|
|
24
|
+
| --------------- | ------------------------------------------------------------------------------------- |
|
|
25
|
+
| `button(name)` | accessible name, button role |
|
|
26
|
+
| `link(name)` | accessible name, link role |
|
|
27
|
+
| `field(label)` | accessible name, input roles — or the label that names it |
|
|
28
|
+
| `heading(name)` | accessible name, heading role |
|
|
29
|
+
| `content(text)` | any element containing the text |
|
|
30
|
+
| `testId(id)` | `data-testid` on the web, the accessibility identifier on a device — the escape hatch |
|
|
31
|
+
|
|
32
|
+
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:
|
|
33
|
+
|
|
34
|
+
| Descriptor | Locates by |
|
|
35
|
+
| ----------------- | ------------------------------------------------------- |
|
|
36
|
+
| `dialog(name?)` | a `<dialog>` or `role="dialog"`. A closed one is absent |
|
|
37
|
+
| `status(name?)` | a live region announcing a result |
|
|
38
|
+
| `table(name?)` | a table, by its caption or accessible name |
|
|
39
|
+
| `row(name?)` | a row of a table or grid, by the text of its cells |
|
|
40
|
+
| `listitem(name?)` | an item of a list — the answer to an unnamed `<li>` |
|
|
41
|
+
|
|
42
|
+
And one role that is read where it SITS rather than where it shows:
|
|
43
|
+
|
|
44
|
+
| Descriptor | Locates by |
|
|
45
|
+
| -------------- | ----------------------------------------------------------------------------------------------------- |
|
|
46
|
+
| `option(name)` | an option of a select or a listbox, by its label — named inside the field holding it, with `within()` |
|
|
47
|
+
|
|
48
|
+
`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.
|
|
49
|
+
|
|
50
|
+
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:
|
|
51
|
+
|
|
52
|
+
| Landmark | Matches |
|
|
53
|
+
| ---------------------- | ------------------------------------- |
|
|
54
|
+
| `banner()` | the page header |
|
|
55
|
+
| `navigation(name?)` | a `<nav>` — name it when several |
|
|
56
|
+
| `main()` | the primary content |
|
|
57
|
+
| `complementary(name?)` | an `<aside>`, a sidebar |
|
|
58
|
+
| `contentinfo()` | the page footer |
|
|
59
|
+
| `region(name)` | a `<section>` with an accessible name |
|
|
60
|
+
| `form(name)` | a named form landmark |
|
|
61
|
+
| `search()` | the search landmark |
|
|
62
|
+
|
|
63
|
+
## Modifiers
|
|
64
|
+
|
|
65
|
+
Four, and none is a descriptor of its own: each narrows a descriptor by a STATE the vocabulary can already name the element of, and each is accepted by both `see` and `gone`.
|
|
66
|
+
|
|
67
|
+
| Modifier | Asks |
|
|
68
|
+
| -------------------------- | ----------------------------------------------------------------------------- |
|
|
69
|
+
| `focused(element)` | Where the keyboard is |
|
|
70
|
+
| `disabled(element)` | Whether the control refuses input — `enabled(element)` is the other direction |
|
|
71
|
+
| `selected(element)` | Which option the field is on |
|
|
72
|
+
| `valued(element, 'value')` | What the field holds — its live value, never its `value` attribute |
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
await visitor.press('Escape');
|
|
76
|
+
await visitor.gone(dialog('Settings'));
|
|
77
|
+
await visitor.see(focused(button('Open')));
|
|
78
|
+
await visitor.see(disabled(button('Publish')));
|
|
79
|
+
await visitor.see(selected(option('LinkedIn')));
|
|
80
|
+
await visitor.see(valued(field('Title'), 'Launch teaser'));
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
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.
|
|
84
|
+
|
|
85
|
+
Scope an option to its field when a surface carries several selects — and NAME the descriptor when you do: three constructors inside a verb is one call too deep for `unicorn/max-nested-calls`, and the name reads better besides.
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
const channel = within(field('Channel'), selected(option('LinkedIn')));
|
|
89
|
+
await visitor.see(channel);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Verbs
|
|
93
|
+
|
|
94
|
+
A scenario is the When: pure interaction, no `expect()` inside it (rule W1). Every verb auto-waits; there are no sleeps anywhere in the framework.
|
|
95
|
+
|
|
96
|
+
| Verb | Does | Where |
|
|
97
|
+
| ------------------------- | ------------------------------------------------------------------- | -------------- |
|
|
98
|
+
| `click(element)` | Click the element | web, component |
|
|
99
|
+
| `tap(element)` | Tap the element — the device's word for the same act | mobile |
|
|
100
|
+
| `fill(element, value)` | Fill a form field | everywhere |
|
|
101
|
+
| `press(key)` | Press a key (e.g. `'Enter'`) | web, component |
|
|
102
|
+
| `select(element, option)` | Select an option in a select field | web, component |
|
|
103
|
+
| `check(element)` | Check a checkbox or radio | web, component |
|
|
104
|
+
| `hover(element)` | Hover the element | web, component |
|
|
105
|
+
| `goto(path)` | Navigate to another path of the site under test | web |
|
|
106
|
+
| `rerender(ui)` | What a PARENT does: new props, same mount | component |
|
|
107
|
+
| `unmount()` | What a parent does when it takes the thing off the screen | component |
|
|
108
|
+
| `see(element)` | **The synchronization primitive** — retries until visible | everywhere |
|
|
109
|
+
| `gone(element)` | The absence primitive — retries until hidden or out of the document | web, component |
|
|
110
|
+
|
|
111
|
+
`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()` waits for the mobile facet's adapter to answer for absence, which an XCUITest tree does not do honestly yet.
|
|
112
|
+
|
|
113
|
+
## The per-facet matrix
|
|
114
|
+
|
|
115
|
+
What each surface can name. 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.
|
|
116
|
+
|
|
117
|
+
| Word | website | component | mobile |
|
|
118
|
+
| -------------------------------------------------- | ------- | --------- | ------ |
|
|
119
|
+
| `button` `field` `content` `testId` | yes | yes | yes |
|
|
120
|
+
| `link` `heading` | yes | yes | — |
|
|
121
|
+
| `dialog` `status` `table` `row` `listitem` | yes | yes | — |
|
|
122
|
+
| `option` | yes | yes | — |
|
|
123
|
+
| landmarks (`main` `navigation` `region` …) | yes | yes | — |
|
|
124
|
+
| `within(scope, target)` | yes | yes | yes |
|
|
125
|
+
| `{ exact: false }` | yes | yes | yes |
|
|
126
|
+
| `focused` `disabled` `enabled` `selected` `valued` | yes | yes | — |
|
|
127
|
+
| `see` | yes | yes | yes |
|
|
128
|
+
| `gone` | yes | yes | — |
|
|
129
|
+
|
|
130
|
+
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.
|
|
131
|
+
|
|
132
|
+
## `see()` and `gone()` are the synchronization
|
|
133
|
+
|
|
134
|
+
`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.
|
|
135
|
+
|
|
136
|
+
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.
|
|
137
|
+
|
|
138
|
+
`see()` ACTS ON NOTHING, and on the mobile facet that changes what W3 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.
|
|
139
|
+
|
|
140
|
+
## Designating exactly one element
|
|
141
|
+
|
|
142
|
+
**A descriptor must match exactly one element (rule W3).** When several match, the framework refuses the action instead of taking the first one:
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
Ambiguous element: link("Articles") matched 3 elements on http://site.test/ambiguous.
|
|
146
|
+
|
|
147
|
+
A spec must designate exactly one element. Acting on the first match would let
|
|
148
|
+
this test keep passing while the visitor interacts with something else.
|
|
149
|
+
|
|
150
|
+
Matched:
|
|
151
|
+
1. <a href="/articles">Articles</a> in <nav>
|
|
152
|
+
2. <a href="/articles">Articles</a> in <footer>
|
|
153
|
+
|
|
154
|
+
Disambiguate with one of:
|
|
155
|
+
• scope it within(navigation(), link("Articles")) [leaves 1 of 2] [also here: contentinfo()]
|
|
156
|
+
• other element a heading(), button() or field() may name one thing where this does not
|
|
157
|
+
|
|
158
|
+
Docs: docs/13-elements.md#designating-exactly-one-element (CONVENTIONS W3)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
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. The evidence also names the ACCESSIBLE name a candidate matched on when that is not its text — a role descriptor 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.
|
|
162
|
+
|
|
163
|
+
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:
|
|
164
|
+
|
|
165
|
+
```
|
|
166
|
+
Ambiguous element: button("Bookmark") matched 3 elements on the screen.
|
|
167
|
+
|
|
168
|
+
Matched:
|
|
169
|
+
1. Button "Bookmark" [testId: event-1-bookmark]
|
|
170
|
+
2. Button "Bookmark" [testId: event-2-bookmark]
|
|
171
|
+
3. Button "Bookmark all"
|
|
172
|
+
|
|
173
|
+
Disambiguate with one of:
|
|
174
|
+
• scope it within(testId('…'), button("Bookmark")) — a screen has no landmarks; any descriptor works as the scope
|
|
175
|
+
• test id testId("event-1-bookmark") [also here: event-2-bookmark]
|
|
176
|
+
• other element a button() or field() may name one thing where this does not
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
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.
|
|
180
|
+
|
|
181
|
+
## `within(scope, target)`
|
|
182
|
+
|
|
183
|
+
Search inside a scope, the way a person would say _"the Articles link **in the nav**"_:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
await visitor.click(within(navigation(), link('Articles')));
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Scopes compose outside-in, and any descriptor works as one — including `testId()` when a container has no landmark role to stand on, which is every container on a device:
|
|
190
|
+
|
|
191
|
+
```typescript
|
|
192
|
+
await visitor.click(within(main(), within(region('Series'), link('Part 2'))));
|
|
193
|
+
await visitor.tap(within(testId('event-list'), button('Bookmark')));
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
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 descriptor.
|
|
197
|
+
|
|
198
|
+
## A name designates the accessible name WHOLE
|
|
199
|
+
|
|
200
|
+
`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.
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
await visitor.click(link('Articles')); // exactly "Articles"
|
|
204
|
+
await visitor.click(link('Articles', { exact: false })); // also "Read Articles"
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Every named descriptor 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`.
|
|
208
|
+
|
|
209
|
+
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.
|
|
210
|
+
|
|
211
|
+
**The transitional window.** Exact-by-default arrived in 16.0, where substring was the rule before it. Through 16.x, a descriptor that designates NOTHING as a whole name but WOULD have designated something as a substring is RESOLVED as a substring, once, and prints one line naming itself, the whole name it matched — the spelling to write — the three ways out and this deadline. The line is printed once per descriptor per run, by whichever adapter did the looking: a page's, a mounted component's, a screen's. It is a runtime warning and not a lint rule on purpose: whether a name matched whole or in part is a fact about a RUN, and a verdict fed by a previous run's artefact is not deterministic. In 17.0 the fallback is gone and the same descriptor is simply not found.
|
|
212
|
+
|
|
213
|
+
The window reaches every LEVEL of a chain, not the target alone: `within(button('Proof of authorship'), content('Verified'))` against a button named "Proof of authorship Verified" fails on the SCOPE, and a window that only ever widened the target left that migration with no line to read at all. The chain is walked outside-in, each level asked the same question, and a level that states its own `exact` is left as the author wrote it.
|
|
214
|
+
|
|
215
|
+
The window is asked BEFORE the verb acts, not after it fails: a verb waits its whole actionability budget, which is the test's own budget, so a retry afterwards never runs — the test is already over. What it costs on the ordinary path is one count per level of the chain, which waits for nothing.
|
|
216
|
+
|
|
217
|
+
**A widening is decided on a SETTLED surface.** A count is one observation, and a `see()` fired right after a navigating `click()` takes it on the page being left: a descriptor that is perfectly exact on the destination read as substring-only, non-deterministically, and the tell was the printed name coming back equal to the name already written. So a level that designates nothing and has exactly one substring match is given five seconds to arrive before anything is printed, and the verdict is reached once per descriptor per run — a scenario that clicks the same link in ten tests waits on the first of them. Nothing else waits: a descriptor with no substring match at all, which is every ordinary `gone()`, is still two counts and no pause.
|
|
218
|
+
|
|
219
|
+
It widens on exactly one match, never on several: a name two elements carry in part is the ambiguity the old default hid, and the window may not resolve it by guessing which one the author meant. That descriptor designates nothing, as W3 says, and the refusal is the one to read.
|
|
220
|
+
|
|
221
|
+
**The name it prints is the ACCESSIBLE name, and the whole of it.** It is read off the element's ARIA snapshot — the browser's own name computation, which is what the descriptor matched on — and never off the 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. Nor is it truncated: a name cut at eighty characters, mid-word, designates nothing when it is written back. The one descriptor whose printed name is TEXT is `content()`, because text is what it matches. On a device the name is what the device reports as the element's label.
|
|
222
|
+
|
|
223
|
+
**The line goes to stderr**, and never to the console. Vitest CAPTURES the console and its default reporter prints nothing of what it captured, so a warning written with `console.warn` is invisible in the very run that fired it — `npm test` showed zero lines on a run that had one, and a deadline nobody can see is a deadline nobody meets. The node-side adapters write the line to the runner's own stderr; a mounted component, which runs IN the page and has no stderr, hands it back to the node side through `server.commands`. Nothing but a plain `vitest --run` is needed to read it.
|
|
224
|
+
|
|
225
|
+
## `testId()` — the one escape hatch
|
|
226
|
+
|
|
227
|
+
`testId(id)` reads `data-testid` on the web and the accessibility identifier on a device. It is the ONLY descriptor that names something a user cannot perceive, and rule W2 is an error on it: a `testId()` carries a same-line comment naming what the element lacks.
|
|
228
|
+
|
|
229
|
+
```typescript
|
|
230
|
+
// testId: the row has no accessible name — the cells carry the data
|
|
231
|
+
await visitor.click(within(testId('row-3'), button('Delete')));
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
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 descriptor changes with it. A `testId` with no such line is a selector, and a selector is what this vocabulary exists to refuse.
|
|
235
|
+
|
|
236
|
+
## Pitfalls
|
|
237
|
+
|
|
238
|
+
- **Asserting focus with a golden.** The accessibility tree carries no focus state. `see(focused(x))` / `gone(focused(x))` are the verbs that do.
|
|
239
|
+
- **Expecting a name to match a prefix.** It does not since 16.0. Read the transitional warning; it names the three ways out.
|
|
240
|
+
- **Answering an ambiguity with `{ exact: false }`.** The option widens the match; ambiguity wants narrowing. Scope it.
|
|
241
|
+
- **Reaching for `testId()` first.** It is the last rung, and W2 asks for the line saying why. A heading, a button or a field usually names the thing already.
|
|
242
|
+
- **Putting an `expect()` inside a scenario.** W1 refuses it: a scenario is interaction, the Then is on the result.
|
|
243
|
+
- **Passing a landmark to a mobile verb.** It refuses at runtime, naming the boundary. Scope with `within(testId(…), …)` instead.
|
|
244
|
+
- **Nesting three constructors in a verb call.** `unicorn/max-nested-calls` refuses it, and a named `const` reads better.
|
|
245
|
+
|
|
246
|
+
## Related
|
|
247
|
+
|
|
248
|
+
[07 — Component specs](07-component.md) · [08 — Website specs](08-website.md) · [09 — Mobile specs](09-mobile.md) · [14 — Assertions](14-assertions.md) · [15 — Tokens](15-tokens.md) · [18 — Conventions](18-conventions.md) · [19 — Linting](19-linting.md)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
# 14 — Assertions: the reference
|
|
2
|
+
|
|
3
|
+
Every assertion goes through `expect()` (rule D1). The framework auto-registers its matchers into vitest and types them **by subject**: a table subject only offers table matchers, a stream subject only stream matchers — the wrong pairing is a compile error. Result accessors are read-only; there are no assertion methods on accessors (`result.stdout.toContain(…)` does not exist — the methods live on `expect()`).
|
|
4
|
+
|
|
5
|
+
This chapter is the exhaustive matrix: every matcher, for every valid subject, with its sync/async rule and its resolution target.
|
|
6
|
+
|
|
7
|
+
## The two global rules
|
|
8
|
+
|
|
9
|
+
### Sync vs async (rule D2)
|
|
10
|
+
|
|
11
|
+
`await expect(…)` is used **only** for matchers that perform IO, and this table is the whole list — the types say the same thing, so a mistaken `await` is a toolchain error (`typescript(await-thenable)`) in a consumer and a missed one is an assertion that never runs:
|
|
12
|
+
|
|
13
|
+
| IO matcher subjects | Why |
|
|
14
|
+
| ------------------------------------------------------------------- | ------------------------------------ |
|
|
15
|
+
| `result.table(…)`, and `toBeEmpty()` on it | Runs a SQL query |
|
|
16
|
+
| `result.filesystem` / `result.directory(…)` | Walks the disk |
|
|
17
|
+
| container subjects | Talks to the Docker daemon |
|
|
18
|
+
| every accessor of a COMPONENT result (`tree`, `html`, `content`, …) | The capture crosses the browser seam |
|
|
19
|
+
|
|
20
|
+
Everything else is synchronous, and that includes the subjects it is easiest to get wrong: `result.value`, `result.error`, `result.response`, `result.stdout`, `result.stderr`, `result.json` — and `result.tree` on a WEBSITE page, which is captured on the node side and is a plain text subject there. Only a component's accessors are read inside the page.
|
|
21
|
+
|
|
22
|
+
### `toMatch` resolution (rule D3)
|
|
23
|
+
|
|
24
|
+
`.request(file)` reads `_requests/<file>`. Everything else is expected output: `expect(...).toMatch(name)` **always** resolves against `_expected/<name>`, for every subject — `response`, `stdout`, `stderr`, `json`, `directory`, `filesystem`. There is no per-subject resolution.
|
|
25
|
+
|
|
26
|
+
`_expected/` is flat: `toMatch('help.txt')` → `_expected/help.txt`. A slash in the name creates a subfolder: `toMatch('build/verbose.txt')` → `_expected/build/verbose.txt` (rule C5). The extension is part of the name and mandatory (`'help.txt'`, never `'help'`) — except for tree snapshots, which are directories: `toMatch('shop-scaffold')` → `_expected/shop-scaffold/` (rule C6).
|
|
27
|
+
|
|
28
|
+
All file-based comparisons understand the [`{{token}}` grammar](15-tokens.md); all code-side dynamic values use `match.*`.
|
|
29
|
+
|
|
30
|
+
## Scalars — native `expect`
|
|
31
|
+
|
|
32
|
+
Plain values take vitest's native matchers. No framework matcher exists (or is needed) for them.
|
|
33
|
+
|
|
34
|
+
| Subject | Type | Example |
|
|
35
|
+
| ----------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------ |
|
|
36
|
+
| `result.status` | `number` | `expect(result.status).toBe(201)` |
|
|
37
|
+
| `result.exitCode` | `number` | `expect(result.exitCode).toBe(0)` |
|
|
38
|
+
| `result.filesystem.cwd` | `string` | `expect(result.filesystem.cwd).toContain('/tmp/')` |
|
|
39
|
+
| `result.response.body` | parsed body | `expect(result.response.body).toEqual({ error: 'User 999 not found' })` |
|
|
40
|
+
| `result.json.value` | parsed JSON | `expect(result.json.value).toMatchObject({ name: 'shoply' })` |
|
|
41
|
+
| `result.stdout.grep(pattern)` | `TextAccessor` | `expect(result.stdout.grep('broken.yaml')).toContain('missing price')` |
|
|
42
|
+
| `result.file(path).exists` | `boolean` | `expect(result.file('dist/index.js').exists).toBe(true)` |
|
|
43
|
+
| `result.file(path).content` | `string` | `expect(result.file('shoply.yaml').content).toContain('name: my-shop')` |
|
|
44
|
+
| `result.stdout.text` | `string` | `expect(result.stdout.text).toBe('')` — raw capture, never stripped |
|
|
45
|
+
| `result.containerIds` | `string[]` | `expect(result.containerIds).toHaveLength(1)` |
|
|
46
|
+
| `await result.filesystem.files()` | `string[]` | `expect(await result.filesystem.files()).toContain('shoply.lock')` |
|
|
47
|
+
| `await cli.run('<case>.spec.yaml')` | `CliResult` | the document asserts itself; the LAST run's result comes back — [12](12-cli.md#spec-documents--casespecyaml) |
|
|
48
|
+
|
|
49
|
+
## `result.response` — HTTP response (api)
|
|
50
|
+
|
|
51
|
+
| Matcher | Sync/async | Resolves against | Example |
|
|
52
|
+
| ------------------- | ---------- | ------------------ | ------------------------------------------------------ |
|
|
53
|
+
| `toMatch('x.http')` | sync | `_expected/x.http` | `expect(result.response).toMatch('user-created.http')` |
|
|
54
|
+
|
|
55
|
+
Checks, in order: the status line, the listed headers (**subset** — unlisted response headers are unconstrained, rule C3), then the body. Placeholders apply in headers _and_ body, with `#ref` captures shared across both:
|
|
56
|
+
|
|
57
|
+
```http
|
|
58
|
+
HTTP/1.1 201 Created
|
|
59
|
+
Location: /orders/{{uuid#order}}
|
|
60
|
+
|
|
61
|
+
{ "id": "{{uuid#order}}" }
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
On mismatch the failure shows which part diverged. A status mismatch is a three-line message naming the fixture:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
Response status mismatch (user-created.http)
|
|
68
|
+
expected: 201
|
|
69
|
+
received: 500
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
A header mismatch has the same shape (`Response header mismatch (name)`, with `header:` / `expected:` / `received:` lines; a missing header shows `received: (absent)`). A body mismatch prints a line-by-line structural diff (`Response mismatch (name)`, `- Expected` / `+ Received`) of the expected body — with matchers and tokens rendered as their placeholder text (`{{uuid#order}}`) — against the actual body. The diff is literal: it does not annotate captured ref values, and a failed `{{uuid#order}}` re-occurrence surfaces only as the two differing lines.
|
|
73
|
+
|
|
74
|
+
`.not`: `expect(result.response).not.toMatch('user-created.http')` passes when any part diverges. Rarely useful — prefer positive fixtures.
|
|
75
|
+
|
|
76
|
+
## `result.table(name, { database }?)` — database tables (api, jobs, cli)
|
|
77
|
+
|
|
78
|
+
Always `await expect(…)` (IO). `database:` is **mandatory when the services record declares ≥ 2 databases, forbidden with exactly 1** (rule A7).
|
|
79
|
+
|
|
80
|
+
| Matcher | Sync/async | Example |
|
|
81
|
+
| -------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
|
82
|
+
| `toMatchRows({ columns, rows })` | async | see below |
|
|
83
|
+
| `toBeEmpty()` | async | `await expect(result.table('orders', { database: 'db' })).toBeEmpty()` |
|
|
84
|
+
| `toBeEmpty()` on a text subject | async | `await expect(result.error).toBeEmpty()` — the integration facet's "and it did not refuse" ([06](06-integration.md#the-result)) |
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
await expect(result.table('orders', { database: 'db' })).toMatchRows({
|
|
88
|
+
columns: ['id', 'status', 'total_cents', 'created_at'],
|
|
89
|
+
rows: [[match.uuid(), 'pending', match.number(), match.iso8601()]],
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Cell values are literals or [`match.*`](15-tokens.md) matchers — including `match.ref(name)` to capture a generated value in one table and require equality in another, and `match.ref(name, { not: other })` to require inequality:
|
|
94
|
+
|
|
95
|
+
```typescript
|
|
96
|
+
await expect(result.table('orders', { database: 'db' })).toMatchRows({
|
|
97
|
+
columns: ['id'],
|
|
98
|
+
rows: [[match.ref('order')]],
|
|
99
|
+
});
|
|
100
|
+
await expect(result.table('payment_intents', { database: 'db' })).toMatchRows({
|
|
101
|
+
columns: ['id', 'order_id'],
|
|
102
|
+
rows: [[match.ref('intent', { not: 'order' }), match.ref('order')]],
|
|
103
|
+
});
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`toBeEmpty` is async on EVERY subject, the text ones included: it is one matcher with one signature, so a sync/async split by subject could not be expressed by a lint rule that keys on the matcher's name. Rule D2 therefore refuses the bare `expect(x).toBeEmpty();` outright — dropped, it passes the test and surfaces the real failure as an unhandled rejection.
|
|
107
|
+
|
|
108
|
+
A failing `toMatchRows` prints the expected grid against the actual rows for the selected columns, cell by cell — matcher cells render as their placeholder text (`Matcher.toString()`): `match.uuid()` shows as `{{uuid}}`, `match.ref('order')` as `{{ref#order}}`, `match.ref('intent', { not: 'order' })` as `{{ref#intent!order}}`, `match.regex(/…/)` as `{{regex:…}}`. A failing `toBeEmpty` reports how many rows it found (the count, not the rows themselves). `.not` inverts both (`.not.toBeEmpty()` = at least one row).
|
|
109
|
+
|
|
110
|
+
## `result.stdout` / `result.stderr` — stream subjects (cli, container `exec`)
|
|
111
|
+
|
|
112
|
+
Streams are compared **after ANSI stripping** (rule D6); the raw capture stays available as `.text`.
|
|
113
|
+
|
|
114
|
+
| Matcher | Sync/async | Resolves against | Example |
|
|
115
|
+
| --------------------- | ---------- | ----------------- | ----------------------------------------------------------------- |
|
|
116
|
+
| `toMatch('x.txt')` | sync | `_expected/x.txt` | `expect(result.stdout).toMatch('help.txt')` |
|
|
117
|
+
| `toContain('needle')` | sync | — | `expect(result.stderr).toContain("Unknown command 'frobnicate'")` |
|
|
118
|
+
|
|
119
|
+
`toMatch` on a stream is a full-text snapshot; the fixture may contain any [token](15-tokens.md) (`{{semver}}`, `{{duration}}`, `{{workdir}}`, …). Token matching decides **pass or fail** only: `textEquals` resolves the placeholders to determine whether the output matches. The rendered failure, though, is a **literal** line-by-line diff (`Output mismatch (name)`, `- Expected` / `+ Received`) of the fixture text against the stripped output — tokens are not resolved in the diff, so a fixture line `Done in {{duration}}` is printed verbatim on the expected side whenever any line diverges, even if the duration itself matched.
|
|
120
|
+
|
|
121
|
+
> `toMatch` on **any accessor subject** (`result.stdout`, `result.json`, `result.response`, `result.directory`, `result.filesystem`) takes a **fixture name with its extension** (`'help.txt'`), never a regex. Passing a `RegExp` — the instinct carried over from vitest-native `toMatch` — throws immediately, naming the subject and pointing at the escape hatch: for a raw-regex assertion, reach through to the text with `expect(result.stdout.text).toMatch(/re/)`.
|
|
122
|
+
|
|
123
|
+
`toContain` failures print the needle and the (stripped) haystack in a diff-style layout so the near-miss is visible. `.not.toContain(…)` asserts absence.
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
// .grep() returns a TextAccessor — chainable and snapshot-able, so the same
|
|
127
|
+
// Token grammar and _expected/ resolution apply as on the stream itself.
|
|
128
|
+
expect(result.stdout.grep('products/ok.yaml')).not.toContain('error'); // absence probe
|
|
129
|
+
expect(result.stdout.grep('products/broken.yaml')).toMatch('broken-block.txt'); // snapshot a block
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## `text(value)` — any string as a stream subject
|
|
133
|
+
|
|
134
|
+
`text(value)` wraps an arbitrary string in the same `TextAccessor` streams surface, anchored on the calling test's directory via the same caller-detection the runners use. It promotes an ad-hoc string — most often a thrown **error message**, a checker line, or a report — into a first-class snapshot subject: ANSI is stripped before comparison (raw stays on `.text`), the [`{{token}}`](15-tokens.md) grammar applies to the fixture, and `.grep()` composes exactly as on a captured stream.
|
|
135
|
+
|
|
136
|
+
The product surface of a test framework **is** its error messages and reports; golden them in full instead of reconstructing them with a cluster of `toContain` probes.
|
|
137
|
+
|
|
138
|
+
| Matcher | Sync/async | Resolves against | Example |
|
|
139
|
+
| --------------------- | ---------- | ----------------- | -------------------------------------------------- |
|
|
140
|
+
| `toMatch('x.txt')` | sync | `_expected/x.txt` | `expect(text(message)).toMatch('parse-error.txt')` |
|
|
141
|
+
| `toContain('needle')` | sync | — | `expect(text(message)).toContain('did you mean')` |
|
|
142
|
+
|
|
143
|
+
```typescript
|
|
144
|
+
import { text } from '@jterrazz/test';
|
|
145
|
+
|
|
146
|
+
// Capture a rejection message cleanly, then golden the whole thing.
|
|
147
|
+
async function catchMessage(assertion: () => unknown): Promise<string> {
|
|
148
|
+
try {
|
|
149
|
+
await assertion();
|
|
150
|
+
} catch (error: any) {
|
|
151
|
+
return error.message;
|
|
152
|
+
}
|
|
153
|
+
throw new Error('expected the assertion to throw, but it passed');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const message = await catchMessage(() => expect(result.response).toMatch('wrong-body.http'));
|
|
157
|
+
// Volatile fragments (paths, durations, ids) go through tokens: {{path}}, {{duration}}, {{uuid}}…
|
|
158
|
+
expect(text(message)).toMatch('errors/wrong-body-error.txt');
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
## `result.json` — parsed stdout (cli)
|
|
162
|
+
|
|
163
|
+
| Matcher | Sync/async | Resolves against | Example |
|
|
164
|
+
| ------------------- | ---------- | ------------------ | -------------------------------------------- |
|
|
165
|
+
| `toMatch('x.json')` | sync | `_expected/x.json` | `expect(result.json).toMatch('config.json')` |
|
|
166
|
+
|
|
167
|
+
Deep-equal against the JSON fixture; the fixture may embed tokens (`"id": "{{uuid}}"`). Failure prints a structural object diff (missing keys, extra keys, per-key value mismatches). For partial checks, read `.value` and use native `toMatchObject`.
|
|
168
|
+
|
|
169
|
+
## `result.file(path)` — single files in the cwd (cli)
|
|
170
|
+
|
|
171
|
+
A pure read accessor — no framework matcher; assert on its properties with native `expect`:
|
|
172
|
+
|
|
173
|
+
| Property | Type | Example |
|
|
174
|
+
| ---------- | --------- | ------------------------------------------------------------------------------- |
|
|
175
|
+
| `.exists` | `boolean` | `expect(result.file('my-shop/.env').exists).toBe(false)` |
|
|
176
|
+
| `.content` | `string` | `expect(result.file('my-shop/shoply.yaml').content).toContain('name: my-shop')` |
|
|
177
|
+
|
|
178
|
+
## `result.directory(name)` — tree snapshots (cli)
|
|
179
|
+
|
|
180
|
+
| Matcher | Sync/async | Resolves against | Example |
|
|
181
|
+
| ---------------- | ---------- | ------------------------------ | -------------------------------------------------------------------- |
|
|
182
|
+
| `toMatch('dir')` | **async** | `_expected/dir/` (a directory) | `await expect(result.directory('my-shop')).toMatch('shop-scaffold')` |
|
|
183
|
+
|
|
184
|
+
Compares the tree rooted at `<cwd>/name` against the fixture directory `_expected/shop-scaffold/` — structure _and_ file contents (contents honour tokens). Failure is a structured diff in three groups: `added` (on disk, not in fixture), `removed` (in fixture, not on disk), `changed` (content mismatch, with a per-file diff). No extension on the argument — tree snapshots are directories (rule C6).
|
|
185
|
+
|
|
186
|
+
## `result.filesystem` — the whole cwd (cli)
|
|
187
|
+
|
|
188
|
+
| Matcher / accessor | Sync/async | Resolves against | Example |
|
|
189
|
+
| ------------------ | ---------- | ---------------- | ------------------------------------------------------------------ |
|
|
190
|
+
| `toMatch('dir')` | **async** | `_expected/dir/` | `await expect(result.filesystem).toMatch('upgraded-shop')` |
|
|
191
|
+
| `.files()` | async read | — | `expect(await result.filesystem.files()).toContain('shoply.lock')` |
|
|
192
|
+
|
|
193
|
+
Same semantics as `result.directory(…)`, rooted at the cwd itself. `.files()` returns the sorted recursive file list — a read, so follow it with native matchers.
|
|
194
|
+
|
|
195
|
+
## Container subjects (docker-aware cli)
|
|
196
|
+
|
|
197
|
+
Available when the runner declares `docker: { envVar, nameLabel, testRunLabel }`; results must be bound with `await using` (rule B5). `result.container(name)` looks a container up by its `nameLabel` value — lazily: no Docker call happens until you touch it. Absent containers return `exists: false` instead of throwing.
|
|
198
|
+
|
|
199
|
+
The runner handle itself also exposes a `docker(containerId)` reader (returned by `specification.api()` and `specification.cli()`, not `specification.jobs()`): given a raw container id, it lazily runs `docker inspect` and returns the **same** `ContainerAccessor` type — so it works with `await expect(spec.docker(id)).toBeRunning()` and every read accessor below. An unknown id yields an accessor with `exists: false` rather than throwing.
|
|
200
|
+
|
|
201
|
+
| Subject / matcher | Sync/async | Example |
|
|
202
|
+
| ----------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------- |
|
|
203
|
+
| `container.exists` (read) | sync | `expect(result.container('nope').exists).toBe(false)` |
|
|
204
|
+
| `expect(container).toBeRunning()` | **async** | `await expect(shop).toBeRunning()` |
|
|
205
|
+
| `container.file(path).content` (read) | sync API | `expect(shop.file('/app/shoply.yaml').content).toContain('name: alpha')` |
|
|
206
|
+
| `container.exec(cmd)` (read) | async read | `const inside = await shop.exec('ls /app')` then `expect(inside.stdout).toContain('shoply.yaml')` |
|
|
207
|
+
| `container.stdout` / `.stderr` (logs, stream subject) | sync | `expect(shop.stdout).toContain('shop ready')` |
|
|
208
|
+
|
|
209
|
+
`container.exec()` resolves to a result whose `stdout`/`stderr` are ordinary stream subjects — the same matchers as host streams. `.not.toBeRunning()` asserts a stopped (but existing) container; for a _removed_ container assert `exists` instead.
|
|
210
|
+
|
|
211
|
+
**The sync container-read exception (as implemented):** container property reads — `exists`, `running`, `status`, `file(path).exists`, `file(path).content`, log streams — are synchronous, backed by one-shot `docker inspect` / `docker exec` shell-outs captured lazily on first access. This is the documented exception to the "await only IO matchers" rule (D2): only the _matchers_ (`toBeRunning`) are async; property reads stay sync so container assertions read exactly like host-side ones.
|
|
212
|
+
|
|
213
|
+
## `result.tree` — the accessibility outline (website, component)
|
|
214
|
+
|
|
215
|
+
The one golden a rendered surface wants. It is the outline a screen reader walks, it is the same dialect on a page and on a mounted unit, and it is deterministic where a screenshot is not — a font hint or a scrollbar moves a picture and moves nothing here.
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// A website page: the capture is already on the node side.
|
|
219
|
+
expect(result.tree).toMatch('home.aria.yaml');
|
|
220
|
+
|
|
221
|
+
// A component: the capture is read INSIDE the page, so the read is IO.
|
|
222
|
+
await expect(result.tree).toMatch('two-of-two-hundred.aria.yaml');
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
The `await` is the component's, not the tree's: a subject captured inside a page crosses the browser seam through a server command, and every accessor of a `component` result is awaited for that reason. It carries what the tree carries and nothing else — enablement (`[disabled]`), selection (`[selected]`), the roles and the accessible names. It does NOT carry focus, so a keyboard assertion is a verb, never a golden ([13 — Elements](13-elements.md#modifiers)).
|
|
226
|
+
|
|
227
|
+
## `result.html` — the markup (component)
|
|
228
|
+
|
|
229
|
+
The escape hatch for what the tree cannot say: a class name, a `data-` attribute, an element the accessibility tree flattens away.
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
expect(result.html).toContain('row row--live');
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Reach for it after the tree, not instead of it. A test that asserts only on markup is testing an implementation the user never meets, which is the failure the vocabulary exists to prevent.
|
|
236
|
+
|
|
237
|
+
## `result.value` / `result.error` — what a call returned (integration)
|
|
238
|
+
|
|
239
|
+
`.call()` resolves one way or the other, and both sides are subjects.
|
|
240
|
+
|
|
241
|
+
`result.value` is a **JSON accessor** when the call returned an object and a **text accessor** when it returned a string, so the same golden vocabulary reaches both:
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
expect(result.value).toMatch('found.json');
|
|
245
|
+
expect(result.value.text).toContain('Alice');
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
`result.error` is what the call THREW, read as a subject of its own — a refusal is an answer, and it deserves a golden like any other:
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
expect(result.error).toMatch('refused.txt');
|
|
252
|
+
await expect(result.error).toBeEmpty(); // nothing was thrown
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
A call that threw leaves `result.value` empty and vice versa; asserting on both in one test is asserting on a branch that cannot happen.
|
|
256
|
+
|
|
257
|
+
## Two helpers that are not matchers
|
|
258
|
+
|
|
259
|
+
`expect()` is the only way an assertion is made (rule D1), but two gestures around an assertion had been hand-written in every repository that needed them.
|
|
260
|
+
|
|
261
|
+
**`required(value, why)`** — the value, or a failure that says what was missing and why it mattered. A test reaching into a captured structure meets `T | undefined` and has to answer for it; `!` erases the question and a three-line `if (…) throw` is the same three lines everywhere.
|
|
262
|
+
|
|
263
|
+
```typescript
|
|
264
|
+
import { required } from '@jterrazz/test';
|
|
265
|
+
|
|
266
|
+
const body: { id?: number } = { id: 7 };
|
|
267
|
+
const id: number = required(body.id, 'the creation reply carries the new id');
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
`null` and `undefined` are the only values it refuses: `0`, `''` and `false` are present, and go through.
|
|
271
|
+
|
|
272
|
+
**`waitUntil(predicate, { timeout?, interval?, why? })`** — wait on a CONDITION, never on a duration. A spec that sleeps for a guessed number of milliseconds is slow on a fast machine and flaky on a slow one, which is why rule J2 refuses `setTimeout` under `specs/`. Every facet that drives something already waits on a condition; this is the primitive for the cases no facet owns — a background write to land, a queue to drain, a file to appear.
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
import { waitUntil } from '@jterrazz/test';
|
|
276
|
+
|
|
277
|
+
const outbox: string[] = [];
|
|
278
|
+
await waitUntil(() => outbox.length === 1, { why: 'the job wrote its one message' });
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The budget defaults to 5 000 ms and the poll to 50 ms; the failure names the condition and the budget, so a timeout reads as a sentence rather than as "expected true, got false". The budget is measured on `performance.now()`, never on `Date`, so `clock.at()` may freeze the calendar around a wait and the timeout still arrives with its own sentence. Not for a subject under `clock.run()` — there the scheduler belongs to the test and `clock.advance(ms)` is what makes time pass ([18 — Conventions § Time](18-conventions.md#time--one-primitive-two-depths)).
|
|
282
|
+
|
|
283
|
+
## Matcher summary
|
|
284
|
+
|
|
285
|
+
| Matcher | Valid subjects | Sync/async | Fixture root |
|
|
286
|
+
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | ------------ |
|
|
287
|
+
| `toMatch` | `response`, `stdout`, `stderr`, `json`, `directory(…)`, `filesystem` | sync, except directory/filesystem (async) | `_expected/` |
|
|
288
|
+
| `toMatchRows` | `table(…)` | async | — (inline) |
|
|
289
|
+
| `toBeEmpty` | `table(…)`, `error`, `console` and every text accessor | async (every subject) | — |
|
|
290
|
+
| `toContain` | `stdout`, `stderr` (host and container-exec streams), container logs | sync | — |
|
|
291
|
+
| `toBeRunning` | `container(…)` | async | — |
|
|
292
|
+
| native vitest | every scalar/read accessor (`status`, `exitCode`, `filesystem.cwd`, `.text`, `.value`, `.exists`, `.content`, `grep(…)`, `files()`, `containerIds`) | per vitest | — |
|
|
293
|
+
|
|
294
|
+
`.not` is supported on every framework matcher.
|
|
295
|
+
|
|
296
|
+
## Update mode
|
|
297
|
+
|
|
298
|
+
`TEST_UPDATE=1` (or `vitest -u`) rewrites a mismatching `toMatch` fixture from the actual output instead of failing, preserving the tokens already in the file, and `{ frozen: true }` opts one fixture out — a fixture whose mismatch IS the behaviour under test. The engine, the flag, the discipline it asks for and the frozen two-pass are all [15 — Tokens](15-tokens.md#update-mode-tokens-are-preserved)'s; this chapter only notes which matcher the flag reaches, and that is `toMatch`, on every fixture subject.
|
|
299
|
+
|
|
300
|
+
## Common mistakes
|
|
301
|
+
|
|
302
|
+
- **Calling assertion methods on accessors** — `result.stdout.toContain('x')` does not compile; accessors are read-only (rule D1). Write `expect(result.stdout).toContain('x')`.
|
|
303
|
+
- **Missing `await` on IO matchers.** `expect(result.table('users')).toMatchRows(…)` without `await` never queries the database and the test passes vacuously (rule D2).
|
|
304
|
+
- **Putting `await` in front of a sync matcher.** A `toMatch` on a stream, a JSON body, a response or a call's value runs either way, and then the toolchain refuses the file: the matcher is typed as returning a value, so awaiting it is `typescript(await-thenable)` in every consumer. The sync/async split is the contract D2 states and the types carry.
|
|
305
|
+
- **`toMatch('help')` without extension.** The extension is part of the name (rule C6). The only extensionless arguments are tree-snapshot directory names.
|
|
306
|
+
- **Expecting a per-subject fixture root.** Every `toMatch` subject — response, stream, JSON, or tree — resolves against `_expected/`; only `.request()` reads from `_requests/` (rule D3).
|
|
307
|
+
- **Asserting raw ANSI.** Streams are stripped before comparison; if you truly need the raw bytes, that is what `.text` is for (rule D6).
|
|
308
|
+
- **`database:` on a single-database project** (or missing on a multi-database one) — rule A7 cuts both ways.
|
|
309
|
+
- **Using `.not.toMatch` as a lazy negative.** It passes for _any_ divergence, including ones you did not intend. Prefer a positive fixture or a targeted `toContain`.
|
|
310
|
+
- **A deliberately-wrong fixture without `{ frozen: true }`.** `TEST_UPDATE=1` overwrites it with the real output, destroying the negative case. Freeze any `toMatch` whose mismatch (or missing fixture) is the behaviour under test (rule `d13w-unfrozen-negative-fixture`).
|
|
311
|
+
|
|
312
|
+
## Related
|
|
313
|
+
|
|
314
|
+
[10 — API specs](10-api.md) · [12 — CLI specs](12-cli.md) · [15 — Tokens](15-tokens.md) · [18 — Conventions](18-conventions.md)
|