@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,566 @@
|
|
|
1
|
+
# CLI specs (`specification.cli`)
|
|
2
|
+
|
|
3
|
+
`specification.cli(bin, options)` tests a command-line binary. Every spec runs the binary in a **fresh, empty temp directory**, captures stdout/stderr/exit code, and exposes the resulting filesystem and, in Docker-aware mode, the containers the binary spawned. The CLI is always a local binary.
|
|
4
|
+
|
|
5
|
+
Use it when the subject under test is a process invocation. If the process serves HTTP, test that surface with [api](api-specs.md).
|
|
6
|
+
|
|
7
|
+
## What it specifies
|
|
8
|
+
|
|
9
|
+
A CLI spec answers one question: **given this file tree, this environment and this argv, what does the binary print, what does it exit with, and what does it leave on disk?** The subject is the built command met through a child process, never an imported function, which is a module test's ([Module tests](module-tests.md)). Most of them are written as documents rather than as code; [Spec documents](spec-documents.md#the-three-doors-one-runner) says when each door is right.
|
|
10
|
+
|
|
11
|
+
## The constructor
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
// specs/cli/cli.specification.ts
|
|
15
|
+
import { resolve } from 'node:path';
|
|
16
|
+
import { afterAll } from 'vitest';
|
|
17
|
+
import { specification } from '@jterrazz/test';
|
|
18
|
+
|
|
19
|
+
export const { cli, cleanup } = await specification.cli(
|
|
20
|
+
resolve(import.meta.dirname, '../../bin/shoply.sh'),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
afterAll(cleanup);
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`bin` takes one of two forms. An absolute path (as above, or the built `dist/cli.js` of the package under test) runs that file. A bare name is looked up in the project's `node_modules/.bin/`, then in the `node_modules/.bin/` of the directory vitest runs from, and is otherwise handed to the shell, which finds it on `PATH`: `specification.cli('shoply')` runs an installed `shoply`. npm links the binaries of your dependencies there, never your own package's `bin`, so a project testing its own command passes the absolute path of the file its `bin` names. A relative path is neither: every spec runs in a fresh temp directory, so it resolves against that directory, not your project.
|
|
27
|
+
|
|
28
|
+
### Options
|
|
29
|
+
|
|
30
|
+
| Option | Description |
|
|
31
|
+
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
32
|
+
| `root` | **Project-root override** only (rule `no-redundant-root`): anchors local-bin resolution and the artefact paths. It is _not_ a fixtures root: a fixture's path is read from the spec file. Auto-discovery applies when omitted |
|
|
33
|
+
| `services` | Named record of infrastructure services (`postgres()`, `redis()`, `sqlite()`); connection URLs are auto-injected into the child env (rule `service-urls-injected`) |
|
|
34
|
+
| `docker` | Opt-in Docker awareness: `{ envVar, nameLabel, testRunLabel }`. See [Docker-aware mode](#docker-aware-mode) |
|
|
35
|
+
| `backend` | A **stub backend** started before the first run: `{ env, port? }`. Its URL is handed to every run in `env`, and `.intercept()` declares the stubs it serves; see [Declared backend](#declared-backend) |
|
|
36
|
+
| `defaults` | Environment applied to **every** run of this binary: the variables the product needs to be deterministic at all (`TZ`, `NO_COLOR`, `LANG`, a config home under the spec's own workdir). Stated once per app; a chained `.env()` and a document's `env:` both win over it |
|
|
37
|
+
| `env` | Named **environment sets** a spec document names by bare word (`env: [frozen]`); see [Registration](#registration-once-per-app) |
|
|
38
|
+
| `serve` | Named **servers** a spec document starts (`serve: [mcp]`): `{ command, ready, url, env }`; see [Registration](#registration-once-per-app) |
|
|
39
|
+
| `transform` | **Escape hatch only** (rule `no-transform-duplicating-tokens`): a normalizer applied to streams before comparison, for application noise not covered by tokens. ANSI stripping is already the default; a transform that only re-implements standard tokens is a lint error |
|
|
40
|
+
| `commands` | The words the binary's commands are spelled with (`['db', 'migrate']`), read by the conventions checker and never by the run: a chain's subject is the leading words of its command line the list of ITS runner holds: `exec('db migrate --dry')` is filed under `cli/db/migrate/` (`one-subject-per-spec`, `spec-in-subject-folder`), whichever other runner the spec imports. Written in the call or bound to a `const` of the runner's file. Without it the first word alone is the subject |
|
|
41
|
+
|
|
42
|
+
## The chain
|
|
43
|
+
|
|
44
|
+
### `.exec()`: the single execution method
|
|
45
|
+
|
|
46
|
+
`.exec(args?, options?)` is the **only** way to run the binary; there is no `.spawn()`. It covers short commands, long-running processes, and sequences. Called with **no arguments**, the binary runs bare (no CLI args), clearer than the `.exec('')` idiom:
|
|
47
|
+
|
|
48
|
+
```typescript
|
|
49
|
+
const result = await cli.exec(); // invoke the binary with no arguments
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The binary is spawned, never run with `spawnSync`: the test's event loop keeps turning while it runs, so a server the test itself started (an OTLP collector, a stub API on an ephemeral port) answers the binary, one-shot or long-running alike.
|
|
53
|
+
|
|
54
|
+
A one-shot run's whole process group ends with the test that ran it, whether the binary exited or not: `SIGTERM`, then `SIGKILL` after a 2 s grace, and the test is held until the group is gone. A process deaf to `SIGTERM` (a Playwright launcher's handler only closes its browsers) is killed before the worker can be torn down, so nothing the binary forked outlives the run.
|
|
55
|
+
|
|
56
|
+
Note the asymmetry with the array form: `.exec()` (no args) is a bare invocation, but `.exec([])` (empty array) throws: a command _sequence_ must name at least one command.
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
test('shows help', async () => {
|
|
60
|
+
// Given
|
|
61
|
+
const result = await cli.exec('--help');
|
|
62
|
+
|
|
63
|
+
// Then - full stdout snapshot; ANSI already stripped, dynamics covered by tokens
|
|
64
|
+
expect(result.exitCode).toBe(0);
|
|
65
|
+
expect(result.stdout).toMatch(file('./__expected__/help.txt'));
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
#### Long-running processes: `waitFor` / `timeout`
|
|
70
|
+
|
|
71
|
+
```typescript
|
|
72
|
+
test('dev server boots', async () => {
|
|
73
|
+
// Given - long-running: resolved on pattern match, killed at timeout otherwise
|
|
74
|
+
const result = await cli
|
|
75
|
+
.fixture(directory('../../__fixtures__/base-shop/'))
|
|
76
|
+
.exec('dev --port 0', { waitFor: 'Listening on', timeout: 10_000 });
|
|
77
|
+
|
|
78
|
+
// Then
|
|
79
|
+
expect(result.stdout.text).toContain('Listening on');
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
- `waitFor`: a pattern; the promise resolves (exit code 0) as soon as it appears in **stdout or stderr** (both streams are watched). If the process exits before matching, the result carries a non-zero exit code (a clean exit-0 without the pattern is reported as `1`).
|
|
84
|
+
- `timeout`: milliseconds; the process is killed when it elapses without a match, with exit code `124`. **Default: 10 000 ms** when `waitFor` is set without a `timeout`.
|
|
85
|
+
- On resolution (match, exit, or timeout) the child is ended with the escalation every `process()` takes ([Services § `process()`](../reference/services.md#process-the-one-shape-an-external-process-takes)).
|
|
86
|
+
- Termination targets the **whole process group**, not just the direct shell child: the process is spawned detached (its own group on POSIX) and signalled via the negative pid, so descendants (a `tsdown --watch` grandchild under a `dev` command, a background server the script forked) die with it instead of leaking past the spec. On platforms without group support (Windows), or when the group is already gone, it degrades to the direct child kill.
|
|
87
|
+
- Passing either option marks the run as long-running; combining them with the array (sequence) form is an error.
|
|
88
|
+
|
|
89
|
+
#### `cwd`: a folder of the working directory
|
|
90
|
+
|
|
91
|
+
`{ cwd: 'packages/site' }` runs the command in a folder of the working directory, named relative to it: a workspace member a tool judges from inside, a clone whose parent holds what the tool must not read. The folder is one the fixtures laid; a path leaving the working directory, absolute or climbing out of it, is refused before anything runs, and so is a folder the working directory does not hold. `cwd` is no long-running option: it combines with a sequence, and with `waitFor`. The result still reads the working directory as a whole: `result.file('packages/site/out.txt')`.
|
|
92
|
+
|
|
93
|
+
Quoting is identical between the one-shot and `waitFor` forms: both run the full command line through the shell (`spawnSync` vs `spawn`, `shell: true`), so a quoted argument behaves the same either way; there is no naive whitespace split.
|
|
94
|
+
|
|
95
|
+
#### Sequences: array form
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
test('build then start chain stops at first failure', async () => {
|
|
99
|
+
// Given - a sequence in the SAME working directory
|
|
100
|
+
const result = await cli
|
|
101
|
+
.fixture(directory('../../__fixtures__/base-shop/'))
|
|
102
|
+
.exec(['build', 'start --check']);
|
|
103
|
+
|
|
104
|
+
// Then
|
|
105
|
+
expect(result.exitCode).toBe(0);
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Commands run sequentially in the same cwd; the sequence stops at the first failure. Its subject, the folder the spec files under, is its LAST command: that is the one the Then reads, and the commands before it are the Given, which name no subject: a docker session opens with `up` and is not filed under it. This is still one terminal action: one spec, one `.exec()` (rule `one-terminal-action-per-chain`). An empty array throws (`exec([]) requires at least one command`), and `{ waitFor, timeout }` cannot be combined with the array form.
|
|
110
|
+
|
|
111
|
+
### Working-directory semantics
|
|
112
|
+
|
|
113
|
+
Every spec gets a brand-new temp directory (`mkdtemp`) as its cwd. The runner never writes into your fixtures.
|
|
114
|
+
|
|
115
|
+
That directory lives in the **OS temp dir**, inside the run's own temp root, and it stays there on purpose. It is scratch, created for one spec and removed once that spec's test has finished (at the run's end under `test.concurrent`, see [Set up a project](../start/set-up-a-project.md#where-a-run-writes)), not something the project produced, so it does not belong under `.artifacts/` with the caches a next run reuses (see [Set up a project](../start/set-up-a-project.md#where-a-run-writes)). Writing it inside the project would also change what a spec sees: a binary that walks up looking for a `package.json` would find yours instead of nothing.
|
|
116
|
+
|
|
117
|
+
Two disjoint verbs shape a spec's state (rule `seeds-are-sql`): **`.fixture()` carries file state** (an isolated tree copied into the cwd), **`.seed()` carries database state** (SQL only). Any "transformation" you need is expressed declaratively, in the _shape_ of the fixture tree; only what git cannot carry is laid by a step ([`.prepare()`](#prepare-what-no-fixture-can-carry)).
|
|
118
|
+
|
|
119
|
+
A fixture is named by `file()` or `directory()`, with its path from the spec file spelled in full; the wrapper states what it names:
|
|
120
|
+
|
|
121
|
+
| Call | Names | Copy effect |
|
|
122
|
+
| -------------------------------------------------- | ------------------------------ | --------------------------------------------- |
|
|
123
|
+
| `.fixture(file('./__fixtures__/config.toml'))` | one file beside the spec | `<cwd>/config.toml` (the file under its name) |
|
|
124
|
+
| `.fixture(directory('./__fixtures__/base-shop/'))` | a directory beside the spec | its **contents spread** into `<cwd>` |
|
|
125
|
+
| `.fixture(directory('../../__fixtures__/shop/'))` | a directory of the shared pool | its **contents spread** into `<cwd>` |
|
|
126
|
+
|
|
127
|
+
A directory's path ends in `/`, and the type refuses one that does not; a `file()` that names a directory on disk, or a `directory()` that names a file, is refused with the form to write (rule `disk-named-by-relative-path-at-runtime`). A command that expects the project one level down gets a fixture tree shaped that way: `./__fixtures__/nested/shop/...`, spread. The shared pool is `specs/__fixtures__/`, and a spec reaches it by the path that climbs to it; nothing hides behind a marker. Chained fixtures **layer** in order: a later one overwrites files from an earlier one.
|
|
128
|
+
|
|
129
|
+
| Setup | Effect on the cwd |
|
|
130
|
+
| ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
|
131
|
+
| _(nothing)_ | Empty directory |
|
|
132
|
+
| `.fixture(directory('../../__fixtures__/base-shop/'))` | Spread a whole shared project into the cwd |
|
|
133
|
+
| `.fixture(file('./__fixtures__/config.toml'))` | Copy the leaf's own `__fixtures__/config.toml` into the cwd |
|
|
134
|
+
| `.seed(file('./__seeds__/users.sql'))` | SQL into a service database (the `database:` option: [Services § The `database:` rule](../reference/services.md#the-database-rule)) |
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
test('scaffolds a new shop', async () => {
|
|
138
|
+
// Given - empty cwd (every spec = fresh mkdtemp)
|
|
139
|
+
const result = await cli.exec('create my-shop');
|
|
140
|
+
|
|
141
|
+
// Then - precise files...
|
|
142
|
+
expect(result.exitCode).toBe(0);
|
|
143
|
+
expect(result.file('my-shop/shoply.yaml').exists).toBe(true);
|
|
144
|
+
expect(result.file('my-shop/shoply.yaml').content).toContain('name: my-shop');
|
|
145
|
+
expect(result.file('my-shop/.env').exists).toBe(false);
|
|
146
|
+
|
|
147
|
+
// ...or a tree snapshot (structured added/removed/changed diff)
|
|
148
|
+
await expect(result.directory('my-shop')).toMatch(directory('./__expected__/shop-scaffold/'));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('upgrade rewrites the workspace predictably', async () => {
|
|
152
|
+
// Given
|
|
153
|
+
const result = await cli.fixture(directory('../../__fixtures__/base-shop/')).exec('upgrade');
|
|
154
|
+
|
|
155
|
+
// Then - snapshot of the ENTIRE cwd + flat read of the file list
|
|
156
|
+
await expect(result.filesystem).toMatch(directory('./__expected__/upgraded-shop/'));
|
|
157
|
+
expect(await result.filesystem.files()).toContain('shoply.lock');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('seeds a single fixture file', async () => {
|
|
161
|
+
// Given
|
|
162
|
+
const result = await cli
|
|
163
|
+
.fixture(file('./__fixtures__/legacy-config.toml'))
|
|
164
|
+
.exec('migrate-config');
|
|
165
|
+
|
|
166
|
+
// Then
|
|
167
|
+
expect(result.file('shoply.yaml').exists).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
A tree golden is a directory, compared by `toMatch(directory('./__expected__/shop-scaffold/'))`; its path ends in `/` where a file golden's carries its extension (rule `golden-path-has-extension`).
|
|
172
|
+
|
|
173
|
+
### `.env()`: child process environment
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
test('isolates HOME per spec', async () => {
|
|
177
|
+
// Given - HOME on the temp cwd, TZ pinned, stray variable removed
|
|
178
|
+
const result = await cli
|
|
179
|
+
.env({ HOME: '$WORKDIR', TZ: 'UTC', SHOPLY_TOKEN: null })
|
|
180
|
+
.exec('login --offline');
|
|
181
|
+
|
|
182
|
+
// Then
|
|
183
|
+
expect(result.file('.shoply/credentials').exists).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
- `'$WORKDIR'` expands to the spec's temp cwd; pin `HOME`, caches, config dirs onto isolated ground.
|
|
188
|
+
- `null` **removes** a variable from the child env, to shield the spec from the developer's shell.
|
|
189
|
+
- Repeated `.env()` calls merge; later keys win.
|
|
190
|
+
- `cli.with({ env })` is the environment a whole FILE shares: every chain of the view it hands back starts from it, and a chain's own `.env()` merges over it ([Conventions](../concepts/conventions.md#what-a-file-shares)). What every run of the binary needs, whichever file, is the runner's `defaults`.
|
|
191
|
+
|
|
192
|
+
#### Auto-injected connection URLs
|
|
193
|
+
|
|
194
|
+
When the runner has `services`, the framework injects into the child env, automatically:
|
|
195
|
+
|
|
196
|
+
| Variable | Rule |
|
|
197
|
+
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
198
|
+
| `<KEY>_URL` | One per record key, uppercased to CONSTANT_CASE at camelCase boundaries: `db:` → `DB_URL`, `analyticsDb:` → `ANALYTICS_DB_URL`, `cache:` → `CACHE_URL` |
|
|
199
|
+
| `DATABASE_URL` | Standard alias, only when the record has **exactly one** SQL database |
|
|
200
|
+
| `REDIS_URL` | Standard alias, only when the record has **exactly one** redis |
|
|
201
|
+
|
|
202
|
+
`.env()` overrides any injected value; `null` removes it. Re-declaring what injection already provides (`.env({ DATABASE_URL: db.connectionString })`) is redundant: rule [`no-redundant-service-url`](../reference/linting.md#no-redundant-service-url) refuses it.
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
// One SQL database → DATABASE_URL and DB_URL both injected, no database: option (rule `database-named-only-when-several`)
|
|
206
|
+
export const { cli: migrateCli, cleanup } = await specification.cli('shoply-migrate', {
|
|
207
|
+
services: { db: postgres() },
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('migrates a legacy schema', async () => {
|
|
211
|
+
// Given - DATABASE_URL / DB_URL injected automatically into the child env
|
|
212
|
+
const result = await migrateCli.seed(file('./__seeds__/legacy-schema.sql')).exec('up');
|
|
213
|
+
|
|
214
|
+
// Then
|
|
215
|
+
expect(result.exitCode).toBe(0);
|
|
216
|
+
await expect(result.table('schema_migrations')).toMatch([['20260716']]);
|
|
217
|
+
});
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### File state is the shape of a fixture tree
|
|
221
|
+
|
|
222
|
+
CLI-mode `.seed()` is **SQL only**: it carries database state, nothing else (rule `seeds-are-sql`). To put files or config trees into the cwd, use `.fixture(directory(...))` with a fixture laid out exactly as the cwd should look. There is no "seed handler" or path-prefix dispatch.
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
// specs/cli/deploy/__fixtures__/two-shops/ ← committed, shaped like the target cwd
|
|
226
|
+
// shoply.yaml
|
|
227
|
+
// shops/alpha/config.yaml
|
|
228
|
+
// shops/beta/config.yaml
|
|
229
|
+
|
|
230
|
+
test('deploys from a shaped workspace', async () => {
|
|
231
|
+
// Given - the tree is spread into the cwd; layering a second fixture would
|
|
232
|
+
// overwrite matching files (last write wins)
|
|
233
|
+
const result = await cli.fixture(directory('./__fixtures__/two-shops/')).exec('deploy alpha');
|
|
234
|
+
|
|
235
|
+
// Then
|
|
236
|
+
expect(result.exitCode).toBe(0);
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
#### Where a fixture lives
|
|
241
|
+
|
|
242
|
+
A fixture tree sits beside the one leaf that reads it, `./__fixtures__/<name>/` or, when several leaves share it, in the `__fixtures__/` of the lowest folder they share, `specs/__fixtures__/` when they span specifications, reached by the path that climbs to it. That law holds for every reserved folder, `__setup__/` included, and its owner is [Conventions](../concepts/conventions.md#the-naming-recap), with the three rules that hold it (`no-reach-into-sibling-ground`, `no-duplicate-ground`, `ground-shared-or-inline`).
|
|
243
|
+
|
|
244
|
+
### `.link()`: a package the working directory resolves
|
|
245
|
+
|
|
246
|
+
A fixture tree carries no `node_modules`: git never tracks one. A binary whose subject imports a package (a checker that loads a plugin, a build that resolves a peer) needs that package where Node looks for it, and `.link(name, source?)` puts it there: `<cwd>/node_modules/<name>`, a symlink to the package, never a copy and never an install, except for a packed package, which is the install a consumer gets.
|
|
247
|
+
|
|
248
|
+
| Call | Links |
|
|
249
|
+
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- |
|
|
250
|
+
| `.link('@acme/sdk')` | the package the spec's own project installed, found as Node finds it from the spec |
|
|
251
|
+
| `.link('@acme/sdk', directory('./__fixtures__/sdk/'))` | the package a directory holds: a stub install, a broken one, an older version |
|
|
252
|
+
| `.link('@acme/sdk', packed())` | the tarball `npm pack` writes for the spec's own project, as a consumer installs it ([below](#a-package-specified-from-its-artefact)) |
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
test('loads the plugin the project installed', async () => {
|
|
256
|
+
// Given - a project fixture, and the plugin it imports linked into its node_modules
|
|
257
|
+
const result = await cli
|
|
258
|
+
.fixture(directory('./__fixtures__/shop/'))
|
|
259
|
+
.link('@acme/sdk')
|
|
260
|
+
.exec('check');
|
|
261
|
+
|
|
262
|
+
// Then
|
|
263
|
+
expect(result.exitCode).toBe(0);
|
|
264
|
+
});
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
A link is armed after every fixture, so it replaces a package a fixture laid under the same name. The name is a package name (`@scope/name` or `name`), and one name is linked once per chain; a bare name no `node_modules` above the spec holds, or a `directory()` naming nothing, is refused before the binary runs. So is a working directory whose `node_modules` (or scope folder) is a symlink (a fixture can carry one, a binary can lay one, and the link would replace the real install it points to), or a file, which leaves the link no folder to be laid in.
|
|
268
|
+
|
|
269
|
+
### A package specified from its artefact
|
|
270
|
+
|
|
271
|
+
A library is specified as its consumers receive it: from the tarball, never from its source tree. `packed()` names that tarball, and `.link(name, packed())` lays it: `npm pack` over the project the spec belongs to (its nearest `package.json`), unpacked and COPIED to `node_modules/<name>`, beside a link to each package its manifest requires at runtime (`dependencies`, `optionalDependencies`, every peer not marked optional), taken from where the package is developed, and its `bin` entries in `node_modules/.bin/`. What `files` leaves out is not there, a subpath the `exports` map forgets does not resolve, and a dependency left in `devDependencies` is missing, each the failure a consumer would meet.
|
|
272
|
+
|
|
273
|
+
```typescript
|
|
274
|
+
test('a consumer loads the published entry', async () => {
|
|
275
|
+
// Given - a consumer module importing the package by its published name
|
|
276
|
+
const result = await cli
|
|
277
|
+
.fixture(directory('./__fixtures__/consumer/'))
|
|
278
|
+
.link('@acme/telemetry', packed())
|
|
279
|
+
.exec('consumer.mjs');
|
|
280
|
+
|
|
281
|
+
// Then
|
|
282
|
+
expect(result.exitCode).toBe(0);
|
|
283
|
+
});
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The project is packed once per test process, from what its build left in `dist/` (so the spec runs after `make build`, as the Makefile's `test: build` orders it), and under a mutation run the sandbox's own build is what gets packed. `packed(directory('./__fixtures__/.../'))` packs another package the spec holds. The name is the one the package's manifest declares: linking it under another is refused before the binary runs. So is a `directory()` naming nothing, a file or a folder with no `package.json`, a manifest that is no JSON, a runtime dependency no checkout above the package installed, a package `npm pack` cannot pack (its refusal quotes what npm said) and, as for a plain link, a working directory whose `node_modules` or scope folder is a symlink, which the install would otherwise delete through. Each is refused before anything is deleted or laid, so a refused install leaves what an earlier one laid as it was.
|
|
287
|
+
|
|
288
|
+
The install lays the package and what its manifest requires, never what npm would hoist beside them: a consumer whose install holds `vite` under `vitest`, `@types/node` or its own devDependencies links each by name (`.link('vite')`), so every package the consumer reaches is one its spec names.
|
|
289
|
+
|
|
290
|
+
**The declarations a consumer compiles against** are checked the same way: a consumer fixture holding a `.ts` module and a `tsconfig.json`, the compiler and the Node types linked beside the packed package, and `tsc` run over it: `.link('@acme/telemetry', packed()).link('typescript').link('@types/node').exec('node_modules/typescript/bin/tsc -p .')`, an exit of `0` and an empty stdout.
|
|
291
|
+
|
|
292
|
+
**`NODE_OPTIONS` reaches the binary on purpose.** A mutation run hands every spawned node its coverage reporter through it; a `defaults` that sets `NODE_OPTIONS: null` makes every mutant read as "no test runs". Clear what the product reads, never that.
|
|
293
|
+
|
|
294
|
+
### `.prepare()`: what no fixture can carry
|
|
295
|
+
|
|
296
|
+
A fixture tree is what git tracks, and some ground git never tracks: a `.git` for a tool that reads a repository, an empty folder, a file the repository's own `.gitignore` keeps out of the fixture, an index holding one file and not another. `.prepare(step)` runs a function of the working directory's absolute path once every fixture and link is laid, in the order the steps are stated, before the binary runs; a step may be async, and one that throws fails the chain before anything runs.
|
|
297
|
+
|
|
298
|
+
```typescript
|
|
299
|
+
test('refuses a tracked build artefact', async () => {
|
|
300
|
+
// Given - a project, made a repository whose index holds a buildinfo its .gitignore excludes
|
|
301
|
+
const result = await cli
|
|
302
|
+
.fixture(directory('./__fixtures__/project/'))
|
|
303
|
+
.prepare((workdir) => {
|
|
304
|
+
writeFileSync(join(workdir, 'tsconfig.tsbuildinfo'), '{}\n');
|
|
305
|
+
execFileSync('git', ['init', '--quiet'], { cwd: workdir });
|
|
306
|
+
execFileSync('git', ['add', '--force', 'tsconfig.tsbuildinfo'], { cwd: workdir });
|
|
307
|
+
})
|
|
308
|
+
.exec('check');
|
|
309
|
+
|
|
310
|
+
// Then
|
|
311
|
+
expect(result.exitCode).toBe(1);
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
A step is the Given of state no fixture can hold, never a second way to write one: a file a fixture could carry belongs in the fixture, where it is read in the tree rather than in code, and a package belongs to `.link()`.
|
|
316
|
+
|
|
317
|
+
### The chain never reaches a real system
|
|
318
|
+
|
|
319
|
+
A CLI spec is sandboxed the way an HTTP spec is, and for the same reason: what a chain proves must come out of its fixture, never out of the machine that happened to run it. A chain that shells out to a real `kubectl`, `helm` or `gh`, opens a network connection, or reads the operator's home directory is **a test escaping the sandbox, not extra realism**: it passes on the one laptop where those tools are installed and configured, fails everywhere else, and either way it proves something about that laptop rather than about the binary under test. The HTTP half of the same rule is `network-strict-after-first-stub`, in [stubs](../reference/stubs.md).
|
|
320
|
+
|
|
321
|
+
The framework supplies the ground; the chain states the sandbox, in three verbs:
|
|
322
|
+
|
|
323
|
+
| Verb | What it closes |
|
|
324
|
+
| --------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
325
|
+
| `.fixture(directory('../../__fixtures__/<name>/'))` | spreads a `bin/` of stub binaries into the spec's temp cwd |
|
|
326
|
+
| `.env({ PATH: '$WORKDIR/bin:/usr/bin:/bin' })` | puts those stubs FIRST; the binary finds the stub, never the tool installed on the host |
|
|
327
|
+
| `.env({ HOME: '$WORKDIR' })` | moves config dirs, caches and credential stores onto the temp cwd, off the operator's home |
|
|
328
|
+
|
|
329
|
+
A sealed `HOME` hides what the product itself shells out to. Pinning `HOME` onto the temp cwd also removes the operator's docker CLI plugins and git configuration, so a product that runs `docker build` loses its buildx plugin and prints a legacy-builder notice a racing stream then carries. Seal the product's own home, the folder it writes its configuration to (`.env({ MYAPP_HOME: '$WORKDIR' })`), and leave the operator's `HOME` for the tools the product shells out to.
|
|
330
|
+
|
|
331
|
+
A spec document cannot take a lock on a resource the suite shares, such as the one named container a TypeScript specification starts under a lock. A spec that needs a shared resource is code: a `*.spec.ts` that takes the lock the others take.
|
|
332
|
+
|
|
333
|
+
`PATH` is not rewritten for you: `.fixture(directory(...))` lays the tree down, `.env()` decides what the child is allowed to find. Pin the tail of `PATH` too: inheriting the developer's own `PATH` re-opens the door the stub was there to close.
|
|
334
|
+
|
|
335
|
+
```typescript
|
|
336
|
+
test('reports what the cluster holds', async () => {
|
|
337
|
+
// Given - stubs first on PATH, HOME on the temp cwd: nothing can reach out
|
|
338
|
+
const result = await cli
|
|
339
|
+
.fixture(directory('../../__fixtures__/cluster-stub/'))
|
|
340
|
+
.env({ HOME: '$WORKDIR', PATH: '$WORKDIR/bin:/usr/bin:/bin', TZ: 'UTC' })
|
|
341
|
+
.exec('cluster pods');
|
|
342
|
+
|
|
343
|
+
// Then
|
|
344
|
+
expect(result.exitCode).toBe(0);
|
|
345
|
+
expect(result.stdout).toMatch(file('./__expected__/pods.txt'));
|
|
346
|
+
});
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
#### The shape of a stub fixture
|
|
350
|
+
|
|
351
|
+
A stub fixture is a `bin/` holding one script per binary the run must not find for real:
|
|
352
|
+
|
|
353
|
+
```text
|
|
354
|
+
specs/__fixtures__/cluster-stub/
|
|
355
|
+
└── bin/
|
|
356
|
+
├── kubectl
|
|
357
|
+
└── helm
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
Each script answers **by argv** and refuses everything else. The executable bit survives the copy, so a stub committed `chmod +x` is executable in the cwd:
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
#!/bin/sh
|
|
364
|
+
# Every invocation this suite cans. Anything else is a chain reaching for an
|
|
365
|
+
# answer nobody wrote: fail loudly rather than improvise one.
|
|
366
|
+
case "$*" in
|
|
367
|
+
"get pods -A -o json")
|
|
368
|
+
cat <<'JSON'
|
|
369
|
+
{"items":[{"metadata":{"name":"api-abc12","namespace":"prod"},"status":{"phase":"Running"}}]}
|
|
370
|
+
JSON
|
|
371
|
+
;;
|
|
372
|
+
*)
|
|
373
|
+
echo "cluster-stub kubectl: unexpected invocation: $*" >&2
|
|
374
|
+
exit 64
|
|
375
|
+
;;
|
|
376
|
+
esac
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
The catch-all matters as much as the canned cases: a stub that shrugs at an unknown invocation hands the chain an empty success, and the spec goes green on a command nobody described.
|
|
380
|
+
|
|
381
|
+
**One fixture per ANSWER of the system, not one per test.** A cluster with things in it, that same cluster holding nothing, and a cluster whose API server is down are three fixtures, not three settings of one: they are three answers to the same question, and choosing between them is choosing what the chain proves. So a new answer is a NEW fixture, never a flag threaded through an existing stub: fixtures layer (a drift stub over a healthy one), which is how one is extended without being made conditional.
|
|
382
|
+
|
|
383
|
+
**The data-driven form: the script routes, a FILE answers.** When the answer is a document (a JSON payload, a listing, a manifest), heredocs stop being readable and stop layering: two worlds that differ only in what the cluster holds have to differ in the script. So the `case` maps a question to an answer file under `answers/<system>/`, and the script's whole job is routing:
|
|
384
|
+
|
|
385
|
+
```bash
|
|
386
|
+
#!/bin/sh
|
|
387
|
+
# Route argv to the file that answers it. The ANSWERS are the fixture; this
|
|
388
|
+
# Script only says which one the question maps to.
|
|
389
|
+
answers="${0%/*}/../answers/cluster"
|
|
390
|
+
case "$*" in
|
|
391
|
+
"get pods -A -o json") file="$answers/pods.json" ;;
|
|
392
|
+
"get nodes -o json") file="$answers/nodes.json" ;;
|
|
393
|
+
*) echo "cluster-stub kubectl: unexpected invocation: $*" >&2; exit 64 ;;
|
|
394
|
+
esac
|
|
395
|
+
[ -f "$file" ] || { echo "cluster-stub kubectl: no answer file at $file" >&2; exit 64; }
|
|
396
|
+
cat "$file"
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
Two failures, both exit 64, and they say different things: **no route** names the argv nobody described, **a routed file that is absent** names the path the fixture was supposed to carry. Neither may degrade into an empty success, which is also why **an empty answer is an EMPTY file, never a missing one**: "the cluster holds nothing" is a fixture stating `[]`, and a file that is not there is a fixture that forgot to state anything.
|
|
400
|
+
|
|
401
|
+
Pick by what the stub is standing in for. **Data-driven** when the answer is a document two worlds may legitimately differ on: that difference is then a fixture layered over another, and the script never changes. **`case`** when the stub simulates an action (a write, an exit code, a banner) or answers one constant: a file per constant is a directory of one-liners, and indirection nobody reads.
|
|
402
|
+
|
|
403
|
+
A served stub (a `serve:` server in a spec document) routes the same way over `$TEST_WORKDIR`, which is where that document's `fixture:` landed: see [Registration: once per app](#registration-once-per-app).
|
|
404
|
+
|
|
405
|
+
### Declared backend
|
|
406
|
+
|
|
407
|
+
A binary that sends its requests to a configurable origin (a collector, an ingest endpoint, an API) can be served stubs: `specification.cli(bin, { backend: { env: 'INGEST_URL' } })` starts a stub backend once, and every run receives its address in `INGEST_URL`. The chain declares what it answers with `.intercept(http.post('/ingest'), http.json({ accepted: true }))`: paths, since the origin is the backend's own. A request matching no declared stub is answered 501 and fails the run, naming the request; a stub declared `never` fails it if reached. What the binary sent is on the result: `result.requests` lists every request the backend received during the run, in order, matched or not, each `{ body, headers, method, url }` with the body parsed when it is JSON. `expect(result.requests.map(({ body }) => body)).toStrictEqual([{ report: 'daily' }])` compares an ingest payload whole, with no server of the spec's own. A chain begins with none: a result lists its own run's requests. A library whose host is written into its code cannot be pointed at a backend, and its specs stay in the process they run in.
|
|
408
|
+
|
|
409
|
+
## The result
|
|
410
|
+
|
|
411
|
+
A CLI result carries the streams, the exit code, the files the run left and, under Docker-aware mode, the containers it touched. Every accessor and every matcher it accepts is [Assertions](../reference/assertions.md)'s; what follows is the part only this specification has.
|
|
412
|
+
|
|
413
|
+
### Streams, JSON, grep
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
test('fails on unknown command with a useful error', async () => {
|
|
417
|
+
// Given
|
|
418
|
+
const result = await cli.exec('frobnicate');
|
|
419
|
+
|
|
420
|
+
// Then
|
|
421
|
+
expect(result.exitCode).toBe(1);
|
|
422
|
+
expect(result.stderr.text).toContain("Unknown command 'frobnicate'");
|
|
423
|
+
expect(result.stdout.text).toBe(''); // .text: ANSI stripped, transform applied
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
test('emits machine-readable config', async () => {
|
|
427
|
+
// Given
|
|
428
|
+
const result = await cli.exec('config --json');
|
|
429
|
+
|
|
430
|
+
// Then
|
|
431
|
+
expect(result.json.value).toMatchObject({ name: 'shoply' }); // read + native matcher
|
|
432
|
+
expect(result.json).toMatch(file('./__expected__/config.json')); // __expected__/config.json
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
test('lints a shop and reports per-file blocks', async () => {
|
|
436
|
+
// Given
|
|
437
|
+
const result = await cli.fixture(directory('../../__fixtures__/base-shop/')).exec('lint');
|
|
438
|
+
|
|
439
|
+
// Then - snapshot the whole surface; grep is the scalpel for targeted probes
|
|
440
|
+
expect(result.stdout).toMatch(file('./__expected__/lint-output.txt')); // full snapshot, tokens for volatile parts
|
|
441
|
+
expect(result.stdout.grep('products/ok.yaml').text).not.toContain('error'); // absence probe
|
|
442
|
+
});
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
`result.stdout` / `result.stderr` are `TextAccessor` subjects, the universal text handle (stdout, stderr, container logs, file text), ANSI-stripped for every comparison ([Tokens § Where tokens work](../reference/tokens.md#where-tokens-work)): `.text` is that comparable text (the runner's `transform` applied too) and `.raw` the capture as it was. Text operations are **closed** over the type: `.grep(pattern)` cuts the blank-line-separated blocks of the capture, its colour codes stripped and BEFORE the transform (so a pattern the transform removes still finds its block), and returns a `TextAccessor` (not a string) whose `.text` reads through the same transform, so it chains (`result.stdout.grep(a).grep(b)`) and snapshots (`expect(result.stdout.grep('users.ts')).toMatch(file('./__expected__/block.txt'))`). There is no `result.grep()`: the source is always explicit. `result.json` parses stdout as JSON (`.value` for the parsed object).
|
|
446
|
+
|
|
447
|
+
**Tool output gets a snapshot per scoped use case (rule `output-asserted-as-a-golden`).** For linter/compiler/CLI output, prefer a per-use-case fixture project + a full `expect(result.stdout).toMatch(file('./__expected__/<use-case>.txt'))` snapshot (volatile parts covered by `{{duration}}` / `{{workdir}}` / `{{path}}` tokens, generated with `TEST_UPDATE=1`) over a cluster of greps. The fixture is the Given; no shared `beforeAll`. Keep `.grep()` for targeted presence/absence probes in large outputs. The full surface is in [assertions](../reference/assertions.md).
|
|
448
|
+
|
|
449
|
+
## Unique here
|
|
450
|
+
|
|
451
|
+
### Spec documents: `<case>.spec.yaml`
|
|
452
|
+
|
|
453
|
+
Most cli cases are documents: a session written as data (the ground, the runs, what each printed and left on disk), collected as a test file of its own by the `cli()` project. The format is [Spec documents](spec-documents.md): its keys, its paths, the three doors that run it, failure and update. What stays here is what only this constructor owns: the words a document names its ground by.
|
|
454
|
+
|
|
455
|
+
#### Registration: once per app
|
|
456
|
+
|
|
457
|
+
The document names its ground by WORD; the code says what those words mean, in the `specification.cli()` options:
|
|
458
|
+
|
|
459
|
+
```typescript
|
|
460
|
+
// apps/cli/specs/cli/cli.specification.ts, in a workspace
|
|
461
|
+
export const { cli, cleanup } = await specification.cli(bin, {
|
|
462
|
+
env: {
|
|
463
|
+
frozen: { HOME: '$WORKDIR', TZ: 'UTC' },
|
|
464
|
+
},
|
|
465
|
+
serve: {
|
|
466
|
+
mcp: {
|
|
467
|
+
// Relative to apps/cli/, the project root, not the repo root
|
|
468
|
+
command: 'bun specs/harness/mcp-server.ts',
|
|
469
|
+
env: 'SHOPLY_MCP_ORIGIN',
|
|
470
|
+
ready: /listening on port (?<port>\d+)/,
|
|
471
|
+
url: (port) => `http://127.0.0.1:${port}/mcp`,
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
});
|
|
475
|
+
```
|
|
476
|
+
|
|
477
|
+
A `serve` entry is spawned with the document's extra `KEY: value` merged into its environment, and its **cwd is the project root**: rule `no-redundant-root`'s root, the nearest ancestor of the specification file carrying a `package.json`. **In a workspace that is the package's own directory, not the repository root**: a spec at `<repo>/apps/cli/specs/cli/cli.specification.ts` gives `<repo>/apps/cli/`, so `command` reads `'bun specs/harness/mcp-server.ts'` and not `'bun apps/cli/specs/harness/mcp-server.ts'`. Pass the runner's `root` option to move it.
|
|
478
|
+
|
|
479
|
+
`ready` is a regex over the child's output whose **first capture group** is the port the server chose (named or not, it is group 1 either way); the framework injects no `PORT`, the server announces one. `url(port)` builds the URL, and `env` names the variable it is bound to in every run's child.
|
|
480
|
+
|
|
481
|
+
**`TEST_WORKDIR` is seeded into every served child**: the document's working directory in the resolved form `{{workdir}}` holds and `$WORKDIR` expands to. The servers start after `fixture:` has been copied, so a stub reads the world the document laid down (`$TEST_WORKDIR/answers/dashboard/posts.json`) exactly as a stub binary reads `${0%/*}/../answers/`: a scenario is a layer of files, not a flag threaded through the server. The mapping form overrides it like any other key: `- mcp: { TEST_WORKDIR: /elsewhere }` points that one server somewhere else.
|
|
482
|
+
|
|
483
|
+
### Docker-aware mode
|
|
484
|
+
|
|
485
|
+
For CLIs that spawn Docker containers, declare the `docker` option (rule `containers-removed-with-the-run`). The specification refuses to start where no Docker daemon answers: a run that reads containers and could see none would pass on nothing.
|
|
486
|
+
|
|
487
|
+
```typescript
|
|
488
|
+
export const {
|
|
489
|
+
cli: deploy,
|
|
490
|
+
cleanup,
|
|
491
|
+
docker,
|
|
492
|
+
} = await specification.cli('shoply', {
|
|
493
|
+
docker: {
|
|
494
|
+
envVar: 'SHOPLY_TEST_RUN', // run id injected into the child env
|
|
495
|
+
nameLabel: 'dev.shoply.shop.name', // label used as the .container(name) key
|
|
496
|
+
testRunLabel: 'dev.shoply.test.run', // label the CLI must put on every container
|
|
497
|
+
},
|
|
498
|
+
});
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
The contract with your binary: it must label every container it creates with `testRunLabel = <value of envVar>`. That is how the runner finds (and force-removes) the containers belonging to a run.
|
|
502
|
+
|
|
503
|
+
A Docker-aware runner **requires `await using`** on every result (rule `docker-exec-bound-with-await-using`; the checker infers the runners from the `docker:` option), so leaked containers are cleaned by label filter at scope exit:
|
|
504
|
+
|
|
505
|
+
```typescript
|
|
506
|
+
test('deploy spawns a labelled container', async () => {
|
|
507
|
+
// Given - await using ⇒ containers force-removed at scope exit (rule `docker-exec-bound-with-await-using`)
|
|
508
|
+
await using result = await deploy
|
|
509
|
+
.fixture(directory('../../__fixtures__/two-shops/'))
|
|
510
|
+
.exec('deploy alpha');
|
|
511
|
+
|
|
512
|
+
// Then - lazy access (zero Docker calls if you never touch .container)
|
|
513
|
+
const shop = result.container('alpha');
|
|
514
|
+
expect(shop.exists).toBe(true);
|
|
515
|
+
await expect(shop).toBeRunning();
|
|
516
|
+
|
|
517
|
+
// Reads INSIDE the container, same vocabulary as on the host
|
|
518
|
+
expect(shop.file('/app/shoply.yaml').content).toContain('name: alpha');
|
|
519
|
+
const inside = await shop.exec('ls /app');
|
|
520
|
+
expect(inside.stdout.text).toContain('shoply.yaml');
|
|
521
|
+
expect(shop.stdout.text).toContain('shop ready'); // container logs
|
|
522
|
+
|
|
523
|
+
// Absent ≠ crash
|
|
524
|
+
expect(result.container('nope').exists).toBe(false);
|
|
525
|
+
expect(result.containerIds).toHaveLength(1);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
test('destroy removes the container created in an earlier run', async () => {
|
|
529
|
+
// Given - spawn then destroy: the whole runner shares one run-id scope
|
|
530
|
+
await using first = await deploy.exec('deploy alpha');
|
|
531
|
+
await using second = await deploy.exec('destroy alpha');
|
|
532
|
+
|
|
533
|
+
// Then
|
|
534
|
+
expect(second.exitCode).toBe(0);
|
|
535
|
+
expect(second.container('alpha').exists).toBe(false);
|
|
536
|
+
});
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
Container accessor surface: `exists`, `running`/`toBeRunning`, `status`, `id`, `file(path)` (content read inside the container), `exec(cmd)` (returns stdout/stderr), `stdout`/`stderr` (container logs). Looking up an absent container returns `exists: false` instead of throwing. `result.containerIds` lists every container captured for the run.
|
|
540
|
+
|
|
541
|
+
The runner handle also destructures to `{ cli, cleanup, docker }`. The `docker(containerId)` reader is the escape hatch for reading an **arbitrary** container by raw id (e.g. one a follow-up command referenced): it lazily runs `docker inspect` and returns the same `ContainerAccessor` type, so `await expect(docker(id)).toBeRunning()` and the sync reads work identically. Unlike `result.container(name)` it does not need `nameLabel`: it looks up by id directly; an unknown id yields `exists: false`.
|
|
542
|
+
|
|
543
|
+
## Pitfalls
|
|
544
|
+
|
|
545
|
+
- **Reaching for `.spawn()`.** It does not exist; `.exec(args, { waitFor, timeout })` is the unified execution method.
|
|
546
|
+
- **Asserting on raw ANSI or absolute paths in snapshots.** ANSI is stripped by default; paths and timestamps belong to `{{workdir}}`, `{{path}}`, `{{iso8601}}` tokens in `__expected__/*.txt`; `transform` is a last resort (rule `no-transform-duplicating-tokens`).
|
|
547
|
+
- **Assigning a Docker-aware result without `await using`.** Error (rule `docker-exec-bound-with-await-using`): that is the leak-cleanup mechanism.
|
|
548
|
+
- **Re-declaring injected URLs.** `.env({ DATABASE_URL: ... })` duplicates rule `service-urls-injected`'s auto-injection, and [`no-redundant-service-url`](../reference/linting.md#no-redundant-service-url) refuses it. Override only to _change_ it, `null` to remove it.
|
|
549
|
+
- **Letting the chain find the real binary.** Mounting a stub fixture without pinning `PATH` (or pinning it with the developer's own `PATH` on the tail) leaves the installed `kubectl`/`gh` reachable, and the spec then tests the operator's machine. Pin `PATH` and `HOME` on `$WORKDIR`.
|
|
550
|
+
- **Writing into fixture folders from a test.** The cwd is a copy; feature-local `__fixtures__/` and the shared `specs/__fixtures__/` pool are templates and stay pristine.
|
|
551
|
+
- **Reaching for `.project()` or `seedHandlers`.** Both are gone: one verb copies files, `.fixture()`, handed a `file()` or a `directory()`, and `.seed()` is SQL-only (rule `seeds-are-sql`).
|
|
552
|
+
- **A bare fixture name.** `.fixture('shop/')` and `directory('shop/')` do not compile: the path is spelled from the spec: `./__fixtures__/shop/` beside it, `../../__fixtures__/shop/` in the ground at the specs root (rule `disk-named-by-relative-path`).
|
|
553
|
+
- **Building a `node_modules` by hand.** `mkdirSync` and `symlinkSync` around a raw `spawnSync` is a chain the runner already is: `.link()` lays the package, `.link(name, packed())` the published tarball with what it requires, and the spec stays one chain.
|
|
554
|
+
- **Forgetting the extension in `file('./__expected__/help')`.** The extension is part of the path (rule `golden-path-has-extension`); a tree is `directory('./__expected__/help/')`.
|
|
555
|
+
- **Reading a sequence's first command.** A sequence's result is the LAST command's (its streams and exit code are what the Then reads), and its subject is the last command as well; the commands before it are the Given. A spec that must read an earlier step runs it as its own spec.
|
|
556
|
+
- **A serve-and-read consumer.** A spec that starts a server in one command and reads it in the next races the server's start: serve with `waitFor`, or give the reader a stand-in that fails loudly when nothing answers.
|
|
557
|
+
- **Opening a browser, or reaching a client's network.** Put a stand-in opener on `PATH` and set `$BROWSER` to it so a command that opens a page writes the URL it was handed to a file; give a client that reaches the network a proxy that refuses every request and names it.
|
|
558
|
+
- **Specifying an application through its built entry.** A spec that runs `dist/index.js` needs the build first: the Makefile of every kind whose specs run `dist/` reads `test: build`, and the path is named in the spec as the build leaves it (`named-path-exists` accepts a path under `dist/` or `.artifacts/<tool>/`).
|
|
559
|
+
- **Confusing `text()` with `content`.** `text(value)` promotes a string (a message, a report) to a golden-comparable subject (ANSI stripped, tokens read); `result.file(path).content` is the UTF-8 text of a file the run left, to be read or compared as a file.
|
|
560
|
+
- **A resource shared across spec files.** Files run in worker processes of their own: a port, a docker name or a database two files take at once needs a lock taken in `__setup__/` (a file under `.artifacts/` created exclusively), not a project made serial by name.
|
|
561
|
+
- **A resource the product itself mutates.** A mutable resource the product writes and reads across its own steps (an image tag every build retags, a cache entry) is locked by the product around its window, not by the specs: no spec lock covers two processes of the product itself, so a second run between its build and its create retags what the first was about to use. Give each run a tag of its own, or serialise the window in the product.
|
|
562
|
+
- **`@types/node` in a package that reads no Node.** A library that does not read `node:` modules declares no `@types/node`; one that reads Node declares it, and a spec project follows its package.
|
|
563
|
+
|
|
564
|
+
## Related
|
|
565
|
+
|
|
566
|
+
[Spec documents](spec-documents.md) · [Set up a project](../start/set-up-a-project.md) · [Assertions](../reference/assertions.md) · [Tokens](../reference/tokens.md) · [Services](../reference/services.md)
|