@jterrazz/test 16.1.1 → 18.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 +1 -2980
- package/dist/browser/services.js +1 -0
- package/dist/browser/stubs.js +1 -0
- package/dist/browser-shared-index.browser.js +3233 -0
- package/dist/checker.d.ts +6 -1
- package/dist/checker.js +3721 -2624
- package/dist/config.d.ts +40 -0
- package/dist/config.js +309 -0
- package/dist/in-place.global-setup.d.ts +12 -0
- package/dist/in-place.global-setup.js +21 -0
- package/dist/index.d.ts +1641 -3004
- package/dist/index.js +5095 -3835
- package/dist/mobile-session.global-setup.d.ts +15 -0
- package/dist/mobile-session.global-setup.js +31 -0
- package/dist/oxlint.cjs +6069 -5634
- package/dist/oxlint.d.cts +43 -7212
- package/dist/oxlint.d.ts +43 -7212
- package/dist/oxlint.js +3979 -5799
- package/dist/route-coverage.global-setup.d.ts +43 -0
- package/dist/route-coverage.global-setup.js +1 -0
- package/dist/services.d.ts +3 -0
- package/dist/services.global-setup.d.ts +43 -0
- package/dist/services.global-setup.js +1 -0
- package/dist/services.js +597 -0
- package/dist/shared-appium.adapter.js +428 -0
- package/dist/shared-artifacts.js +34 -0
- package/dist/shared-chapter.js +7 -0
- package/dist/shared-container.port.d.ts +31 -0
- package/dist/shared-derived-projects.js +73 -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.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-listened-port.js +19 -0
- package/dist/shared-literate-plugin.js +184 -0
- package/dist/shared-manifest.js +1321 -0
- package/dist/shared-pause.js +22 -0
- package/dist/shared-peer.js +88 -0
- package/dist/shared-playwright.adapter.js +328 -0
- package/dist/shared-process-alive.js +13 -0
- package/dist/shared-process.adapter.js +74 -0
- package/dist/shared-record-key.js +13 -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 +407 -0
- package/dist/shared-service-images.js +10 -0
- package/dist/shared-services.global-setup.js +174 -0
- package/dist/shared-session-ledger.js +305 -0
- package/dist/shared-setup-entry.js +12 -0
- package/dist/shared-source-scan.js +133 -0
- package/dist/shared-spec-document.js +303 -0
- package/dist/shared-spec-grammar.js +66 -0
- package/dist/shared-specifications.js +26 -0
- package/dist/shared-specs-modules.js +1033 -0
- package/dist/shared-stub-reply.js +167 -0
- package/dist/shared-stub-server.js +142 -0
- package/dist/shared-temp-root.global-setup.js +73 -0
- package/dist/shared-test-baseline.js +26 -0
- package/dist/shared-test-section.js +58 -0
- package/dist/shared-testcontainers.adapter.js +306 -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 +1 -0
- package/dist/vitest.d.ts +93 -164
- package/dist/vitest.js +382 -550
- 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 +566 -0
- package/docs/guides/component-specs.md +252 -0
- package/docs/guides/develop.md +97 -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 +44 -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 +500 -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 +228 -0
- package/package.json +56 -36
- 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 -438
- package/dist/server.js +0 -160
- package/dist/stream.js +0 -321
- package/dist/substring-warning.js +0 -282
- 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 -248
- 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 -332
- package/docs/19-linting.md +0 -512
- 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,252 @@
|
|
|
1
|
+
# Component specs (`component`)
|
|
2
|
+
|
|
3
|
+
`component` tests one thing that draws itself (a React component, a React hook through a small host component written in the test, or a plain DOM function), rendered in a real headless Chromium through Vitest Browser Mode. It lives beside its code as `<file>.test.tsx`, starts nothing, and so has no constructor: the test calls the exported `component` chain directly.
|
|
4
|
+
|
|
5
|
+
For a whole served page, use [Website specs](website-specs.md); for a module that draws nothing, a [module test](module-tests.md). Why a real browser rather than a simulated DOM: [ADR-003](https://github.com/jterrazz/package-test/blob/main/docs/decisions/003-a-rendered-component-is-a-specification-on-vitest-browser-mode.md).
|
|
6
|
+
|
|
7
|
+
## What it specifies
|
|
8
|
+
|
|
9
|
+
A component test answers one question: **given this network and this parent, what does the component put on the screen, and what does it report back?** Everything it reads is what a user or a parent can see (the accessibility tree, the rendered text, the markup, the console), and everything it does is what a user or a parent can do: click, fill, press, change the props, take it off the screen.
|
|
10
|
+
|
|
11
|
+
It is a UNIT, so it lives with its subject, in the `__tests__/` of the subject's folder (`test-in-its-tests-folder`):
|
|
12
|
+
|
|
13
|
+
```text
|
|
14
|
+
src/presentation/posts/
|
|
15
|
+
├── post-table.tsx
|
|
16
|
+
├── use-modal-dialog.ts
|
|
17
|
+
└── __tests__/
|
|
18
|
+
├── post-table.test.tsx # the component's spec; the `.tsx` suffix IS the kind
|
|
19
|
+
└── use-modal-dialog.test.tsx # a hook's spec: its Host is written in the test file
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The suffix decides which project collects the file and therefore which rules judge it: `unit()` takes `**/*.test.ts` and excludes `**/*.test.tsx`; `component()` takes the `.tsx`. A rendered thing in a `.test.ts` is rule `no-dom-in-module-test`'s error, and it names the move.
|
|
23
|
+
|
|
24
|
+
## What it does not do
|
|
25
|
+
|
|
26
|
+
The subject is a unit that draws itself IN A BROWSER, which leaves four neighbours outside the specification, each with a route, so none of them is a hole.
|
|
27
|
+
|
|
28
|
+
| Not this | Because | Route |
|
|
29
|
+
| -------------------------------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
30
|
+
| A React Native screen | The renderer is a real Chromium, and a native view never reaches one | jest stays, until an experiment rendering it through `react-native-web` shows it can move |
|
|
31
|
+
| An `.astro` page | A page is the assembled product, met through an address | a website spec: [Website specs](website-specs.md) |
|
|
32
|
+
| A React island inside an `.astro` page | The island is a rendered unit; only the file around it is not | a `.test.tsx` in the `__tests__/` of the island's folder, here |
|
|
33
|
+
| A Next.js server component | It renders on the server and reads what the server reads; there is no tree to mount | a website spec, through the served page |
|
|
34
|
+
|
|
35
|
+
**What runs under node stays there.** In a page, each node-only name the entry publishes (the seven constructors, the services, the docker accessors, the results that walk a disk) refuses where it is called: a page has no filesystem, no child process and no socket.
|
|
36
|
+
|
|
37
|
+
## The constructor: there is none
|
|
38
|
+
|
|
39
|
+
Every other specification starts something (a server, a database, a binary, a simulator), so every other specification has a `*.specification.ts` holding what was started. A component has nothing to start. What a specification file WOULD have held splits by owner:
|
|
40
|
+
|
|
41
|
+
| What | Whose | Where |
|
|
42
|
+
| -------------------------------------------------- | ---------- | --------------------------------------------------------------- |
|
|
43
|
+
| The providers every render is dressed in | the app's | the `component` project's `wrap`, in `jterrazz.config.ts` |
|
|
44
|
+
| The Vite pipeline the app builds with | the app's | its `vite` |
|
|
45
|
+
| The viewport, the timezone, the locale | the app's | its `viewport`, `timezone`, `locale` |
|
|
46
|
+
| The stubs, the wrapper, the instant, the page size | one test's | the chain: `.intercept()`, `.wrap()`, `.clock()`, `.viewport()` |
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
// apps/web/jterrazz.config.ts: its test section
|
|
50
|
+
const projects = {
|
|
51
|
+
component: {
|
|
52
|
+
reason: 'the app renders through its Vite pipeline, inside its providers',
|
|
53
|
+
vite: './web/vite.config.ts',
|
|
54
|
+
wrap: './web/src/providers.tsx',
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**A relative `vite` or `wrap` is the project's path.** Both are resolved against the component project's `root`, which is the directory holding `jterrazz.config.ts` unless the project states one, so the two paths above mean `apps/web/web/<path>`. A `root` the project states is read from that same directory.
|
|
60
|
+
|
|
61
|
+
`wrap` is a MODULE PATH whose default export is `(ui) => ReactNode`: a page imports a URL, not a function, so the providers cross as a setup file the project generates under `.artifacts/`. Nothing imports the module itself, so `test()` names it to knip as an entry: the project declares none. A router does not belong here: which routes exist is a test's Given, and it goes on the chain.
|
|
62
|
+
|
|
63
|
+
| Option | Description |
|
|
64
|
+
| --------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| `wrap` | Module path, default-exporting `(ui) => ReactNode`: the app's own providers, around every render of the project |
|
|
66
|
+
| `vite` | The app's pipeline: a config path, the object, or the function a framework exports. Only the PIPELINE is adopted (below) |
|
|
67
|
+
| `clock` | An ISO instant pinned for every render, for a project whose components stamp the time |
|
|
68
|
+
| `viewport` | `{ width, height }` of the page. Default `1280 × 720` |
|
|
69
|
+
| `timezone` / `locale` | The browser context's. Defaults `'UTC'` and `'en-US'` |
|
|
70
|
+
| `root` | The directory `vite` and `wrap` are resolved against. Default: the directory holding `jterrazz.config.ts` |
|
|
71
|
+
| `include` / `exclude` | The globs. Derived: `specs/component/**/*.test.tsx`, and each root holding a `*.test.tsx`; state your own to replace them |
|
|
72
|
+
| `timeout` | Raise (or lower) the preset's 30 s for this project alone |
|
|
73
|
+
| `serial` | `fileParallelism: false`: the project's renders run one file at a time |
|
|
74
|
+
|
|
75
|
+
**Only the pipeline of `vite` is adopted.** An app's config says where the app LIVES (its `root`, its `build`, its `server.port`, its `publicDir`), and adopting those re-roots the run so the project collects nothing. The kept keys are an allow-list (`plugins`, `resolve`, `esbuild`/`oxc`, `css`, `define`, `assetsInclude`, `envPrefix`, `json`, `optimizeDeps`), and the consumer's `plugins` are concatenated after `component()`'s own, never assigned over them. So is its `optimizeDeps.include`, joined to the project's own: every dependency a component imports belongs there, since one Vite first finds when a test imports it is optimised mid-run, and the reload fails the tests already loading.
|
|
76
|
+
|
|
77
|
+
**One transformer key, chosen from the installed Vite.** Vite 8 compiles with oxc and warns for every `esbuild` option handed to it beside an `oxc` one; Vite 6 and 7 compile with esbuild and have no `oxc` key at all. `component()` reads the installed major, states the JSX default on the key that Vite actually reads, and drops the other from the adopted pipeline, and the one it states yields to yours: a `jsxImportSource` or a classic runtime is the app's statement, not the project's to overwrite.
|
|
78
|
+
|
|
79
|
+
**A framework's config function is called as a dev server under test.** `component()` calls it with `{ command: 'serve', mode: 'test', isSsrBuild: false }` and adopts what it returns through the same allow-list, so an Astro app hands over its whole pipeline: its integrations' plugins, React's and Tailwind's among them, its `import.meta.env` defines, its `tsconfig` paths:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
// jterrazz.config.ts of an Astro app: its component project
|
|
83
|
+
import { getViteConfig } from 'astro/config';
|
|
84
|
+
|
|
85
|
+
const component = {
|
|
86
|
+
reason: 'the app builds with Astro, whose config function hands over its pipeline',
|
|
87
|
+
vite: getViteConfig({}, { root: import.meta.dirname }),
|
|
88
|
+
};
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Both arguments are Astro's. The first is required: Astro reads the app's `root` off it. The second's `root` is the one the project's `root` cannot give: Astro looks for `astro.config.*` in the CWD, not beside the config that calls it, so a config loaded from elsewhere (knip or `typescript check` at a repository root, an app nested in a workspace) finds none, warns `Missing pages directory`, and hands back a pipeline without the app's integrations, its defines or its plugins. Nothing fails then; a component that needed them renders wrong.
|
|
92
|
+
|
|
93
|
+
**A `resolve.alias` is an unlisted dependency.** An alias tells the bundler where a specifier resolves; it tells knip nothing, so a package reached only through an alias reads as undeclared, and a `paths`-less `tsconfig` makes the compiler disagree with the bundler besides. Mirror the alias in the `tsconfig`'s `paths` (the compiler and the bundler then answer alike), or name the package in `knip.ignoreDependencies` with the alias as the reason.
|
|
94
|
+
|
|
95
|
+
## The chain
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
import { button, component, content, file } from '@jterrazz/test';
|
|
99
|
+
import { defineStub, http } from '@jterrazz/test/stubs';
|
|
100
|
+
import { describe, expect, test } from 'vitest';
|
|
101
|
+
|
|
102
|
+
import { PostTable } from './post-table.js';
|
|
103
|
+
|
|
104
|
+
const listing = defineStub({
|
|
105
|
+
request: http.get('/api/posts'),
|
|
106
|
+
response: http.json({ posts: [{ id: 1, title: 'A first post' }], total: 200 }),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('PostTable', () => {
|
|
110
|
+
test('says how much of the collection the table is showing', async () => {
|
|
111
|
+
// Given - a page of one row answering for a collection of two hundred
|
|
112
|
+
const result = await component.intercept(listing).render(<PostTable />, async (visitor) => {
|
|
113
|
+
await visitor.see(content('Showing 1 of 200 posts'));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Then - the table is qualified by what it is not showing
|
|
117
|
+
await expect(result.tree).toMatch(file('./__expected__/one-of-two-hundred.aria.yaml'));
|
|
118
|
+
expect(result.console.text).toBe('');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Every setup returns a NEW chain, so the handle a spec imports never carries the previous test's state.
|
|
124
|
+
|
|
125
|
+
| Setup | Does |
|
|
126
|
+
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
127
|
+
| `.intercept(...)` | Declares what the network replies: the same stubs and the same queue as every other specification |
|
|
128
|
+
| `.wrap(fn)` | Wraps every render of this chain: a router stub, a `<StrictMode>`, a provider one test needs |
|
|
129
|
+
| `.clock(iso)` | Pins the page's `Date` for this render |
|
|
130
|
+
| `.viewport(size)` | The page size THIS render gets: the Given of a component that reads `matchMedia` or a container query. The project's size is restored when the test ends |
|
|
131
|
+
|
|
132
|
+
| Terminal action | Mounts |
|
|
133
|
+
| ----------------------------- | ----------------------------------------------------------------------------- |
|
|
134
|
+
| `.render(<X />, scenario?)` | A React tree |
|
|
135
|
+
| `.render((container) => ...)` | A DOM function, handed a fresh container; its optional return is the teardown |
|
|
136
|
+
|
|
137
|
+
The scenario is the When, and assertions stay in the Then (rule `no-expect-in-scenario`). Its verbs are the page's (`click` `fill` `check` `select` `hover` `press` `see` `gone`, each owned and described by [The visitor § Verbs](../reference/visitor.md#verbs)), plus the two only a parent has:
|
|
138
|
+
|
|
139
|
+
| Verb | Description |
|
|
140
|
+
| -------------- | --------------------------------------------------------- |
|
|
141
|
+
| `rerender(ui)` | What a PARENT does: new props, same mount |
|
|
142
|
+
| `unmount()` | What a parent does when it takes the thing off the screen |
|
|
143
|
+
|
|
144
|
+
`goto()` does not exist here, because a component has no address. The visitor's vocabulary is the page's too, modifiers included: [The visitor § Modifiers](../reference/visitor.md#modifiers) owns them all, with the `target-matches-one-element` refusal and the exact-name rule.
|
|
145
|
+
|
|
146
|
+
## The result
|
|
147
|
+
|
|
148
|
+
| Member | Type | Description |
|
|
149
|
+
| ----------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
150
|
+
| `result.tree` | `RenderedText` | The ARIA snapshot of the rendered body, the outline a golden pins |
|
|
151
|
+
| `result.content` | `RenderedText` | The RENDERED text of the document body (`innerText`): what a reader sees, so a stylesheet's source and a node the page does not display are not in it |
|
|
152
|
+
| `result.visibleContent` | `RenderedText` | The same text without the text of an element drawn at `opacity: 0`. `innerText` keeps it, a reader of an animation does not see it, so a golden of a reel frame pins what the frame shows |
|
|
153
|
+
| `result.html` | `RenderedText` | The mounted markup, the escape hatch for a class or an attribute |
|
|
154
|
+
| `result.console` | `RenderedText` | Every console message, one `[type] text` line per message, and every uncaught exception as ONE `[error] Uncaught ...` line |
|
|
155
|
+
| `result.errors` | `RenderedText` | Console errors only, plus any uncaught page error, once (`Uncaught Error: ...`) |
|
|
156
|
+
|
|
157
|
+
`toMatch(file(...))` is **awaited** on all five, because each is a `RenderedText`, a `TextAccessor` whose golden lives on the node side: a golden captured inside a page is read back through a server command, so the file crosses from the page to the node side. Which matchers are IO (and therefore awaited) is [Assertions](../reference/assertions.md)'s to state; a directory subject is the other one, and it is awaited for the same kind of reason (it walks a disk).
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
await expect(result.tree).toMatch(file('./__expected__/two-of-two-hundred.aria.yaml'));
|
|
161
|
+
expect(result.html.text).toContain('row row--live');
|
|
162
|
+
expect(result.errors.text).toBe('');
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The ARIA tree is the golden a rendered surface wants: it is the outline a screen reader walks, it is deterministic where a screenshot is not, and it is the SAME dialect [Website specs](website-specs.md) produces for a whole page. Goldens live in `__expected__/` beside the test and are written by `TEST_UPDATE=1`, exactly as everywhere else ([Tokens](../reference/tokens.md)).
|
|
166
|
+
|
|
167
|
+
## Unique here
|
|
168
|
+
|
|
169
|
+
- **The hook recipe.** A hook has no surface, so its Given is a **Host component written in the test file**, carrying the states the test needs: an opener, a fallback target, a handler that can be swapped. The visitor drives the Host; `rerender` swaps the handler; `unmount` is the parent walking away. There is no hook-specific runner and there is no need for one.
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
import { button, component, dialog, focused } from '@jterrazz/test';
|
|
173
|
+
import { useState } from 'react';
|
|
174
|
+
import { describe, expect, test, vi } from 'vitest';
|
|
175
|
+
|
|
176
|
+
// The Given of a hook spec is a Host, written HERE: the component carrying the
|
|
177
|
+
// States the test needs is part of the test, not of the app.
|
|
178
|
+
function Host({ onClose }: { onClose: () => void }) {
|
|
179
|
+
const [open, setOpen] = useState(false);
|
|
180
|
+
|
|
181
|
+
const show = (): void => {
|
|
182
|
+
setOpen(true);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const dismiss = (): void => {
|
|
186
|
+
setOpen(false);
|
|
187
|
+
onClose();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (!open) {
|
|
191
|
+
return (
|
|
192
|
+
<button onClick={show} type="button">
|
|
193
|
+
Open
|
|
194
|
+
</button>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<dialog aria-label="Host" open>
|
|
200
|
+
<button onClick={dismiss} type="button">
|
|
201
|
+
Close
|
|
202
|
+
</button>
|
|
203
|
+
</dialog>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
describe('focus', () => {
|
|
208
|
+
test('gives the keyboard back to whatever opened it', async () => {
|
|
209
|
+
// Given - a dialog opened from a button and then dismissed
|
|
210
|
+
const onClose = vi.fn<() => void>();
|
|
211
|
+
|
|
212
|
+
await component.render(<Host onClose={onClose} />, async (visitor) => {
|
|
213
|
+
await visitor.click(button('Open'));
|
|
214
|
+
await visitor.see(dialog('Host'));
|
|
215
|
+
await visitor.click(button('Close'));
|
|
216
|
+
await visitor.gone(dialog('Host'));
|
|
217
|
+
await visitor.see(focused(button('Open')));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
// Then - the owner heard the dismissal exactly once
|
|
221
|
+
expect(onClose).toHaveBeenCalledOnce();
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- **A DOM function is still a component.** `renderSessionRows(container, rows)` renders; it just does not use React. `.render((container) => ...)` mounts it in the same Chromium under the same visitor, and `result.html` reads what it drew. No React is loaded. Write the body BRACED: a concise arrow hands back the void the render function returns, and `typescript/no-confusing-void-expression` refuses that.
|
|
227
|
+
|
|
228
|
+
```tsx
|
|
229
|
+
const result = await component.render((container) => {
|
|
230
|
+
renderSessionRows(container, rows);
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
- **A router is a test's Given.** A screen that renders `<Link>` or reads `useParams()` throws without a router. `createRoutesStub([...])` on `.wrap()` is that Given: React Router's own guidance, and the fork's: a route module is a website spec, a presentation component is a component spec, a loader is a module test.
|
|
235
|
+
- **Strictness is total.** A component that fetches something no stub declared fails the render, naming the request, including a component given NO stub at all, which is how "this subject has no network" is said. The request is the failure the render reports even when the scenario then failed for want of what it asked for: the cause, not the symptom. There is no bypass and no default handler. The api specification's `network-strict-after-first-stub` stops at a known scope because it shares a process with everything else the test does; a page does not, so here it is total. It follows that a module-scope `intercept()` opened around a `.render()` is shadowed: the render's own registration is installed last and answers everything. Declare a render's network on the chain; `intercept()` in a `.test.tsx` is for code the test calls itself ([Stubs](../reference/stubs.md#intercept-the-same-double-with-no-chain)).
|
|
236
|
+
- **The clock is the page's.** `.clock(iso)` pins `Date` and nothing else: faking the page's timers would stop React's scheduler and nothing would ever render.
|
|
237
|
+
|
|
238
|
+
## Pitfalls
|
|
239
|
+
|
|
240
|
+
- **Reaching for `@testing-library/react` or `happy-dom`.** Rule `no-second-test-runtime` refuses both, and the second is the reason the specification exists: a simulated DOM behaves almost like a browser, and what ships is judged by a real one.
|
|
241
|
+
- **Writing a `*.specification.ts` for it.** There is nothing to start. The project holds what every render shares; the chain holds what one test needs.
|
|
242
|
+
- **Hand-rolling `browser:` in a config.** A derived config has no place for it: the provider is pinned to the runner's EXACT version, the service worker is served from the framework's own install, the JSX transform follows the Vite in use, and a cold dependency cache must be pre-bundled or the first run reloads the tester under the test. `component()` owns all four.
|
|
243
|
+
- **Putting a rendered test in a `.test.ts`.** Rule `no-dom-in-module-test` refuses the DOM globals it would need. Rename it `.test.tsx`, in the `__tests__/` of its component's folder; the project that collects it opens a browser.
|
|
244
|
+
- **Asserting focus with a golden.** The tree carries no focus; [The visitor § Modifiers](../reference/visitor.md#modifiers) says what asserts it.
|
|
245
|
+
- **Expecting `toMatch(file(...))` to be synchronous here.** It is not: `await expect(result.tree).toMatch(file('./__expected__/<name>.aria.yaml'))`. A bare call still runs the assertion, but off the test's lifetime: a failure surfaces as an `Unhandled Errors` entry attributed to no test, the test itself is reported PASSED, and the run exits non-zero anyway. Under `TEST_UPDATE=1` the write happens outside the test too.
|
|
246
|
+
- **Reading the `target-matches-one-element` refusal as a suggestion.** When two elements carry the SAME whole name, the refusal names the accessible name each candidate matched on and offers a landmark; read that before scoping, because the landmark it offers is the one that narrows, not necessarily the one you meant. Exact-by-default and its `{ exact: false }` escape are [The visitor § A name designates the accessible name WHOLE](../reference/visitor.md#a-name-designates-the-accessible-name-whole)'s.
|
|
247
|
+
- **Pointing `wrap` at a router.** `wrap` is the app's frame, around every render of the project. Which routes exist changes per test, so it belongs on the chain.
|
|
248
|
+
- **Giving the project the app's whole Vite config and expecting the app's root.** Only the pipeline is adopted; the project's root stays the directory of `vitest.config.ts`, which is what makes its `include` globs mean what they say.
|
|
249
|
+
|
|
250
|
+
## Related
|
|
251
|
+
|
|
252
|
+
[Set up a project](../start/set-up-a-project.md) · [Website specs](website-specs.md) · [Stubs](../reference/stubs.md) · [The visitor](../reference/visitor.md) · [Assertions](../reference/assertions.md)
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# Develop
|
|
2
|
+
|
|
3
|
+
How a change to this repository is made: the toolchain, the order its gates demand, which file a change opens and what it owes when it lands. How a consuming project installs and configures the framework is [Set up a project](../start/set-up-a-project.md).
|
|
4
|
+
|
|
5
|
+
## The loop
|
|
6
|
+
|
|
7
|
+
The repository answers six `make` targets, the same six every repository of its author answers:
|
|
8
|
+
|
|
9
|
+
| Target | Does |
|
|
10
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
11
|
+
| `make install` | Install the dependencies (`npm ci`, again only when the lockfile moves) |
|
|
12
|
+
| `make build` | Build the bundle, before `lint` and before the `unit` project; an unchanged tree restores it from the build cache instead |
|
|
13
|
+
| `make lint` | Typecheck, lint, format check, knip, then the conventions checker and the self checker (the source tree's structure locks) |
|
|
14
|
+
| `make fix` | Build, then `npx typescript fix`, which runs the conventions' fixes and the self checks first, then oxlint's and the formatter, so the formatter formats what the checks wrote |
|
|
15
|
+
| `make test` | Every suite, through `npx typescript test`, which answers a tree it already proved green from that proof |
|
|
16
|
+
| `make check` | The whole verdict: build, lint, test, then the mutation verdict (`make mutation`, incremental); a stage whose inputs it already proved green answers from its proof |
|
|
17
|
+
|
|
18
|
+
A stage keeps its green as a proof keyed by the content of everything it reads, under `.artifacts/proven/`, and answers the same key from it: `make lint`, `make test` and `make mutation` run nothing on a tree they already proved green, on a laptop as on a runner ([the toolchain's Quality checks § A tree proven green](https://github.com/jterrazz/package-typescript/blob/main/docs/reference/quality-checks.md#a-tree-proven-green)). `check` asks no stage cold; a run that must ignore its proofs says so itself, `make mutation MUTATION_ARGS=--cold`.
|
|
19
|
+
|
|
20
|
+
Every target installs first. CI calls `build`, `lint`, `test`, then `make mutation` ([Test § What CI runs](test.md#what-ci-runs)).
|
|
21
|
+
|
|
22
|
+
`make mutation` is `typescript mutation`: [Stryker](https://stryker-mutator.io), from the config the toolchain derives with `typescript.mutation`, twice. The first run is the `unit` project over `src/` and the shipped `lint/` code that carries a module test; the second is every node project, against a build of the instrumented sources, over every mutant the module tests let live and the changed lines (the change not yet committed or, on a clean tree, the branch since its merge-base with the default branch) that no module test runs. Every process a spec spawns is handed the active mutant. It fails on any mutant a test runs and none detects ([its verdict](https://github.com/jterrazz/package-typescript/blob/main/docs/reference/quality-checks.md#every-mutant-a-test-runs-is-detected)). `lint/self/` is not mutated: its audits run over this repository's own corpus, which they hold clean, so a mutant that loosens one survives by construction. A run mutates a sandbox copy under `.artifacts/stryker/tmp/`, never the checkout, and leaves its reports in `.artifacts/stryker/`, which git ignores.
|
|
23
|
+
|
|
24
|
+
It is incremental: Stryker re-runs only the mutants whose source or tests changed since the report it left, so a run over a day's changes takes roughly two to four minutes, and a first module run in a fresh clone, with no report yet, takes 75 minutes, once ([measured by ADR-020](https://github.com/jterrazz/package-typescript/blob/main/docs/decisions/020-mutation-measures-the-whole-suite-over-a-change.md#consequences)). A survivor whose covering test was removed keeps its result until its source changes, so a report that disagrees with a fresh read is cleared by deleting `.artifacts/stryker/incremental.json`, and `.artifacts/stryker/suite-incremental.json` for the suite's, whose stale entries the suite run reuses.
|
|
25
|
+
|
|
26
|
+
The everyday loop, and the targets beyond the six:
|
|
27
|
+
|
|
28
|
+
| Gesture | Does |
|
|
29
|
+
| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
30
|
+
| `npx vitest --run --project unit` | The fast suite, with no infrastructure |
|
|
31
|
+
| `npm run docs` | Regenerate the generated tables |
|
|
32
|
+
| `make test-changed` | Only the tests the uncommitted changes reach (`vitest --changed`): the local loop, never a verdict. Like any partial run, it judges no route ratchet ([API specs](api-specs.md#when-a-run-is-not-judged)) |
|
|
33
|
+
| `make test-mobile` | The mobile specs on a real iOS simulator; no CI job runs it, and [Test](test.md) says when it is owed |
|
|
34
|
+
| `make check-links` | Every link of `README.md`, `docs/` and `skills/`, remote ones included, with [lychee](https://lychee.cli.rs). It needs the network and a lychee on the machine, and fails where there is none. Never part of `check`: the gate reads local links offline, as `@jterrazz/typescript`'s Markdown pass states |
|
|
35
|
+
| `make test-merge` | Read the blob reports of a sharded run back as one, then judge the route ratchet on it ([Test § What CI runs](test.md#what-ci-runs) says how a run is split) |
|
|
36
|
+
| `make rehearse` | The release gate on Linux, on the commit to tag, before the tag ([Releasing](release.md) says when). It needs docker and the network; never part of `check` |
|
|
37
|
+
|
|
38
|
+
The `sqlite()` specs of the `unit` project need no rebuild after `npm ci`: this repository runs the `better-sqlite3` line whose native binding ships prebuilt. [Set up a project § What to install](../start/set-up-a-project.md#what-to-install) says which line that is, and what an older one costs.
|
|
39
|
+
|
|
40
|
+
## The build comes first
|
|
41
|
+
|
|
42
|
+
The lint loads this package's OWN plugin and presets from its build (`test()` from `dist/config.js`, the plugin from `dist/oxlint.js`, `self()` and its checks from `.artifacts/dev/`), and the end-to-end lint specs load them too. Node's type-stripping does not resolve a `.js` specifier back to its `.ts` source, so **`npm run build` must precede `npm run lint`** and must precede the `unit` project. A lint run on a stale bundle judges the previous build's rules.
|
|
43
|
+
|
|
44
|
+
`npm run build` is `typescript build`: the toolchain derives the bundle from the manifest (every `exports`, `imports` and `bin` target from its source) and from the names of the modules loaded by path: each `*.global-setup.ts` a specification hands vitest is shipped in `dist/` under its own name, and the programs only this repository runs (each `*.cli.ts`: `schema`, `route-split`, `self`; and the modules at the root of `lint/self/`: `preset`, `plugin`) are built into `.artifacts/dev/` and never shipped.
|
|
45
|
+
|
|
46
|
+
This repository's presets are its own build, and the build reads `jterrazz.config.ts` before there is one: the file lists `test()` and `self()` through `ownPresets` ([`@jterrazz/typescript`'s Configuration](https://github.com/jterrazz/package-typescript/blob/main/docs/reference/configuration.md#a-repository-held-to-its-own-presets)), so a fresh clone's first build reads the declaration alone, and every later run reads the presets too. Any other command over an unbuilt tree (a lint, a test, an editor's language server) is refused in four parts naming `npm run build`, so none of them passes on nothing.
|
|
47
|
+
|
|
48
|
+
This repository's `jterrazz.config.ts` declares it a `library` on `node`, lists `test()`, as every consumer does ([Linting § What the preset carries](../reference/linting.md#what-the-preset-carries)), then `self()`, the one preset only this repository declares, and states only its exceptions (how four test projects depart from the ones its tree derives, a mutation exclusion, the lint rules it sets apart, the `schema/` root), each with its reason, and under `optOuts` the two laws it sets aside: a base law, and its own preset's network seam, since the module test of the handlers that wrap msw drives msw itself; `self()` restates that seam's law for every other test file of the repository. `self()` is `lint/self/preset.ts`, built as `.artifacts/dev/preset.js`, and it carries everything this repository adds to its own tree: its layers, `ARCHITECTURE` in `lint/self/checks/architecture.ts`, which compiles `FRAMEWORK_LAYERS` (`lint/self/checks/layers.ts`) and the library owners into dependency-cruiser rules: the enforced statement of the trees [Architecture](../concepts/architecture.md) describes, the direction of `lint/` being the toolchain's standard's; the one name rule only this repository is held to ([Test](test.md#the-packages-own-rules)); the self plugin and its rules. Its `oxlint.config.ts`, `oxfmt.config.ts`, `tsconfig.json` and `vitest.config.ts` are the lines the toolchain derives.
|
|
49
|
+
|
|
50
|
+
## Which file a change opens
|
|
51
|
+
|
|
52
|
+
| Changing... | Opens |
|
|
53
|
+
| --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
54
|
+
| A specification's chain, setups or terminal actions | `src/specifications/<specification>/<specification>.composition.ts` and the `*.capability.ts` or `*.action.ts` it composes, in the specification's concept folders, or in the notion it sets up (`src/stubs/`, `src/services/`, `src/disk/`, `src/clock/`) and `src/chain/context/` for a setup every specification shares |
|
|
55
|
+
| What a result exposes | `src/expectations/` for an accessor, or the `*.result.ts` in the specification's concept folder; accessors stay READ-ONLY |
|
|
56
|
+
| A matcher, or update-mode behaviour | `src/expectations/goldens/` and `src/expectations/in-place/`; `src/vitest/` only for the hook that wires them |
|
|
57
|
+
| An external dependency's adapter | the ONE slice that owns the library: a notion (`src/services/<db>/`, `src/stubs/delivery/` ...) or a specification's concept folder (`src/specifications/website/browser/` ...), and no other |
|
|
58
|
+
| A `{{token}}` or the structural comparison | `src/expectations/tokens/`, the list of kinds in `lint/kit/project/tokens.ts` |
|
|
59
|
+
| A verb, a target, a landmark, or how one matches | `src/visitor/`: `verbs.ts` (`VisitorVerbs`, the verbs every surface shares), `targets/` (the `Target` type in `target.ts`, `roles.ts` and `landmarks.ts` (`within()` and the scope walk with it), each with the private factory it builds targets from, `modifiers.ts`, how a target is spelled in `as-written.ts`), `matching/` (the `target-matches-one-element` refusal in `ambiguous.ts`, what it lists of each candidate in `candidates.ts`, the tag-to-landmark table in `landmark-of-tag.ts`), `matrix.ts` (which verb and target each surface offers) |
|
|
60
|
+
| A runner-config default, or a project helper | `src/vitest/preset.ts`, `src/vitest/project.ts` and `src/vitest/unit.project.ts`, or the specification's own `<specification>.project.ts` for its project, published by `src/index.vitest.ts` |
|
|
61
|
+
| The `<case>.spec.yaml` grammar | `lint/kit/project/spec-document.ts`, read by BOTH the runner and the checker |
|
|
62
|
+
| How a cli document runs | `src/specifications/cli/documents/literate.ts` |
|
|
63
|
+
| A mechanized rule | its row in `lint/consumer/catalogue/manifest.json` (`lint/self/catalogue/manifest.ts` for a self rule), its audience stated, **and** its implementation by channel: a static rule at `lint/consumer/rules/<family>/<name>.ts`, wired in `lint/consumer/rules/plugin.ts`; a checker pass at `lint/consumer/checks/<name>.ts`, listed in the registry `lint/consumer/checks/checker.ts`, which `lint/index.checker.ts` hands the kit; an option on an upstream rule in `lint/consumer/rulebook/`; a self rule or pass under `lint/self/rules/<family>/` or `lint/self/checks/`, listed in the `plugin.ts` or `checker.ts` beside them, which `lint/self/self.cli.ts` composes |
|
|
64
|
+
| A principle, or a criterion no machine can settle | [Conventions](../concepts/conventions.md), the constitution |
|
|
65
|
+
|
|
66
|
+
## What a change owes
|
|
67
|
+
|
|
68
|
+
Four things land in the SAME commit as the change that makes them true.
|
|
69
|
+
|
|
70
|
+
- **The guard.** Every defect class discovered (in review, from a bug, during a migration) grows the thing that stops it recurring: a static rule, a meta-test, or a runtime refusal. It is what keeps the other channels growing instead of decaying. When no channel is possible, the change says so explicitly.
|
|
71
|
+
- **The regenerated tables.** `npm run docs` rewrites every `GENERATED` block at once, and `schema/spec.schema.json`. The blocks are, in [Test](test.md), the layered reading (`layers`), the siblingless table (`siblings`), the capability matrix (`matrix`), the meta-test channel (`meta-tests`) and the package's own rules (`self-rules`); the rule catalogue in [Linting](../reference/linting.md) (`catalogue`); the fork in [Getting started](../start/getting-started.md) (`fork`). Never edit one by hand: the freshness meta-tests fail on a hand edit.
|
|
72
|
+
- **The chapter the behaviour falsified.** A page that still describes the old behaviour is a defect that ships. The corpus is mapped by [`docs/README.md`](../README.md).
|
|
73
|
+
- **The skill, when the public surface moved.** `skills/jterrazz-test/SKILL.md` is hand-written and short (which kind, the pitfalls, the chapter to read next), and a meta-test fails when it names a symbol the entries do not export or a chapter that is not on disk; `README.md` is the front page and moves with a public API change too.
|
|
74
|
+
|
|
75
|
+
## The oxlint plugin API is alpha
|
|
76
|
+
|
|
77
|
+
Oxlint's JS-plugin API is **alpha**. The plugin takes oxlint's types from `@oxlint/plugins` (the context, the report, the fixer, the comments, a node's span) and keeps one view of its own (`lint/kit/project/rule.ts`): a rule reads a node by its `type` and its span, every other field defensively, so a change to oxlint's generated ESTree union is not a change to the rules. Every rule is written in oxlint's `createOnce` form: its visitor is built once per run, and its `before` hook resets what the rule keeps for one file and returns `false` for a file the rule does not judge, which oxlint then skips. What every rule asks of a file (its role, whether it is production, its `specs/` anchor) is answered once per file for all of them (`lint/kit/project/linted-file.ts`). `lint/self/corpus/__tests__/rule-state.test.ts` holds each rule to both: no state a `before` leaves unreset, and the same verdicts from a file linted alone or after the rest of its corpus. Expect to re-validate the plugin on oxlint majors; the E2E specs (`specs/lint/**`, one violation/compliant fixture pair per rule) run the real binary and are the canary.
|
|
78
|
+
|
|
79
|
+
## Pitfalls
|
|
80
|
+
|
|
81
|
+
- **Linting before building.** The lint loads this package's own plugin from `dist/`, so a lint on a stale bundle judges the previous build's rules ([The build comes first](#the-build-comes-first)).
|
|
82
|
+
- **Editing a generated table by hand.** The next `npm run docs` overwrites it, and the freshness meta-test fails until it does ([What a change owes](#what-a-change-owes)).
|
|
83
|
+
- **A stale plugin in the editor after a rebuild.** The oxlint language server loads `dist/oxlint.js` once and keeps it for the life of the server, so after `npm run build` the editor still lints against the old bundle. The command line is authoritative; restart the editor's oxlint server to resync.
|
|
84
|
+
|
|
85
|
+
## Writing the manual
|
|
86
|
+
|
|
87
|
+
The manual is cut into the four sections the [map](../README.md) lists; how a page is written:
|
|
88
|
+
|
|
89
|
+
- **One owner per fact.** Each fact is stated on one page; the others link to it.
|
|
90
|
+
- **The kind guides share one outline** (what it specifies, the constructor, the chain, the result, unique here, pitfalls), so the reader who learned one kind knows where to look in the next.
|
|
91
|
+
- **Rules live in the code.** A rule's normative sentence is written in `lint/consumer/catalogue/manifest.json` and generated into [Linting](../reference/linting.md); the pages explain and illustrate, and the catalogue decides.
|
|
92
|
+
- **Every example compiles.** A meta-test typechecks each block that imports only the framework, so a sample cannot drift from the API.
|
|
93
|
+
- **A block that starts at `test(...)` is an excerpt.** In its file, that test sits inside the file's one `describe`, which a block showing a whole file writes out.
|
|
94
|
+
|
|
95
|
+
## Related
|
|
96
|
+
|
|
97
|
+
[Architecture](../concepts/architecture.md) · [Test](test.md) · [Release](release.md) · [Set up a project](../start/set-up-a-project.md) · [Linting](../reference/linting.md)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Integration specs: a module against the real thing
|
|
2
|
+
|
|
3
|
+
`specification.integration()` tests a module (a function your test calls) against the real things it depends on: a database, a cache, a process, a declared stub. It also takes a module whose expected answer is a golden file. There is no HTTP, no binary and no page here; what makes this a spec rather than a module test beside the code is what the module stands on.
|
|
4
|
+
|
|
5
|
+
```typescript
|
|
6
|
+
// specs/integration/integration.specification.ts
|
|
7
|
+
import { specification } from '@jterrazz/test';
|
|
8
|
+
import { postgres } from '@jterrazz/test/services';
|
|
9
|
+
import { afterAll } from 'vitest';
|
|
10
|
+
|
|
11
|
+
export const { cleanup, integration } = await specification.integration({
|
|
12
|
+
services: { db: postgres() },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(cleanup);
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
// specs/integration/orders/orders.spec.ts
|
|
20
|
+
import { file } from '@jterrazz/test';
|
|
21
|
+
import { describe, expect, test } from 'vitest';
|
|
22
|
+
|
|
23
|
+
import { listOrders } from '../../../src/orders/orders.js';
|
|
24
|
+
import { integration } from '../integration.specification.js';
|
|
25
|
+
|
|
26
|
+
describe('orders', () => {
|
|
27
|
+
test('reads the orders the day left behind', async () => {
|
|
28
|
+
// Given - two orders in the real database, from the seed beside the test
|
|
29
|
+
const result = await integration
|
|
30
|
+
.seed(file('./__seeds__/two-orders.sql'))
|
|
31
|
+
.call(async ({ db }) => await listOrders(db.connectionString));
|
|
32
|
+
|
|
33
|
+
// Then - the module answered with both, compared whole
|
|
34
|
+
expect(result.value).toMatch([
|
|
35
|
+
{ id: 1, reference: 'ORD-1', total: 42 },
|
|
36
|
+
{ id: 2, reference: 'ORD-2', total: 108 },
|
|
37
|
+
]);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## What it specifies
|
|
43
|
+
|
|
44
|
+
An integration spec answers one question: **given these real services, what does this module return, throw, or leave in the database?** Two kinds of subject land here, the two halves of the fork's eighth question:
|
|
45
|
+
|
|
46
|
+
- **a module on a real service:** a repository against Postgres, a cache client against Redis;
|
|
47
|
+
- **a module whose oracle is a golden:** a renderer, a parser, a formatter whose output is long enough to be read as a file.
|
|
48
|
+
|
|
49
|
+
A subject that answers HTTP, runs as a binary or draws a screen belongs to that kind instead; a module that needs neither a service nor a golden is a module test ([the fork](../start/getting-started.md#which-kind-of-test)). Why this is a kind of its own: [ADR-004](https://github.com/jterrazz/package-test/blob/main/docs/decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-specification.md).
|
|
50
|
+
|
|
51
|
+
## The constructor
|
|
52
|
+
|
|
53
|
+
| Option | Means |
|
|
54
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
|
55
|
+
| `services` | the named record the module is specified against: `postgres()`, `redis()`, `sqlite()`, `process()`. Omit it for the golden-only half |
|
|
56
|
+
| `root` | project-root override (rule `no-redundant-root`), auto-discovered from the calling file |
|
|
57
|
+
|
|
58
|
+
The handle is `{ integration, cleanup }`, destructured with those names (rule `no-runner-alias`) and cleaned up with `afterAll(cleanup)` (rule `cleanup-passed-to-after-all`).
|
|
59
|
+
|
|
60
|
+
## The chain
|
|
61
|
+
|
|
62
|
+
Setups: `.seed()`, `.intercept()`, `.clock()`, and `integration.with({ clock })`, a view whose every chain starts at the instant the whole file shares ([Conventions](../concepts/conventions.md#what-a-file-shares)). Terminal action: `.call(subject)` (rule `one-terminal-action-per-chain`).
|
|
63
|
+
|
|
64
|
+
| Setup | Means |
|
|
65
|
+
| ------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
66
|
+
| `.seed(file('./__seeds__/rows.sql'))` | run that `.sql` file, named from the spec file, before the call; `{ database }` targets one of several |
|
|
67
|
+
| `.intercept(stubs)` | declare what the outside world replies ([Stubs](../reference/stubs.md)) |
|
|
68
|
+
| `.clock('2026-03-04T09:30:00Z')` | pin the module's `Date` for this chain ([Time and doubles](../reference/time-and-doubles.md#the-clock)) |
|
|
69
|
+
|
|
70
|
+
`.call(subject)` hands the STARTED services record to the subject, so the module is built with the real connection strings rather than with a double:
|
|
71
|
+
|
|
72
|
+
```typescript
|
|
73
|
+
const result = await integration.call(async ({ db }) => await listOrders(db.connectionString));
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Databases reset at the start of every chain, exactly as on api and job (rules `one-terminal-action-per-chain`, `sequence-expressed-by-seeding`): one spec is one call, and no spec depends on a previous one.
|
|
77
|
+
|
|
78
|
+
## The result
|
|
79
|
+
|
|
80
|
+
Two readings, never both.
|
|
81
|
+
|
|
82
|
+
| Accessor | Is |
|
|
83
|
+
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
84
|
+
| `result.value` | what the call RETURNED: a `TextAccessor` for a string, a `JsonAccessor<T>` for anything else, the union of both for a `T` that may be either |
|
|
85
|
+
| `result.error` | what it THREW, as text: an `Error`'s message, or the value itself. Empty when it returned |
|
|
86
|
+
| `result.table()` | a table of a declared database, for row-level assertions |
|
|
87
|
+
|
|
88
|
+
A refusal is a reading, never a `try`/`catch`:
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
test('refuses an order that is not one', async () => {
|
|
92
|
+
// Given - the module asked to record an order worth nothing
|
|
93
|
+
const result = await integration.call(
|
|
94
|
+
async ({ db }) => await placeOrder(db.connectionString, { reference: 'ORD-4', total: 0 }),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// Then - what it threw is the reading, and nothing was written
|
|
98
|
+
expect(result.error).toMatch('an order of 0 is not an order: reference ORD-4');
|
|
99
|
+
await expect(result.table('orders')).toBeEmpty();
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
That is what keeps a spec of a refusal the same size as a spec of a success, and what stops an "it should throw" spec from passing when nothing throws at all. `expect(result.error.text).toBe('')` is how a chain says "and it did not refuse": `result.error` is empty rather than absent when the call returned, so the empty text is the whole answer.
|
|
104
|
+
|
|
105
|
+
`.call<T>()` carries `T` through to the result, so one field can be read where one field is what the spec means (`expect(result.value.value.ok).toBe(true)` for JSON, `result.value.text` for a string), beside the comparison of the whole value, never instead of it (rule `result-compared-whole`).
|
|
106
|
+
|
|
107
|
+
**The accessor follows the value, and the type says so.** A subject typed exactly `string` reads as a `TextAccessor`; one that can never be a string reads as a `JsonAccessor<T>`; one that MAY be a string (`string | undefined`, `string | number`) is typed as the union of the two, and the spec narrows it, because only the value in hand decides, and a type that guessed handed back a `JsonAccessor` whose `.value` was silently `undefined` while the runtime had built a `TextAccessor`.
|
|
108
|
+
|
|
109
|
+
**What crosses is JSON, not the object.** A non-string value is serialised, so a `Map`, a `Set`, a `Date` or a class instance arrives as the plain JSON it serialises to (`{}`, `{}`, an ISO string, an object of its own fields), while `T` still claims the original type. A call whose answer is one of those projects it to plain data INSIDE the call, where the projection is visible:
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// the Map is projected where the spec can see it, not silently at the seam
|
|
113
|
+
const result = await integration.call(async () => Object.fromEntries(await loadIndex()));
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Both accessors are the package's ordinary subjects, compared whole: to the value written in place (`toMatch({ ... })` for a json, `toMatch('...')` for a text) or to `file('./__expected__/<name>.json')` when several tests share it; the `{{token}}` grammar for what moves, `{ frozen }`, and `TEST_UPDATE=1`, which writes the value into the test ([Tokens](../reference/tokens.md), [Assertions](../reference/assertions.md)).
|
|
117
|
+
|
|
118
|
+
A call that a declared stub never accepted fails the CHAIN rather than becoming `result.error`: a refusal the module chose is the subject's behaviour, an undeclared outgoing call is the spec's own mistake (rule `network-strict-after-first-stub`).
|
|
119
|
+
|
|
120
|
+
## Unique here
|
|
121
|
+
|
|
122
|
+
### The golden half: a pure module, no services at all
|
|
123
|
+
|
|
124
|
+
`specification.integration()` with no options is the pure-module runner: nothing starts, and what earns the folder is the golden.
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
export const { cleanup, integration } = await specification.integration();
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A table of cases is one `.call()` inside `test.each`, which is what turns fifty-three hand-paired fixture files into one spec:
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
test.each(CASES)('renders $name', async ({ name, input }) => {
|
|
134
|
+
// Given - one case of the table
|
|
135
|
+
const result = await integration.call(() => render(input));
|
|
136
|
+
|
|
137
|
+
// Then - the golden for that case
|
|
138
|
+
expect(result.value).toMatch(file(`./__expected__/${name}.json`));
|
|
139
|
+
expect(result.error.text).toBe('');
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The golden is named by a template literal, and the checker's `no-unused-ground` dead-fixture pass reads that form: a golden whose name matches the literal's static ends is one the table could have asked for, so a wall of `__expected__/` files stays alive without a hand-written literal each.
|
|
144
|
+
|
|
145
|
+
### The `integration()` project
|
|
146
|
+
|
|
147
|
+
The `integration` specification is the canonical project: a `specs/integration/` folder makes it, it collects `specs/integration/**/*.spec.ts`, carries the preset's budgets and artefact directory, and runs in group 0 with the other node specifications. `jterrazz.config.ts` states nothing for it until it departs from that.
|
|
148
|
+
|
|
149
|
+
An env-gated sub-suite is an option of `test.projects.integration`, not a second project: the whole point of `{ include, exclude, timeout }`:
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
const integration = {
|
|
153
|
+
exclude: process.env.E2E_NETWORK ? [] : ['specs/integration/network/**'],
|
|
154
|
+
reason: 'the network specs reach the internet, which a run turns on with E2E_NETWORK',
|
|
155
|
+
};
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Folder layout
|
|
159
|
+
|
|
160
|
+
```text
|
|
161
|
+
specs/integration/
|
|
162
|
+
├── integration.specification.ts # the runner, at the specification root
|
|
163
|
+
├── pure.specification.ts # a second runner: no services, goldens only
|
|
164
|
+
└── orders/ # a domain
|
|
165
|
+
├── orders.spec.ts # under specs/, the suffix is .spec.ts (rule `file-suffix-matches-place`)
|
|
166
|
+
├── __seeds__/two-orders.sql # ground: database state, beside its one test, or the tests of the leaf that share it
|
|
167
|
+
└── __expected__/ # ground: a golden several tests share; one test's is written in place (`ground-shared-or-inline`)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
**`required` is verified per `.call()`.** A chain's scope is ONE terminal action: the stubs it declared are checked when that call ends, and a stub declared `required` that was never requested fails there. A second `.call()` is a second chain with a second verification: it does not inherit the first's obligations.
|
|
171
|
+
|
|
172
|
+
## Pitfalls
|
|
173
|
+
|
|
174
|
+
- **Reaching for `try`/`catch`.** A thrown error is `result.error`. A `try` around `.call()` brings back the shape where a spec passes because nothing threw.
|
|
175
|
+
- **Writing a module test here.** A module alone, with no service and no golden, belongs in the `__tests__/` of its folder as `<file>.test.ts` (rule `test-in-its-tests-folder`). The folder is not a home for tests that were hard to place.
|
|
176
|
+
- **Goldening a volatile value.** An id, a timestamp or a duration inside `result.value` is tokened (`{{int}}`, `{{iso8601}}`), or pinned with `.clock()`. A literal that moves is a test that fails tomorrow (rule `no-volatile-literal`).
|
|
177
|
+
- **Asserting on a double.** The specification exists so the subject meets the real thing. A `mockOf<T>()` inside `.call()` is a module test that started a container for nothing.
|
|
178
|
+
|
|
179
|
+
## Related
|
|
180
|
+
|
|
181
|
+
[Module tests](module-tests.md) · [Stubs](../reference/stubs.md) · [Services](../reference/services.md) · [Assertions](../reference/assertions.md) · [Tokens](../reference/tokens.md)
|