@jterrazz/test 16.1.1 → 17.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -171
- package/dist/browser/index.js +2 -2980
- package/dist/browser/services.js +2 -0
- package/dist/browser/stubs.js +2 -0
- package/dist/checker.d.ts +6 -1
- package/dist/checker.js +4534 -2628
- package/dist/config.d.ts +40 -0
- package/dist/config.js +309 -0
- package/dist/in-place.global-setup.d.ts +11 -0
- package/dist/in-place.global-setup.js +21 -0
- package/dist/index.d.ts +1653 -3004
- package/dist/index.js +5226 -3845
- package/dist/mobile-session.global-setup.d.ts +14 -0
- package/dist/mobile-session.global-setup.js +31 -0
- package/dist/oxlint.cjs +6069 -5634
- package/dist/oxlint.d.cts +43 -7212
- package/dist/oxlint.d.ts +43 -7212
- package/dist/oxlint.js +6127 -5693
- package/dist/route-coverage.global-setup.d.ts +42 -0
- package/dist/route-coverage.global-setup.js +2 -0
- package/dist/services.d.ts +3 -0
- package/dist/services.global-setup.d.ts +43 -0
- package/dist/services.global-setup.js +2 -0
- package/dist/services.js +597 -0
- package/dist/shared-appium.adapter.js +428 -0
- package/dist/shared-artifacts.js +32 -0
- package/dist/shared-chapter.js +7 -0
- package/dist/shared-container.port.d.ts +31 -0
- package/dist/shared-derived-projects.js +58 -0
- package/dist/shared-device.port.d.ts +189 -0
- package/dist/shared-exchange.js +447 -0
- package/dist/shared-in-place.js +172 -0
- package/dist/shared-index.browser.js +3191 -0
- package/dist/shared-index.services.d.ts +346 -0
- package/dist/shared-index.stubs.d.ts +287 -0
- package/dist/shared-json-shape.js +17 -0
- package/dist/shared-landmarks.js +71 -0
- package/dist/shared-ledger.js +68 -0
- package/dist/shared-literate-plugin.js +183 -0
- package/dist/shared-manifest.js +1321 -0
- package/dist/shared-pause.js +11 -0
- package/dist/shared-peer.js +88 -0
- package/dist/shared-playwright.adapter.js +330 -0
- package/dist/shared-process.adapter.js +74 -0
- package/dist/shared-refusal.js +35 -0
- package/dist/shared-resolve.js +24 -0
- package/dist/shared-route-coverage.global-setup.js +187 -0
- package/dist/shared-scenario-failure.js +198 -0
- package/dist/shared-serve.js +365 -0
- package/dist/shared-service-images.js +10 -0
- package/dist/shared-services.global-setup.js +179 -0
- package/dist/shared-session-ledger.js +301 -0
- package/dist/shared-setup-entry.js +12 -0
- package/dist/shared-source-scan.js +133 -0
- package/dist/shared-spec-document.js +365 -0
- package/dist/shared-specifications.js +26 -0
- package/dist/shared-stream-body.js +130 -0
- package/dist/shared-stub-server.js +190 -0
- package/dist/shared-temp-root.global-setup.js +80 -0
- package/dist/shared-test-baseline.js +26 -0
- package/dist/shared-test-section.js +58 -0
- package/dist/shared-testcontainers.adapter.js +203 -0
- package/dist/shared-tokens.js +55 -0
- package/dist/shared-yaml-document.js +119 -0
- package/dist/stubs.d.ts +2 -0
- package/dist/stubs.js +657 -0
- package/dist/temp-root.global-setup.d.ts +7 -0
- package/dist/temp-root.global-setup.js +2 -0
- package/dist/vitest.d.ts +97 -163
- package/dist/vitest.js +373 -525
- package/docs/README.md +82 -36
- package/docs/concepts/architecture.md +118 -0
- package/docs/concepts/conventions.md +333 -0
- package/docs/guides/api-specs.md +374 -0
- package/docs/guides/cli-specs.md +564 -0
- package/docs/guides/component-specs.md +252 -0
- package/docs/guides/develop.md +94 -0
- package/docs/guides/integration-specs.md +181 -0
- package/docs/guides/job-specs.md +180 -0
- package/docs/guides/lint-specs.md +174 -0
- package/docs/guides/mobile-specs.md +319 -0
- package/docs/guides/module-tests.md +149 -0
- package/docs/guides/release.md +43 -0
- package/docs/guides/spec-documents.md +189 -0
- package/docs/guides/website-specs.md +413 -0
- package/docs/reference/assertions.md +345 -0
- package/docs/reference/glossary.md +20 -0
- package/docs/reference/linting.md +483 -0
- package/docs/reference/services.md +236 -0
- package/docs/reference/stubs.md +498 -0
- package/docs/reference/time-and-doubles.md +137 -0
- package/docs/reference/tokens.md +258 -0
- package/docs/reference/visitor.md +221 -0
- package/docs/start/getting-started.md +187 -0
- package/docs/start/set-up-a-project.md +214 -0
- package/package.json +50 -30
- package/schema/spec.schema.json +43 -17
- package/dist/appium.adapter.js +0 -489
- package/dist/artifacts.js +0 -32
- package/dist/fixtures.js +0 -110
- package/dist/literate.js +0 -1747
- package/dist/match.js +0 -804
- package/dist/playwright.adapter.js +0 -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,180 @@
|
|
|
1
|
+
# Job specs (`specification.job`)
|
|
2
|
+
|
|
3
|
+
`specification.job()` tests background pipelines (cron jobs, queue consumers, nightly reports) by triggering them in-process against real databases and stubbed external providers. No HTTP server is involved: the subject under test is _what a job writes_, not what an endpoint returns.
|
|
4
|
+
|
|
5
|
+
Use it when the behaviour you care about starts with "when the job runs...". If the behaviour starts with an HTTP request, use [api](api-specs.md).
|
|
6
|
+
|
|
7
|
+
## What it specifies
|
|
8
|
+
|
|
9
|
+
A job spec answers one question: **when this name is triggered, what does the pipeline do to the world it was handed?** The subject is the assembled job (its handler, its services, the network it reaches), met through the one door the scheduler would use, `.trigger(name)`. What a single pure function inside that pipeline returns is a module test's ([Module tests](module-tests.md)); what the pipeline leaves in a database is this page's.
|
|
10
|
+
|
|
11
|
+
The scheduler that invokes the job in production (a cron, a queue) is out of scope: the job is the subject, and its name is how a spec reaches it.
|
|
12
|
+
|
|
13
|
+
## The constructor
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
// specs/job/job.specification.ts
|
|
17
|
+
import { afterAll } from 'vitest';
|
|
18
|
+
import { specification } from '@jterrazz/test';
|
|
19
|
+
import { postgres } from '@jterrazz/test/services';
|
|
20
|
+
import { nightlyReport, supportDrafts } from '../../src/jobs.js';
|
|
21
|
+
|
|
22
|
+
// Hoisted, so a test can read this database after a chain that rejected
|
|
23
|
+
export const analyticsDb = postgres(); // → "analytics-db", init from docker/analytics-db/
|
|
24
|
+
|
|
25
|
+
export const { job, cleanup } = await specification.job({
|
|
26
|
+
services: { db: postgres(), analyticsDb },
|
|
27
|
+
jobs: ({ db, analyticsDb }) => [nightlyReport(db, analyticsDb), supportDrafts(db)],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(cleanup);
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Options
|
|
34
|
+
|
|
35
|
+
| Option | Required | Description |
|
|
36
|
+
| ---------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
37
|
+
| `services` | yes (if the jobs need infra) | Named record of service factories, same semantics as [api](api-specs.md), see [services](../reference/services.md) |
|
|
38
|
+
| `jobs` | yes | `(services) => Job[]`, or a static array. Each is a named, triggerable job (rule `server-sees-declared-services`) |
|
|
39
|
+
| `root` | no | Root-resolution override, same walk-up rule as everywhere (rule `no-redundant-root`) |
|
|
40
|
+
|
|
41
|
+
There is **no `server`** option: jobs run in-process by definition, and the record of services is the only outside world they meet. A `Job` carries the name you pass to `.trigger()`: the handles above respond to `'nightly-report'` and `'support-drafts'`.
|
|
42
|
+
|
|
43
|
+
## The chain
|
|
44
|
+
|
|
45
|
+
Setups: `.seed(file('./__seeds__/<name>.sql'))`, `.intercept()`, `.clock()`, and `job.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: `.trigger(name)` (rule `one-terminal-action-per-chain`). No `.headers()`: there is no request.
|
|
46
|
+
|
|
47
|
+
`.clock('2026-03-04T09:30:00Z')` pins the job's own `Date` for the chain and releases it when the action resolves: a nightly report that stamps a window, an expiry the job computes. The primitive and its second depth are [Time and doubles § The clock](../reference/time-and-doubles.md#the-clock).
|
|
48
|
+
|
|
49
|
+
```typescript
|
|
50
|
+
// specs/job/nightly-report/pipeline.spec.ts: the job, and the aspect this file asks of it
|
|
51
|
+
import { file } from '@jterrazz/test';
|
|
52
|
+
import { describe, expect, test } from 'vitest';
|
|
53
|
+
import { job } from '../job.specification.js';
|
|
54
|
+
import pipeline from './__setup__/stubs/pipeline.stubs.js';
|
|
55
|
+
|
|
56
|
+
describe('pipeline', () => {
|
|
57
|
+
test('nightly report classifies, prices and drafts', async () => {
|
|
58
|
+
// Given - one pending article + the world the pipeline reaches out to
|
|
59
|
+
const result = await job
|
|
60
|
+
.seed(file('./__seeds__/one-pending-article.sql'), { database: 'db' })
|
|
61
|
+
.intercept(pipeline)
|
|
62
|
+
.trigger('nightly-report');
|
|
63
|
+
|
|
64
|
+
// Then - the pipeline produced a classified, priced report
|
|
65
|
+
await expect(result.table('reports', { database: 'analyticsDb' })).toMatch([
|
|
66
|
+
[1, 'ELECTRONICS', 109],
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Everything a pipeline reads from the outside world is declared: seeds set the database state, stubs pin the external providers (OpenAI, Anthropic, arbitrary HTTP; see [stubs](../reference/stubs.md)). Databases reset at the start of every chain, exactly as for API specs (rules `one-terminal-action-per-chain`, `sequence-expressed-by-seeding`).
|
|
73
|
+
|
|
74
|
+
Because jobs run in-process by definition, `.intercept()` is always available. It is **strict** (rule `network-strict-after-first-stub`): once a chain declares one stub, an outgoing request no stub answers fails the spec ([Stubs § Strict by construction](../reference/stubs.md#strict-by-construction)). A chain with no stubs is not network-guarded.
|
|
75
|
+
|
|
76
|
+
### Seeding and sequences for pipelines
|
|
77
|
+
|
|
78
|
+
Selection is **first non-exhausted match wins**, so a multi-call scenario is a finite stub in front of an unlimited tail; `times` says how many calls the first one answers:
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
test('retries then recovers from provider rate-limit', async () => {
|
|
82
|
+
// Given - the first call is rate-limited, everything after it succeeds
|
|
83
|
+
const result = await job
|
|
84
|
+
.seed(file('./__seeds__/pending-articles.sql'), { database: 'db' })
|
|
85
|
+
.intercept([
|
|
86
|
+
{ request: openai.chat(), response: openai.error(429), times: 1 },
|
|
87
|
+
{ request: openai.chat(), response: openai.reply({ category: 'BOOKS' }) },
|
|
88
|
+
])
|
|
89
|
+
.trigger('nightly-report');
|
|
90
|
+
|
|
91
|
+
// Then - the retry succeeded
|
|
92
|
+
await expect(result.table('reports', { database: 'analyticsDb' })).toMatch([
|
|
93
|
+
[1, 'BOOKS', '{{number}}'],
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The number in `times` IS the assertion: it says the job retries exactly once. A stub with no `times` is unlimited, which is what you want for a route the pipeline may hit any number of times.
|
|
99
|
+
|
|
100
|
+
## The result
|
|
101
|
+
|
|
102
|
+
`.trigger()` resolves to a result whose primary subjects are the databases:
|
|
103
|
+
|
|
104
|
+
| Member | Description |
|
|
105
|
+
| --------------------------- | --------------------------------------------------------------------------- |
|
|
106
|
+
| `result.table(name, opts?)` | Table subject, compared whole: `toMatch(<rows>)` / `toBeEmpty()`, `await`ed |
|
|
107
|
+
|
|
108
|
+
Whether `.seed()` and `.table()` name a database is [Services § The `database:` rule](../reference/services.md#the-database-rule).
|
|
109
|
+
|
|
110
|
+
## Unique here
|
|
111
|
+
|
|
112
|
+
### Error-case testing: a provider that fails on purpose
|
|
113
|
+
|
|
114
|
+
Provider failure modes are first-class response builders. This is where job specs earn their keep, because these paths are nearly impossible to reproduce against live providers:
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
test('handles malformed model output', async () => {
|
|
118
|
+
// Given - the model returns something that is not JSON
|
|
119
|
+
const result = await job
|
|
120
|
+
.intercept(openai.chat(), openai.malformed('not json at all'))
|
|
121
|
+
.trigger('nightly-report');
|
|
122
|
+
|
|
123
|
+
// Then - nothing half-written
|
|
124
|
+
await expect(result.table('reports', { database: 'analyticsDb' })).toBeEmpty();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('times out gracefully', async () => {
|
|
128
|
+
// Given - a silent provider (delay longer than the job timeout)
|
|
129
|
+
const result = await job
|
|
130
|
+
.intercept(anthropic.messages(), anthropic.timeout())
|
|
131
|
+
.trigger('support-drafts');
|
|
132
|
+
|
|
133
|
+
// Then
|
|
134
|
+
await expect(result.table('support_drafts', { database: 'db' })).toBeEmpty();
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
The three failure families:
|
|
139
|
+
|
|
140
|
+
| Builder | Simulates |
|
|
141
|
+
| ---------------------------------------------------------------------- | ------------------------------------------------------ |
|
|
142
|
+
| `openai.error(429)` / `anthropic.error(status)` / `http.error(status)` | HTTP error status from the provider |
|
|
143
|
+
| `openai.timeout()` / `anthropic.timeout()` | A provider that never answers within the job's timeout |
|
|
144
|
+
| `openai.malformed('...')` | A 200 whose body violates the provider schema |
|
|
145
|
+
|
|
146
|
+
The full builder catalogue lives in [stubs](../reference/stubs.md).
|
|
147
|
+
|
|
148
|
+
### A crash is read by its rejection
|
|
149
|
+
|
|
150
|
+
A job that throws rejects `.trigger()` with its own error, and what it wrote before the throw stays in the database. Read it from the service handle, since a rejected chain hands back no result. That is why the constructor above hoists `analyticsDb` out of the record: a handle declared on its own is one a test can import and query.
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
import { describe, expect, test } from 'vitest';
|
|
154
|
+
|
|
155
|
+
import { analyticsDb, job } from '../job.specification.js';
|
|
156
|
+
|
|
157
|
+
describe('crash', () => {
|
|
158
|
+
test('a crashed job leaves what it wrote first', async () => {
|
|
159
|
+
// Given - a job that writes one row, then throws
|
|
160
|
+
const run = job.trigger('crash-after-insert');
|
|
161
|
+
|
|
162
|
+
// Then - its error, and the row it wrote before it
|
|
163
|
+
await expect(run).rejects.toThrow('crash-after-insert: boom');
|
|
164
|
+
await expect(analyticsDb.query('events', ['type'])).resolves.toStrictEqual([['pre_crash']]);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Pitfalls
|
|
170
|
+
|
|
171
|
+
- **Looking for a `server` option.** It exists only on `specification.api()` and `specification.website()`; jobs are in-process by definition; what a job reads from the outside is its `services` record (rule `server-sees-declared-services`).
|
|
172
|
+
- **Asserting on a job's return value.** The result surface is the observable state (tables); jobs are specified by their effects.
|
|
173
|
+
- **Forgetting `times` on the leading stub of a sequence.** With no `times` the first stub is unlimited, so it answers every call and the recovery stub behind it is never reached.
|
|
174
|
+
- **One giant spec that triggers two jobs.** A chain has exactly one terminal action (rule `one-terminal-action-per-chain`); sequence scenarios are expressed by seeding the state the second job would have found (rule `sequence-expressed-by-seeding`).
|
|
175
|
+
- **Forgetting `await` on table expectations.** `expect(result.table(...))` without `await` never runs the SQL: table matchers are IO matchers, typed `Promise<void>`, and the type-aware `typescript/no-floating-promises` refuses one dropped.
|
|
176
|
+
- **Bounding the tail of a sequence.** The last stub of a sequence should stay unlimited; a `times` there turns one extra retry into an "unmatched outgoing request" failure (rule `network-strict-after-first-stub`).
|
|
177
|
+
|
|
178
|
+
## Related
|
|
179
|
+
|
|
180
|
+
[API specs](api-specs.md) · [Assertions](../reference/assertions.md) · [Stubs](../reference/stubs.md) · [Services](../reference/services.md)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Lint specs (`specification.lint`)
|
|
2
|
+
|
|
3
|
+
`specification.lint()` tests the rules a project ships (an oxlint plugin, a cross-file checker, the laws a `@jterrazz/typescript` preset holds a tree to) by running the real linters over fixture trees and comparing their whole report. The subject is a rule, met the way a consumer meets it: a tree in, the linter's verdict out.
|
|
4
|
+
|
|
5
|
+
Use it when the behaviour you care about starts with "when this tree is linted...". A rule's visitor alone, on one snippet, is a module test with [`ruleTester()`](#a-rule-alone-ruletester); the tree the rule is meant to judge, with its folders and its neighbours, is this page's.
|
|
6
|
+
|
|
7
|
+
## What it specifies
|
|
8
|
+
|
|
9
|
+
A lint spec answers one question: **when this tree is linted, where does which rule report, and what does it say?** A rule is a real subject a team tests, like a binary: it has inputs (a tree), a contract (a report), and consumers who read that report. A rule reading ONE file is proven by its report over a tree holding that file; a rule reading the whole tree (a folder's shape, a manifest, what imports what) can only be proven that way, so one specification serves both.
|
|
10
|
+
|
|
11
|
+
What it does not do: judge the tree it runs in. The specification lints a fixture laid in a fresh working directory, never the repository's own sources; those are `typescript check`'s.
|
|
12
|
+
|
|
13
|
+
## The constructor
|
|
14
|
+
|
|
15
|
+
The examples below are a package that ships an oxlint plugin and a preset, both built to its own `dist/`: `dist/oxlint.js` is that package's built plugin, and `dist/config.js` exports its preset, here called `acme()`. Use the paths your build writes, or the published specifier of your package (`'@acme/lint/oxlint'`). A plugin is loaded from what was built, so build before the lint project runs ([§ Pitfalls](#pitfalls)).
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// specs/lint/lint.specification.ts
|
|
19
|
+
import { afterAll } from 'vitest';
|
|
20
|
+
|
|
21
|
+
import { recommendedRules } from '../../dist/oxlint.js';
|
|
22
|
+
import { specification } from '@jterrazz/test';
|
|
23
|
+
|
|
24
|
+
export const { lint, cleanup } = await specification.lint({
|
|
25
|
+
config: { categories: { correctness: 'off' }, rules: recommendedRules },
|
|
26
|
+
plugins: ['../../dist/oxlint.js'],
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterAll(cleanup);
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
A preset's laws, or a checker of the project's own, are linters of the same specification:
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
// specs/lint/preset-lint.specification.ts
|
|
36
|
+
import { afterAll } from 'vitest';
|
|
37
|
+
|
|
38
|
+
import { acme } from '../../dist/config.js';
|
|
39
|
+
import { specification } from '@jterrazz/test';
|
|
40
|
+
|
|
41
|
+
export const { lint, cleanup } = await specification.lint({ presets: [acme()] });
|
|
42
|
+
|
|
43
|
+
afterAll(cleanup);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Options
|
|
47
|
+
|
|
48
|
+
At least one linter: a plugin, a config, a checker or presets, `presets: []` holding the tree to the base preset alone; a specification naming none is refused.
|
|
49
|
+
|
|
50
|
+
| Option | Description |
|
|
51
|
+
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
52
|
+
| `plugins` | oxlint JS plugins, each a path from the specification's folder or a package specifier. They run in the real oxlint, `oxlint` being an optional peer the project installs |
|
|
53
|
+
| `config` | The oxlint config oxlint runs under (`rules`, `settings`, `categories`, `overrides`), less `jsPlugins`, which `plugins` states; named alone, it runs oxlint's built-in rules |
|
|
54
|
+
| `presets` | `@jterrazz/typescript` presets whose laws run on the tree (the Architecture pass over every `layers`, each declared `names` rule), resolved after the always-on base preset as `jterrazz.config.ts` would; `[]` is the base preset alone |
|
|
55
|
+
| `checkers` | Cross-file checkers, each a command printing one finding a line in the compiler's convention, `<file>:<line>:<column>: error <plugin>(<rule>): <message>`; `.run()` hands it the tree last |
|
|
56
|
+
| `root` | Root-resolution override (rule `no-redundant-root`); the plugins' specifiers and the checkers' commands resolve from its `node_modules` |
|
|
57
|
+
| `settings` | What the project adds beside its presets, written as `jterrazz.config.ts` writes it (`typescript: { names, architecture: { layers } }`, `optOuts`), so a repository's own additions are its own, never a preset it makes up |
|
|
58
|
+
|
|
59
|
+
There is nothing to start and nothing to stop: `cleanup` is handed to `afterAll` like every constructor's, and releases nothing.
|
|
60
|
+
|
|
61
|
+
## The chain
|
|
62
|
+
|
|
63
|
+
Setups: `.fixture(directory('./__fixtures__/<tree>/'))` (or a `file()`, copied by name) lays the tree in a fresh working directory ([CLI specs § Fixtures](cli-specs.md#the-chain)); `.rules({ ... })` states rules and their options for this chain, over the specification's `config`, a later call winning; on a specification that runs no oxlint (no plugin, no config) it is refused, since no linter would read it. Terminal action: `.run()` (rule `one-terminal-action-per-chain`).
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
// specs/lint/no-module-mock/no-module-mock.spec.ts
|
|
67
|
+
import { describe, expect, test } from 'vitest';
|
|
68
|
+
import { directory } from '@jterrazz/test';
|
|
69
|
+
|
|
70
|
+
import { lint } from '../lint.specification.js';
|
|
71
|
+
|
|
72
|
+
describe('no-module-mock', () => {
|
|
73
|
+
test('rejects an allow-list entry with no reason', async () => {
|
|
74
|
+
// Given - a module on the allow-list, and no reason beside it
|
|
75
|
+
const result = await lint
|
|
76
|
+
.rules({ 'acme/no-module-mock': ['error', { modules: [{ module: 'x', reason: '' }] }] })
|
|
77
|
+
.fixture(directory('./__fixtures__/allow-listed/'))
|
|
78
|
+
.run();
|
|
79
|
+
|
|
80
|
+
// Then - the finding, where it fires, with its lesson
|
|
81
|
+
expect(result.report).toMatch(`
|
|
82
|
+
src/storage.test.ts:5:1 acme(no-module-mock)
|
|
83
|
+
found: "x" is on the \`modules\` allow-list with no reason
|
|
84
|
+
why: A test doubles a PORT, never a module.
|
|
85
|
+
fix: state why no consumer can inject it
|
|
86
|
+
read: docs/reference/linting.md#no-module-mock
|
|
87
|
+
`);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`.run()` takes two options:
|
|
93
|
+
|
|
94
|
+
| Option | Description |
|
|
95
|
+
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
96
|
+
| `fix` | Apply oxlint's and the checkers' fixes, then lint the fixed tree: the report is what a run after the fix says, and `result.file()` reads what it wrote; the presets' laws have no fix, and are judged on the fixed tree |
|
|
97
|
+
| `args` | Handed to every checker before the tree, as a checker takes a flag (`--member packages/web`) |
|
|
98
|
+
|
|
99
|
+
## The result
|
|
100
|
+
|
|
101
|
+
| Member | Description |
|
|
102
|
+
| ------------------------ | ------------------------------------------------------------------- |
|
|
103
|
+
| `result.report` | Every linter's diagnostics, as a text subject (see below) |
|
|
104
|
+
| `result.file(path)` | A file of the tree after the run, relative to the working directory |
|
|
105
|
+
| `result.directory(path)` | A folder of the tree, compared whole to a golden tree |
|
|
106
|
+
|
|
107
|
+
### The report
|
|
108
|
+
|
|
109
|
+
One diagnostic is its head line, `<file>:<line>:<column> <plugin>(<rule>)`, then its message: a lesson a part per line, `found:`, `why:`, `fix:`, `read:`, or the message as the linter wrote it:
|
|
110
|
+
|
|
111
|
+
```text
|
|
112
|
+
specs/app/widget/widget.test.ts:8:36 acme(no-sleep)
|
|
113
|
+
found: an arbitrary sleep in a test
|
|
114
|
+
why: No arbitrary sleep in any test file.
|
|
115
|
+
fix: wait on what the sleep stands for
|
|
116
|
+
read: docs/reference/linting.md#no-sleep
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The diagnostics come in the order a reader walks the tree (file, then line, then column), whichever linter printed them and whatever order its threads finished in; a tally, a summary or any other line is not one. A clean tree is the empty text: `expect(result.report).toMatch('')`. Written in place and rewritten by `TEST_UPDATE=1` ([Tokens](../reference/tokens.md#update-mode-tokens-are-preserved)), the golden proves where each rule fired, which one, and what it teaches.
|
|
120
|
+
|
|
121
|
+
oxlint's graphical rendering is refused, never read as a clean run: the specification states `--format=unix` itself, and a report handed to it in frames names the format to state.
|
|
122
|
+
|
|
123
|
+
## Unique here
|
|
124
|
+
|
|
125
|
+
### What `.run()` runs
|
|
126
|
+
|
|
127
|
+
Each linter the specification names, over the working directory:
|
|
128
|
+
|
|
129
|
+
1. **oxlint**, when `plugins` or `config` is named: the real binary, with a config the specification writes outside the tree (`jsPlugins` the plugins, `rules` the specification's `config.rules` and the chain's `.rules()` over them), at `--format=unix`.
|
|
130
|
+
2. **each checker**, as `<command> <args> .`, from the tree, its first word resolved from the project's `node_modules/.bin` when it is installed there.
|
|
131
|
+
3. **the presets' laws**, in process, through `presetChecks` of `@jterrazz/typescript/rules`, handed the `presets` and the `settings` as one settings object: each finding at its position, one about a whole file or folder at `:1:1`. Settings the kit cannot resolve are refused like a linter that fails with no diagnostic.
|
|
132
|
+
|
|
133
|
+
A preset's own `oxlint` (its plugin, its rulebook) is not run by `presets`: the presets hand the specification their laws alone. A preset's rules are proven as any plugin's are, the plugin under `plugins` and its rules under `config`, so a report holds only the rules the specification names.
|
|
134
|
+
|
|
135
|
+
A linter that fails and reports no diagnostic is refused in four parts rather than read as a clean tree: a plugin that does not load, a config oxlint cannot build, a checker that cannot start.
|
|
136
|
+
|
|
137
|
+
### A rule pack: a nested Vitest and a reporter fixture
|
|
138
|
+
|
|
139
|
+
A package that ships lint rules for other projects proves them by running the real binary on a tree: the spec starts a nested `vitest` over a fixture project (`.fixture(directory(...))`) with a reporter fixture that prints one line per finding, and compares that output whole. A rule alone is `ruleTester()`.
|
|
140
|
+
|
|
141
|
+
### A rule alone: `ruleTester()`
|
|
142
|
+
|
|
143
|
+
A rule's visitor, on snippets, is a module test in the `__tests__/` of the rule's folder. `ruleTester()` is oxlint's `RuleTester` (`oxlint/plugins-dev`) bound to vitest (its suites and cases are vitest's), and it requires a position on every invalid case: each expected error is an object stating its `line` and `column`. A count, an empty list, or an error with no position, is refused before the case runs, since it passes for a report on any node.
|
|
144
|
+
|
|
145
|
+
The two tools count columns differently, and each keeps its own convention: `ruleTester()` reads oxlint's RuleTester columns, **0-based** (`column: 25` below is the `setTimeout` at index 25), while a lint spec's report prints the compiler's `file:line:column`, **1-based**, so the same finding reads `:1:26` there.
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
// src/rules/no-sleep.test.ts
|
|
149
|
+
import { ruleTester } from '@jterrazz/test';
|
|
150
|
+
|
|
151
|
+
import { noSleep } from './no-sleep.js';
|
|
152
|
+
|
|
153
|
+
ruleTester().run('no-sleep', noSleep, {
|
|
154
|
+
invalid: [
|
|
155
|
+
{
|
|
156
|
+
code: 'await new Promise((r) => setTimeout(r, 100));',
|
|
157
|
+
errors: [{ column: 25, line: 1, messageId: 'slept' }],
|
|
158
|
+
filename: '/project/specs/api/users/list.spec.ts',
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
valid: ['await waitUntil(() => ready);'],
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Pitfalls
|
|
166
|
+
|
|
167
|
+
- **Linting a stale build.** The plugins and checkers a project ships are usually loaded from its `dist/`: build before the lint project runs, or the specs judge the previous rules.
|
|
168
|
+
- **A golden cut to the rule ids.** The report carries each lesson whole, so a reworded message moves the golden: that is the contract a reader meets, and `TEST_UPDATE=1` rewrites it.
|
|
169
|
+
- **A literal token in a message.** A lesson that echoes `{{uuid}}` is read as the token in the golden; cover the echo with `{{string}}` ([Tokens § Known limitations](../reference/tokens.md#known-limitations)).
|
|
170
|
+
- **One specification for every linter.** A plugin's fixture judged by the checker too reports what it was never built to show: give each linter a specification of its own (`checker-lint.specification.ts`, `preset-lint.specification.ts`).
|
|
171
|
+
|
|
172
|
+
## Related
|
|
173
|
+
|
|
174
|
+
[CLI specs](cli-specs.md) · [Assertions](../reference/assertions.md) · [Tokens](../reference/tokens.md) · [Linting](../reference/linting.md)
|