@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
package/dist/literate.js
DELETED
|
@@ -1,1747 +0,0 @@
|
|
|
1
|
-
import { c as SpecSyntaxError, d as readSpecFile, n as Matcher, p as updateSpecFile, r as TOKEN_KINDS, t as CaptureScope } from "./match.js";
|
|
2
|
-
import { t as copyPlan } from "./fixtures.js";
|
|
3
|
-
import { cpSync, existsSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
-
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
5
|
-
import { execSync, spawn, spawnSync } from "node:child_process";
|
|
6
|
-
import { readdir } from "node:fs/promises";
|
|
7
|
-
import { createServer } from "node:net";
|
|
8
|
-
//#region src/model/matching/structural.ts
|
|
9
|
-
/**
|
|
10
|
-
* Structural comparison engine shared by every fixture matcher.
|
|
11
|
-
*
|
|
12
|
-
* Handles three kinds of "expected" values:
|
|
13
|
-
* - plain JSON values → strict deep equality
|
|
14
|
-
* - {@link Matcher} instances (code-side `match.*`)
|
|
15
|
-
* - strings containing `{{placeholder}}` forms (file-side fixtures)
|
|
16
|
-
*
|
|
17
|
-
* One unified `{{token}}` grammar (CONVENTIONS D4) — the same vocabulary
|
|
18
|
-
* works in `_expected/*.http` (body and headers), `_expected/*.json`, and
|
|
19
|
-
* text snapshots (`_expected/*.txt`).
|
|
20
|
-
*
|
|
21
|
-
* Ref captures (`match.ref(name)` / `{{type#name}}`) are recorded in the
|
|
22
|
-
* {@link CaptureScope} supplied by the caller.
|
|
23
|
-
*/
|
|
24
|
-
const UUID_SOURCE = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
|
|
25
|
-
const ULID_SOURCE = "[0-9A-HJKMNP-TV-Z]{26}";
|
|
26
|
-
const ISO8601_SOURCE = String.raw`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})`;
|
|
27
|
-
const DATE_SOURCE = String.raw`\d{4}-\d{2}-\d{2}`;
|
|
28
|
-
const TIME_SOURCE = String.raw`\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?`;
|
|
29
|
-
const DURATION_SOURCE = String.raw`\d+(?:\.\d+)?(?:ms|s|m|h)`;
|
|
30
|
-
const NUMBER_SOURCE = String.raw`-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?`;
|
|
31
|
-
const INT_SOURCE = String.raw`-?\d+`;
|
|
32
|
-
const FLOAT_SOURCE = String.raw`-?\d+\.\d+`;
|
|
33
|
-
const SEMVER_SOURCE = String.raw`\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?`;
|
|
34
|
-
const SHA_SOURCE = "[0-9a-f]{7,64}";
|
|
35
|
-
const HEX_SOURCE = "[0-9a-fA-F]+";
|
|
36
|
-
const BASE64_SOURCE = "[A-Za-z0-9+/]+={0,2}";
|
|
37
|
-
const PORT_SOURCE = String.raw`(?:6553[0-5]|655[0-2]\d|65[0-4]\d{2}|6[0-4]\d{3}|[1-5]\d{4}|\d{1,4})`;
|
|
38
|
-
const IP_OCTET = String.raw`(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)`;
|
|
39
|
-
const IP_SOURCE = String.raw`(?:${IP_OCTET}\.){3}${IP_OCTET}`;
|
|
40
|
-
const URL_SOURCE = String.raw`https?:\/\/[^\s"'<>]+`;
|
|
41
|
-
const EMBEDDED_SOURCES = {
|
|
42
|
-
base64: BASE64_SOURCE,
|
|
43
|
-
date: DATE_SOURCE,
|
|
44
|
-
duration: DURATION_SOURCE,
|
|
45
|
-
email: String.raw`[^\s@"'<>]+@[^\s@"'<>]+\.[^\s@"'<>]+`,
|
|
46
|
-
float: FLOAT_SOURCE,
|
|
47
|
-
hex: HEX_SOURCE,
|
|
48
|
-
int: INT_SOURCE,
|
|
49
|
-
ip: IP_SOURCE,
|
|
50
|
-
iso8601: ISO8601_SOURCE,
|
|
51
|
-
number: NUMBER_SOURCE,
|
|
52
|
-
path: String.raw`\.{0,2}\/[^\s"'<>]*`,
|
|
53
|
-
port: PORT_SOURCE,
|
|
54
|
-
semver: SEMVER_SOURCE,
|
|
55
|
-
sha: SHA_SOURCE,
|
|
56
|
-
time: TIME_SOURCE,
|
|
57
|
-
ulid: ULID_SOURCE,
|
|
58
|
-
url: URL_SOURCE,
|
|
59
|
-
uuid: UUID_SOURCE
|
|
60
|
-
};
|
|
61
|
-
const wholeRe = (source) => new RegExp(`^(?:${source})$`, "u");
|
|
62
|
-
const WHOLE_RES = Object.fromEntries(Object.entries(EMBEDDED_SOURCES).map(([kind, source]) => [kind, wholeRe(source)]));
|
|
63
|
-
const PLACEHOLDER_RE = new RegExp(String.raw`\{\{(?<kind>${[...TOKEN_KINDS].sort((a, b) => b.length - a.length).join("|")})(?:#(?<ref>[\w.-]+))?\}\}`, "gu");
|
|
64
|
-
/** Whether a fixture string contains at least one `{{placeholder}}`. */
|
|
65
|
-
function hasPlaceholders(value) {
|
|
66
|
-
PLACEHOLDER_RE.lastIndex = 0;
|
|
67
|
-
return PLACEHOLDER_RE.test(value);
|
|
68
|
-
}
|
|
69
|
-
/** Key-order-independent stringification for captured-object comparison. */
|
|
70
|
-
function stableStringify(value) {
|
|
71
|
-
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
72
|
-
if (value !== null && typeof value === "object") return `{${Object.entries(value).sort(([a], [b]) => a < b ? -1 : 1).map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`;
|
|
73
|
-
return JSON.stringify(value) ?? "undefined";
|
|
74
|
-
}
|
|
75
|
-
function capturedEquals(a, b) {
|
|
76
|
-
if (Object.is(a, b)) return true;
|
|
77
|
-
if (typeof a === "number" && typeof b === "string" || typeof a === "string" && typeof b === "number") return String(a) === String(b);
|
|
78
|
-
if (a !== null && b !== null && typeof a === "object" && typeof b === "object") return stableStringify(a) === stableStringify(b);
|
|
79
|
-
return false;
|
|
80
|
-
}
|
|
81
|
-
function recordRef(name, actual, scope) {
|
|
82
|
-
if (scope.has(name)) return capturedEquals(scope.get(name), actual);
|
|
83
|
-
scope.set(name, actual);
|
|
84
|
-
return true;
|
|
85
|
-
}
|
|
86
|
-
function isPortValue(value) {
|
|
87
|
-
return Number.isInteger(value) && value >= 0 && value <= 65535;
|
|
88
|
-
}
|
|
89
|
-
function kindMatches(kind, actual, scope) {
|
|
90
|
-
switch (kind) {
|
|
91
|
-
case "any": return true;
|
|
92
|
-
case "float":
|
|
93
|
-
if (typeof actual === "number") return Number.isFinite(actual);
|
|
94
|
-
return typeof actual === "string" && WHOLE_RES.float.test(actual);
|
|
95
|
-
case "int":
|
|
96
|
-
if (typeof actual === "number") return Number.isInteger(actual);
|
|
97
|
-
return typeof actual === "string" && WHOLE_RES.int.test(actual);
|
|
98
|
-
case "number":
|
|
99
|
-
if (typeof actual === "number") return Number.isFinite(actual);
|
|
100
|
-
return typeof actual === "string" && WHOLE_RES.number.test(actual);
|
|
101
|
-
case "port":
|
|
102
|
-
if (typeof actual === "number") return isPortValue(actual);
|
|
103
|
-
return typeof actual === "string" && WHOLE_RES.port.test(actual) && isPortValue(Number(actual));
|
|
104
|
-
case "string": return typeof actual === "string";
|
|
105
|
-
case "workdir": return typeof actual === "string" && scope.workdir !== void 0 ? actual === scope.workdir : false;
|
|
106
|
-
default: {
|
|
107
|
-
const re = WHOLE_RES[kind];
|
|
108
|
-
return re !== void 0 && typeof actual === "string" && re.test(actual);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
function matcherMatches(matcher, actual, scope) {
|
|
113
|
-
if (matcher.kind === "includes") return typeof actual === "string" && actual.includes(matcher.includes);
|
|
114
|
-
if (matcher.kind === "regex") return typeof actual === "string" && matcher.regex.test(actual);
|
|
115
|
-
if (matcher.kind === "ref") {
|
|
116
|
-
if (matcher.notRef && scope.has(matcher.notRef) && capturedEquals(scope.get(matcher.notRef), actual)) return false;
|
|
117
|
-
return recordRef(matcher.refName, actual, scope);
|
|
118
|
-
}
|
|
119
|
-
return kindMatches(matcher.kind, actual, scope);
|
|
120
|
-
}
|
|
121
|
-
function placeholderSource(kind, scope) {
|
|
122
|
-
if (kind === "workdir") return scope.workdir === void 0 ? "(?!)" : escapeRegExp(scope.workdir);
|
|
123
|
-
const source = EMBEDDED_SOURCES[kind];
|
|
124
|
-
if (source) return source;
|
|
125
|
-
return kind === "any" ? String.raw`[\s\S]*?` : String.raw`[^\n]*?`;
|
|
126
|
-
}
|
|
127
|
-
function escapeRegExp(text) {
|
|
128
|
-
return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`);
|
|
129
|
-
}
|
|
130
|
-
function parsePlaceholderString(expected, scope) {
|
|
131
|
-
PLACEHOLDER_RE.lastIndex = 0;
|
|
132
|
-
const refs = [];
|
|
133
|
-
let source = "";
|
|
134
|
-
let lastIndex = 0;
|
|
135
|
-
let single = null;
|
|
136
|
-
let count = 0;
|
|
137
|
-
for (const found of expected.matchAll(PLACEHOLDER_RE)) {
|
|
138
|
-
const kind = found.groups.kind;
|
|
139
|
-
const { ref } = found.groups;
|
|
140
|
-
source += escapeRegExp(expected.slice(lastIndex, found.index));
|
|
141
|
-
source += `(${placeholderSource(kind, scope)})`;
|
|
142
|
-
refs.push({
|
|
143
|
-
index: count,
|
|
144
|
-
kind,
|
|
145
|
-
ref
|
|
146
|
-
});
|
|
147
|
-
count++;
|
|
148
|
-
lastIndex = found.index + found[0].length;
|
|
149
|
-
if (found.index === 0 && found[0].length === expected.length) single = {
|
|
150
|
-
kind,
|
|
151
|
-
ref
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
source += escapeRegExp(expected.slice(lastIndex));
|
|
155
|
-
return {
|
|
156
|
-
pattern: new RegExp(`^${source}$`, "u"),
|
|
157
|
-
refs,
|
|
158
|
-
single,
|
|
159
|
-
source
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
/**
|
|
163
|
-
* Unanchored regex source matching a fixture string — plain text escaped
|
|
164
|
-
* verbatim, each `{{token}}` expanded to its embedded grammar. Used to route
|
|
165
|
-
* declared `.http` intercept paths (`/articles/{{uuid}}`) as URL patterns.
|
|
166
|
-
*/
|
|
167
|
-
function placeholderPatternSource(expected) {
|
|
168
|
-
return parsePlaceholderString(expected, new CaptureScope()).source;
|
|
169
|
-
}
|
|
170
|
-
function placeholderStringMatches(expected, actual, scope) {
|
|
171
|
-
const parsed = parsePlaceholderString(expected, scope);
|
|
172
|
-
if (parsed.single) {
|
|
173
|
-
if (!kindMatches(parsed.single.kind, actual, scope)) return false;
|
|
174
|
-
if (parsed.single.ref) return recordRef(parsed.single.ref, actual, scope);
|
|
175
|
-
return true;
|
|
176
|
-
}
|
|
177
|
-
if (typeof actual !== "string" && typeof actual !== "number") return false;
|
|
178
|
-
const text = String(actual);
|
|
179
|
-
const found = parsed.pattern.exec(text);
|
|
180
|
-
if (!found) return false;
|
|
181
|
-
for (const entry of parsed.refs) {
|
|
182
|
-
if (!entry.ref) continue;
|
|
183
|
-
if (!recordRef(entry.ref, found[entry.index + 1], scope)) return false;
|
|
184
|
-
}
|
|
185
|
-
return true;
|
|
186
|
-
}
|
|
187
|
-
function isPlainObject(value) {
|
|
188
|
-
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* Deep structural equality with matcher / placeholder support. Ref captures
|
|
192
|
-
* are recorded in `scope` in traversal order (arrays left-to-right, object
|
|
193
|
-
* keys in expected-key order).
|
|
194
|
-
*/
|
|
195
|
-
function structuralEquals(expected, actual, scope) {
|
|
196
|
-
if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
|
|
197
|
-
if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
|
|
198
|
-
if (Array.isArray(expected)) {
|
|
199
|
-
if (!Array.isArray(actual) || actual.length !== expected.length) return false;
|
|
200
|
-
return expected.every((item, i) => structuralEquals(item, actual[i], scope));
|
|
201
|
-
}
|
|
202
|
-
if (isPlainObject(expected)) {
|
|
203
|
-
if (!isPlainObject(actual)) return false;
|
|
204
|
-
const expectedKeys = Object.keys(expected);
|
|
205
|
-
const actualKeys = Object.keys(actual);
|
|
206
|
-
if (expectedKeys.length !== actualKeys.length) return false;
|
|
207
|
-
return expectedKeys.every((key) => key in actual && structuralEquals(expected[key], actual[key], scope));
|
|
208
|
-
}
|
|
209
|
-
return Object.is(expected, actual);
|
|
210
|
-
}
|
|
211
|
-
/**
|
|
212
|
-
* Deep structural SUBSET match (toMatchObject-style) with matcher /
|
|
213
|
-
* placeholder support. Plain objects match when every expected key is present
|
|
214
|
-
* and recursively subset-matches — the actual value may carry extra keys.
|
|
215
|
-
* Arrays require equal length with each element subset-matched. Leaves fall
|
|
216
|
-
* back to {@link structuralEquals} semantics (matchers, placeholders, strict
|
|
217
|
-
* equality). Used by request-body filters on intercept triggers.
|
|
218
|
-
*/
|
|
219
|
-
function structuralSubset(expected, actual, scope) {
|
|
220
|
-
if (expected instanceof Matcher) return matcherMatches(expected, actual, scope);
|
|
221
|
-
if (typeof expected === "string" && hasPlaceholders(expected)) return placeholderStringMatches(expected, actual, scope);
|
|
222
|
-
if (Array.isArray(expected)) {
|
|
223
|
-
if (!Array.isArray(actual) || actual.length !== expected.length) return false;
|
|
224
|
-
return expected.every((item, i) => structuralSubset(item, actual[i], scope));
|
|
225
|
-
}
|
|
226
|
-
if (isPlainObject(expected)) {
|
|
227
|
-
if (!isPlainObject(actual)) return false;
|
|
228
|
-
return Object.keys(expected).every((key) => key in actual && structuralSubset(expected[key], actual[key], scope));
|
|
229
|
-
}
|
|
230
|
-
return Object.is(expected, actual);
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Multi-line text comparison with `{{token}}` support — used by text
|
|
234
|
-
* snapshots (`_expected/*.txt`). Without placeholders this is strict equality.
|
|
235
|
-
*/
|
|
236
|
-
function textEquals(expected, actual, scope) {
|
|
237
|
-
if (!hasPlaceholders(expected)) return expected === actual;
|
|
238
|
-
return placeholderStringMatches(expected, actual, scope);
|
|
239
|
-
}
|
|
240
|
-
/**
|
|
241
|
-
* Substring comparison with `{{token}}` support — the `contains` half of the
|
|
242
|
-
* `files:` assertions of a spec document. Without placeholders this is
|
|
243
|
-
* `String.includes`; with them the fixture text becomes an UNANCHORED pattern,
|
|
244
|
-
* so `wrote {{path}}` is found inside a larger file, and its `#ref` captures are
|
|
245
|
-
* recorded exactly as a whole-text comparison records them.
|
|
246
|
-
*/
|
|
247
|
-
function textContains(expected, actual, scope) {
|
|
248
|
-
if (!hasPlaceholders(expected)) return actual.includes(expected);
|
|
249
|
-
const parsed = parsePlaceholderString(expected, scope);
|
|
250
|
-
const found = new RegExp(parsed.source, "u").exec(actual);
|
|
251
|
-
if (!found) return false;
|
|
252
|
-
return parsed.refs.every((entry) => !entry.ref || recordRef(entry.ref, found[entry.index + 1], scope));
|
|
253
|
-
}
|
|
254
|
-
/**
|
|
255
|
-
* Render an expected value for failure diffs and serialization: Matcher
|
|
256
|
-
* instances become their placeholder text, everything else is untouched.
|
|
257
|
-
*/
|
|
258
|
-
function renderExpected(value) {
|
|
259
|
-
if (value instanceof Matcher) return value.toString();
|
|
260
|
-
if (Array.isArray(value)) return value.map((item) => renderExpected(item));
|
|
261
|
-
if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, renderExpected(item)]));
|
|
262
|
-
return value;
|
|
263
|
-
}
|
|
264
|
-
/**
|
|
265
|
-
* Substitute the framework-known cwd (`workdir`) for its `{{workdir}}` token in
|
|
266
|
-
* every string leaf of a structured value. The structural mirror of the text
|
|
267
|
-
* path's `actual.replaceAll(workdir, '{{workdir}}')` — so a golden written under
|
|
268
|
-
* `TEST_UPDATE=1` stores the token, not a run-specific temp path (CONVENTIONS
|
|
269
|
-
* D5). A no-op when `workdir` is undefined (no cwd, e.g. api/jobs mode).
|
|
270
|
-
*/
|
|
271
|
-
function substituteWorkdirDeep(value, workdir) {
|
|
272
|
-
if (typeof value === "string") return value.replaceAll(workdir, "{{workdir}}");
|
|
273
|
-
if (Array.isArray(value)) return value.map((item) => substituteWorkdirDeep(item, workdir));
|
|
274
|
-
if (isPlainObject(value)) return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, substituteWorkdirDeep(item, workdir)]));
|
|
275
|
-
return value;
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Update-mode merge: rewrite a fixture from the actual output while
|
|
279
|
-
* preserving every segment of the previous fixture that was covered by a
|
|
280
|
-
* placeholder (and still matches the actual value).
|
|
281
|
-
*
|
|
282
|
-
* Token preservation is symmetric with the text path (CONVENTIONS D5): a
|
|
283
|
-
* previous `{{placeholder}}` (including `{{workdir}}`) that still matches the
|
|
284
|
-
* RAW actual value is kept; every other leaf is taken from the actual output
|
|
285
|
-
* with the framework-known cwd substituted back to `{{workdir}}`.
|
|
286
|
-
*/
|
|
287
|
-
function mergePreservingPlaceholders(previous, actual, workdir) {
|
|
288
|
-
const scope = new CaptureScope(workdir);
|
|
289
|
-
const subst = (value) => workdir === void 0 ? value : substituteWorkdirDeep(value, workdir);
|
|
290
|
-
function merge(prev, act) {
|
|
291
|
-
if (typeof prev === "string" && hasPlaceholders(prev)) return placeholderStringMatches(prev, act, scope) ? prev : subst(act);
|
|
292
|
-
if (Array.isArray(prev) && Array.isArray(act)) return act.map((item, i) => i < prev.length ? merge(prev[i], item) : subst(item));
|
|
293
|
-
if (isPlainObject(prev) && isPlainObject(act)) return Object.fromEntries(Object.entries(act).map(([key, item]) => [key, key in prev ? merge(prev[key], item) : subst(item)]));
|
|
294
|
-
return subst(act);
|
|
295
|
-
}
|
|
296
|
-
return merge(previous, actual);
|
|
297
|
-
}
|
|
298
|
-
/**
|
|
299
|
-
* Update-mode merge for text snapshots: a previous line whose placeholders
|
|
300
|
-
* still match an actual line is preserved; every other line is taken from the
|
|
301
|
-
* actual output. Values the framework knows to be dynamic (`{{workdir}}`) are
|
|
302
|
-
* substituted automatically (CONVENTIONS D5).
|
|
303
|
-
*
|
|
304
|
-
* Pairing is by **pattern, not by line index**. The aligned line is tried
|
|
305
|
-
* first, so a golden that still lines up comes back byte-identical; what is
|
|
306
|
-
* left over is then re-paired by matching each remaining previous placeholder
|
|
307
|
-
* line against each unresolved actual line, in file order. That is what makes a
|
|
308
|
-
* MID-FILE INSERTION safe: inserting a line shifts everything below it, and the
|
|
309
|
-
* shifted `{{duration}}` / `{{uuid}}` lines are recognised where they landed
|
|
310
|
-
* instead of being rewritten as raw values. Each previous line is used at most
|
|
311
|
-
* once, so two identical placeholder lines cannot collapse into one.
|
|
312
|
-
*/
|
|
313
|
-
function mergeTextPreservingPlaceholders(previous, actual, scope) {
|
|
314
|
-
const substituted = scope.workdir === void 0 ? actual : actual.replaceAll(scope.workdir, "{{workdir}}");
|
|
315
|
-
if (previous === null) return substituted;
|
|
316
|
-
const prevLines = previous.split("\n");
|
|
317
|
-
const actualLines = substituted.split("\n");
|
|
318
|
-
const rawLines = actual.split("\n");
|
|
319
|
-
const consumed = /* @__PURE__ */ new Set();
|
|
320
|
-
const covers = (prevIndex, actualIndex) => {
|
|
321
|
-
const prev = prevLines[prevIndex];
|
|
322
|
-
if (prev === void 0 || !hasPlaceholders(prev)) return false;
|
|
323
|
-
const line = actualLines[actualIndex];
|
|
324
|
-
if (line === void 0) return false;
|
|
325
|
-
return textEquals(prev, line, new CaptureScope(scope.workdir)) || textEquals(prev, rawLines[actualIndex] ?? line, new CaptureScope(scope.workdir));
|
|
326
|
-
};
|
|
327
|
-
const merged = actualLines.map(() => void 0);
|
|
328
|
-
for (const [index] of actualLines.entries()) if (covers(index, index)) {
|
|
329
|
-
merged[index] = prevLines[index];
|
|
330
|
-
consumed.add(index);
|
|
331
|
-
}
|
|
332
|
-
for (const [index, line] of actualLines.entries()) {
|
|
333
|
-
if (merged[index] !== void 0) continue;
|
|
334
|
-
for (const [prevIndex] of prevLines.entries()) {
|
|
335
|
-
if (consumed.has(prevIndex) || !covers(prevIndex, index)) continue;
|
|
336
|
-
merged[index] = prevLines[prevIndex];
|
|
337
|
-
consumed.add(prevIndex);
|
|
338
|
-
break;
|
|
339
|
-
}
|
|
340
|
-
merged[index] ??= line;
|
|
341
|
-
}
|
|
342
|
-
return merged.join("\n");
|
|
343
|
-
}
|
|
344
|
-
//#endregion
|
|
345
|
-
//#region src/model/result/directory.ts
|
|
346
|
-
const DEFAULT_IGNORES = [
|
|
347
|
-
".git",
|
|
348
|
-
".DS_Store",
|
|
349
|
-
"node_modules",
|
|
350
|
-
".next",
|
|
351
|
-
"dist",
|
|
352
|
-
".turbo",
|
|
353
|
-
".cache"
|
|
354
|
-
];
|
|
355
|
-
/**
|
|
356
|
-
* Recursively walk a directory, returning sorted relative paths of files only.
|
|
357
|
-
*/
|
|
358
|
-
async function walkDirectory(root, options = {}) {
|
|
359
|
-
const ignores = /* @__PURE__ */ new Set([...DEFAULT_IGNORES, ...options.ignore ?? []]);
|
|
360
|
-
const out = [];
|
|
361
|
-
async function walk(current) {
|
|
362
|
-
let entries;
|
|
363
|
-
try {
|
|
364
|
-
entries = await readdir(current);
|
|
365
|
-
} catch {
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
for (const entry of entries) {
|
|
369
|
-
if (ignores.has(entry)) continue;
|
|
370
|
-
const abs = resolve(current, entry);
|
|
371
|
-
const stat = statSync(abs);
|
|
372
|
-
if (stat.isDirectory()) await walk(abs);
|
|
373
|
-
else if (stat.isFile()) out.push(relative(root, abs).split(sep).join("/"));
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
await walk(root);
|
|
377
|
-
out.sort();
|
|
378
|
-
return out;
|
|
379
|
-
}
|
|
380
|
-
/**
|
|
381
|
-
* Compare two directory trees file-by-file. Fixture file contents honor the
|
|
382
|
-
* unified `{{token}}` grammar (CONVENTIONS D4) — captures are recorded in
|
|
383
|
-
* `scope` in sorted-path order.
|
|
384
|
-
*/
|
|
385
|
-
async function diffDirectories(expectedRoot, actualRoot, options = {}) {
|
|
386
|
-
const expectedFiles = await walkDirectory(expectedRoot, options);
|
|
387
|
-
const actualFiles = await walkDirectory(actualRoot, options);
|
|
388
|
-
const expectedSet = new Set(expectedFiles);
|
|
389
|
-
const actualSet = new Set(actualFiles);
|
|
390
|
-
const scope = options.scope ?? new CaptureScope();
|
|
391
|
-
const added = actualFiles.filter((f) => !expectedSet.has(f));
|
|
392
|
-
const removed = expectedFiles.filter((f) => !actualSet.has(f));
|
|
393
|
-
const changed = [];
|
|
394
|
-
for (const file of expectedFiles) {
|
|
395
|
-
if (!actualSet.has(file)) continue;
|
|
396
|
-
const expected = readFileSync(resolve(expectedRoot, file), "utf8");
|
|
397
|
-
const actual = readFileSync(resolve(actualRoot, file), "utf8");
|
|
398
|
-
if (!textEquals(expected, actual, scope)) changed.push({
|
|
399
|
-
actual,
|
|
400
|
-
expected,
|
|
401
|
-
path: file
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
return {
|
|
405
|
-
added,
|
|
406
|
-
changed,
|
|
407
|
-
removed
|
|
408
|
-
};
|
|
409
|
-
}
|
|
410
|
-
/**
|
|
411
|
-
* Read-only accessor for a directory produced by a spec action.
|
|
412
|
-
*
|
|
413
|
-
* Assertions go through `expect()` (async — they walk the disk):
|
|
414
|
-
* `await expect(result.directory('out')).toMatch('scaffold/out')`
|
|
415
|
-
* compares the tree against the fixture directory `_expected/<name>/`.
|
|
416
|
-
*/
|
|
417
|
-
var DirectoryAccessor = class {
|
|
418
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
419
|
-
captures;
|
|
420
|
-
/** @internal Absolute path of the directory under assertion. */
|
|
421
|
-
root;
|
|
422
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
423
|
-
testDir;
|
|
424
|
-
constructor(absPath, testDir, captures) {
|
|
425
|
-
this.root = absPath;
|
|
426
|
-
this.testDir = testDir;
|
|
427
|
-
this.captures = captures ?? new CaptureScope();
|
|
428
|
-
}
|
|
429
|
-
/** List all files (recursively) under the directory, sorted. */
|
|
430
|
-
async files(options = {}) {
|
|
431
|
-
return await walkDirectory(this.root, options);
|
|
432
|
-
}
|
|
433
|
-
};
|
|
434
|
-
//#endregion
|
|
435
|
-
//#region src/model/result/filesystem.ts
|
|
436
|
-
/**
|
|
437
|
-
* Read-only accessor for the whole temporary working directory used by a
|
|
438
|
-
* command spec.
|
|
439
|
-
*
|
|
440
|
-
* Assertions go through `expect()` (async — they walk the disk):
|
|
441
|
-
* `await expect(result.filesystem).toMatch('scaffolded')` compares the
|
|
442
|
-
* tree against the fixture directory `_expected/<name>/`.
|
|
443
|
-
*/
|
|
444
|
-
var FilesystemAccessor = class {
|
|
445
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
446
|
-
captures;
|
|
447
|
-
/** The absolute path of the temporary working directory. */
|
|
448
|
-
cwd;
|
|
449
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
450
|
-
testDir;
|
|
451
|
-
constructor(cwd, testDir, captures) {
|
|
452
|
-
this.cwd = cwd;
|
|
453
|
-
this.testDir = testDir;
|
|
454
|
-
this.captures = captures ?? new CaptureScope();
|
|
455
|
-
}
|
|
456
|
-
/** List all files (recursively) under the working directory, sorted. */
|
|
457
|
-
async files(options = {}) {
|
|
458
|
-
return await walkDirectory(this.cwd, options);
|
|
459
|
-
}
|
|
460
|
-
};
|
|
461
|
-
//#endregion
|
|
462
|
-
//#region src/model/result/grep.ts
|
|
463
|
-
/**
|
|
464
|
-
* Extract text blocks from output that contain a pattern.
|
|
465
|
-
* Splits by blank lines (how linter/compiler output is structured),
|
|
466
|
-
* returns only blocks matching the pattern.
|
|
467
|
-
*
|
|
468
|
-
* @example
|
|
469
|
-
* expect(grep(result.stdout, "unused-var.ts")).toContain("no-unused-vars")
|
|
470
|
-
* expect(grep(result.stdout, "valid/sorted.ts")).not.toContain("sort-imports")
|
|
471
|
-
*/
|
|
472
|
-
function grep(output, pattern) {
|
|
473
|
-
return output.replaceAll(/\u001B\[[0-9;]*m/gu, "").split(/\n\s*\n/u).filter((block) => block.includes(pattern)).join("\n\n");
|
|
474
|
-
}
|
|
475
|
-
//#endregion
|
|
476
|
-
//#region src/model/result/text.ts
|
|
477
|
-
const ANSI_RE = /\u001B\[[0-9;]*m/gu;
|
|
478
|
-
/** Strip ANSI escape sequences from a string. */
|
|
479
|
-
function stripAnsiCodes(value) {
|
|
480
|
-
return value.replace(ANSI_RE, "");
|
|
481
|
-
}
|
|
482
|
-
/**
|
|
483
|
-
* Read-only accessor for a captured text handle — THE universal one: stdout,
|
|
484
|
-
* stderr, container logs, and file text all surface as a `TextAccessor`.
|
|
485
|
-
*
|
|
486
|
-
* Backed by a primitive string, exposed via {@link text}, but also provides
|
|
487
|
-
* `toString()` / `valueOf()` for string coercion, so common patterns like
|
|
488
|
-
* `String(result.stdout)` and template-literal interpolation still work.
|
|
489
|
-
*
|
|
490
|
-
* ANSI escape sequences are stripped by default before every comparison
|
|
491
|
-
* (CONVENTIONS D6) — `.text` stays raw. The runner `transform` option remains
|
|
492
|
-
* an escape hatch for application noise not covered by the `{{token}}`
|
|
493
|
-
* grammar.
|
|
494
|
-
*
|
|
495
|
-
* Text operations are **closed** over the type: `.grep(pattern)` returns a
|
|
496
|
-
* `TextAccessor` (not a bare string), preserving the `_expected/`-resolution
|
|
497
|
-
* context and the `transform`, so results are chainable
|
|
498
|
-
* (`result.stdout.grep(a).grep(b)`) and snapshot-able
|
|
499
|
-
* (`expect(result.stdout.grep('users.ts')).toMatch('block.txt')`).
|
|
500
|
-
*
|
|
501
|
-
* Assertions go through `expect()` matchers: `expect(result.stdout).toContain(...)`
|
|
502
|
-
* and `expect(result.stdout).toMatch('name.txt')` (resolved against
|
|
503
|
-
* `_expected/<name>`, flat — with `{{token}}` support, CONVENTIONS D4).
|
|
504
|
-
*/
|
|
505
|
-
var TextAccessor = class TextAccessor {
|
|
506
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
507
|
-
captures;
|
|
508
|
-
/** @internal Stream label used in failure messages ("stdout" / "stderr"). */
|
|
509
|
-
streamName;
|
|
510
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
511
|
-
testDir;
|
|
512
|
-
/** The raw captured text (never transformed, ANSI preserved). */
|
|
513
|
-
text;
|
|
514
|
-
/** @internal Normaliser applied before comparisons, never to fixtures. */
|
|
515
|
-
transform;
|
|
516
|
-
constructor(value, streamName, testDir, options = {}) {
|
|
517
|
-
this.text = value;
|
|
518
|
-
this.streamName = streamName;
|
|
519
|
-
this.testDir = testDir;
|
|
520
|
-
this.transform = options.transform;
|
|
521
|
-
this.captures = options.captures ?? new CaptureScope();
|
|
522
|
-
}
|
|
523
|
-
/** @internal The text as compared by matchers — ANSI stripped, transform applied. */
|
|
524
|
-
get comparableText() {
|
|
525
|
-
const stripped = stripAnsiCodes(this.text);
|
|
526
|
-
return this.transform ? this.transform(stripped) : stripped;
|
|
527
|
-
}
|
|
528
|
-
/**
|
|
529
|
-
* Keep only the blank-line-separated blocks of the text that contain
|
|
530
|
-
* `pattern` (how linter/compiler output is structured), returned as a new
|
|
531
|
-
* `TextAccessor` — the same subject type, so the result is chainable and
|
|
532
|
-
* snapshot-able. The scalpel for probing large tool outputs; snapshot the
|
|
533
|
-
* whole surface by default, reach for `.grep()` for targeted checks.
|
|
534
|
-
*/
|
|
535
|
-
grep(pattern) {
|
|
536
|
-
return new TextAccessor(grep(this.text, pattern), this.streamName, this.testDir, {
|
|
537
|
-
captures: this.captures,
|
|
538
|
-
transform: this.transform
|
|
539
|
-
});
|
|
540
|
-
}
|
|
541
|
-
toString() {
|
|
542
|
-
return this.text;
|
|
543
|
-
}
|
|
544
|
-
valueOf() {
|
|
545
|
-
return this.text;
|
|
546
|
-
}
|
|
547
|
-
};
|
|
548
|
-
//#endregion
|
|
549
|
-
//#region src/model/result/json.ts
|
|
550
|
-
/**
|
|
551
|
-
* Read-only accessor for a JSON payload parsed from a text stream (stdout).
|
|
552
|
-
*
|
|
553
|
-
* Lazily parses the JSON on first use; parse errors are deferred until the
|
|
554
|
-
* value is read so that callers can still read the raw stream text without
|
|
555
|
-
* triggering a throw.
|
|
556
|
-
*
|
|
557
|
-
* ANSI escape sequences are stripped by default before `JSON.parse`
|
|
558
|
-
* (CONVENTIONS D6). When a `transform` is configured on the spec runner, it
|
|
559
|
-
* runs on the stripped text — an escape hatch for application noise not
|
|
560
|
-
* covered by the `{{token}}` grammar.
|
|
561
|
-
*
|
|
562
|
-
* Assertions go through `expect()`: `expect(result.json).toMatch('name.json')`
|
|
563
|
-
* (deep-equal against `_expected/<name>`, with `{{placeholder}}` support).
|
|
564
|
-
*/
|
|
565
|
-
var JsonAccessor = class {
|
|
566
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
567
|
-
captures;
|
|
568
|
-
/** @internal */
|
|
569
|
-
rawText;
|
|
570
|
-
/** @internal Test-file directory — fixture resolution root for matchers. */
|
|
571
|
-
testDir;
|
|
572
|
-
/** @internal */
|
|
573
|
-
transform;
|
|
574
|
-
constructor(rawText, testDir, transform, captures) {
|
|
575
|
-
this.rawText = rawText;
|
|
576
|
-
this.testDir = testDir;
|
|
577
|
-
this.transform = transform;
|
|
578
|
-
this.captures = captures ?? new CaptureScope();
|
|
579
|
-
}
|
|
580
|
-
/**
|
|
581
|
-
* The parsed JSON value. Throws if the text is not valid JSON.
|
|
582
|
-
*
|
|
583
|
-
* `Value` is `unknown` unless the subject that produced this accessor
|
|
584
|
-
* knows better — `.call<T>()` does, and hands its T straight through so a
|
|
585
|
-
* one-field reading needs no cast in the test.
|
|
586
|
-
*/
|
|
587
|
-
get value() {
|
|
588
|
-
const stripped = stripAnsiCodes(this.rawText);
|
|
589
|
-
const source = this.transform ? this.transform(stripped) : stripped;
|
|
590
|
-
try {
|
|
591
|
-
return JSON.parse(source);
|
|
592
|
-
} catch {
|
|
593
|
-
const preview = source.slice(0, 200);
|
|
594
|
-
throw new Error(`stdout is not valid JSON: ${preview}`);
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
};
|
|
598
|
-
//#endregion
|
|
599
|
-
//#region src/model/chain/resolve.ts
|
|
600
|
-
/** What makes a directory a project root: the thing that declares itself one. */
|
|
601
|
-
const ROOT_MARKER = "package.json";
|
|
602
|
-
/**
|
|
603
|
-
* The NEAREST ancestor of `startDir` (itself included) carrying the root
|
|
604
|
-
* marker, `undefined` when none does.
|
|
605
|
-
*
|
|
606
|
-
* One marker, and it is the nearest thing that declares itself a project: a
|
|
607
|
-
* second marker higher in the tree would outrank the `package.json` of the very
|
|
608
|
-
* package being tested, and every path the runner resolves would be measured
|
|
609
|
-
* from the wrong unit.
|
|
610
|
-
*
|
|
611
|
-
* The existence probe is injected so the lint layer can pass its cached one
|
|
612
|
-
* (A9's `a9w-redundant-root` derives the same root, and must derive it the
|
|
613
|
-
* same way).
|
|
614
|
-
*/
|
|
615
|
-
function findRoot(startDir, exists) {
|
|
616
|
-
let dir = startDir;
|
|
617
|
-
for (;;) {
|
|
618
|
-
if (exists(resolve(dir, ROOT_MARKER))) return dir;
|
|
619
|
-
const parent = dirname(dir);
|
|
620
|
-
if (parent === dir) return;
|
|
621
|
-
dir = parent;
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
/**
|
|
625
|
-
* Auto-discover the project root from a starting directory (CONVENTIONS A9):
|
|
626
|
-
* walk up to the first directory that carries `package.json`; if none does,
|
|
627
|
-
* the starting directory itself.
|
|
628
|
-
*/
|
|
629
|
-
function discoverRoot(startDir) {
|
|
630
|
-
return findRoot(startDir, existsSync) ?? startDir;
|
|
631
|
-
}
|
|
632
|
-
/**
|
|
633
|
-
* Resolve the project root for a specification. An explicit `root` option is
|
|
634
|
-
* an override (resolved from the caller's directory when relative); when
|
|
635
|
-
* absent, the root is auto-discovered by walking up from the calling
|
|
636
|
-
* specification file.
|
|
637
|
-
*/
|
|
638
|
-
function resolveRoot(root, callerDir) {
|
|
639
|
-
if (!root) return discoverRoot(callerDir);
|
|
640
|
-
if (isAbsolute(root)) return root;
|
|
641
|
-
return resolve(callerDir, root);
|
|
642
|
-
}
|
|
643
|
-
/**
|
|
644
|
-
* Resolve a command — checks node_modules/.bin, then treats as absolute/PATH.
|
|
645
|
-
*/
|
|
646
|
-
function resolveCommand(command, root) {
|
|
647
|
-
if (isAbsolute(command)) return command;
|
|
648
|
-
const binPath = resolve(root, "node_modules/.bin", command);
|
|
649
|
-
if (existsSync(binPath)) return binPath;
|
|
650
|
-
const cwdBinPath = resolve(process.cwd(), "node_modules/.bin", command);
|
|
651
|
-
if (existsSync(cwdBinPath)) return cwdBinPath;
|
|
652
|
-
return command;
|
|
653
|
-
}
|
|
654
|
-
/**
|
|
655
|
-
* The realpath form of a working directory — what a child process prints as
|
|
656
|
-
* `$PWD` (macOS resolves the tmpdir symlink), and therefore what `{{workdir}}`
|
|
657
|
-
* compares against. Falls back to the path itself when it cannot be resolved.
|
|
658
|
-
*
|
|
659
|
-
* @internal Shared by the result scope, the literate runner and `$WORKDIR`.
|
|
660
|
-
*/
|
|
661
|
-
function safeRealpath(path) {
|
|
662
|
-
try {
|
|
663
|
-
return realpathSync(path);
|
|
664
|
-
} catch {
|
|
665
|
-
return path;
|
|
666
|
-
}
|
|
667
|
-
}
|
|
668
|
-
/**
|
|
669
|
-
* Expand the `$WORKDIR` token in every string value of a child environment —
|
|
670
|
-
* the one place the token is spelled out, for both doors (`.env()` on the
|
|
671
|
-
* chain, `env:` in a `<case>.spec.yaml`).
|
|
672
|
-
*
|
|
673
|
-
* It expands to the RESOLVED directory, never the raw temp path: a value the
|
|
674
|
-
* child receives comes back in its output, and it must come back spelled the
|
|
675
|
-
* way `{{workdir}}` holds it (realpath form, CONVENTIONS D4). Expanding the raw
|
|
676
|
-
* path made `HOME=$WORKDIR` on macOS echo `/var/folders/…` against a
|
|
677
|
-
* `{{workdir}}` holding `/private/var/folders/…` — a golden no run could match.
|
|
678
|
-
*/
|
|
679
|
-
function expandWorkdir(env, workDir) {
|
|
680
|
-
const workdir = safeRealpath(workDir);
|
|
681
|
-
const expanded = {};
|
|
682
|
-
for (const [key, value] of Object.entries(env)) expanded[key] = typeof value === "string" ? value.replaceAll("$WORKDIR", workdir) : value;
|
|
683
|
-
return expanded;
|
|
684
|
-
}
|
|
685
|
-
//#endregion
|
|
686
|
-
//#region src/model/result/table.ts
|
|
687
|
-
/**
|
|
688
|
-
* Read-only accessor for a database table after a specification run.
|
|
689
|
-
*
|
|
690
|
-
* Assertions go through `expect()` (async — they query the database):
|
|
691
|
-
*
|
|
692
|
-
* await expect(result.table('users', { database: 'db' })).toMatchRows({
|
|
693
|
-
* columns: ['name'],
|
|
694
|
-
* rows: [['Alice']],
|
|
695
|
-
* });
|
|
696
|
-
* await expect(result.table('users', { database: 'db' })).toBeEmpty();
|
|
697
|
-
*/
|
|
698
|
-
var TableAccessor = class {
|
|
699
|
-
/** @internal Ref-capture scope shared by the current spec execution. */
|
|
700
|
-
captures;
|
|
701
|
-
/** @internal */
|
|
702
|
-
db;
|
|
703
|
-
/** The table name this accessor reads. */
|
|
704
|
-
name;
|
|
705
|
-
constructor(name, db, captures) {
|
|
706
|
-
this.name = name;
|
|
707
|
-
this.db = db;
|
|
708
|
-
this.captures = captures ?? new CaptureScope();
|
|
709
|
-
}
|
|
710
|
-
/** @internal Query the table — used by the toMatchRows / toBeEmpty matchers. */
|
|
711
|
-
async query(columns) {
|
|
712
|
-
return await this.db.query(this.name, columns);
|
|
713
|
-
}
|
|
714
|
-
};
|
|
715
|
-
//#endregion
|
|
716
|
-
//#region src/model/result/result.ts
|
|
717
|
-
/**
|
|
718
|
-
* Base result - common accessors available after any action type.
|
|
719
|
-
* Extended by HttpResult, CliResult, and used directly by job results.
|
|
720
|
-
*
|
|
721
|
-
* Every result carries a fresh {@link CaptureScope}: `match.ref()` captures
|
|
722
|
-
* and `{{type#ref}}` placeholders are scoped to one spec execution.
|
|
723
|
-
*/
|
|
724
|
-
var BaseResult = class {
|
|
725
|
-
/** @internal Ref-capture scope shared by every assertion on this result. */
|
|
726
|
-
captures;
|
|
727
|
-
config;
|
|
728
|
-
testDir;
|
|
729
|
-
workDir;
|
|
730
|
-
constructor(options) {
|
|
731
|
-
this.config = options.config;
|
|
732
|
-
this.testDir = options.testDir;
|
|
733
|
-
this.workDir = options.workDir;
|
|
734
|
-
this.captures = new CaptureScope(options.workDir ? safeRealpath(options.workDir) : void 0);
|
|
735
|
-
}
|
|
736
|
-
/** Access a directory (relative to the working directory) for snapshot assertions. */
|
|
737
|
-
directory(path = ".") {
|
|
738
|
-
const baseDir = this.workDir ?? this.testDir;
|
|
739
|
-
return new DirectoryAccessor(resolve(baseDir, path), this.testDir, this.captures);
|
|
740
|
-
}
|
|
741
|
-
/** Access a single file (relative to the working directory) for content assertions. */
|
|
742
|
-
file(path) {
|
|
743
|
-
const baseDir = this.workDir ?? this.testDir;
|
|
744
|
-
const resolvedPath = resolve(baseDir, path);
|
|
745
|
-
const exists = existsSync(resolvedPath);
|
|
746
|
-
const { captures, testDir } = this;
|
|
747
|
-
return {
|
|
748
|
-
get content() {
|
|
749
|
-
if (!exists) throw new Error(`File not found: ${path}`);
|
|
750
|
-
return readFileSync(resolvedPath, "utf8");
|
|
751
|
-
},
|
|
752
|
-
exists,
|
|
753
|
-
grep(pattern) {
|
|
754
|
-
if (!exists) throw new Error(`File not found: ${path}`);
|
|
755
|
-
return new TextAccessor(readFileSync(resolvedPath, "utf8"), path, testDir, { captures }).grep(pattern);
|
|
756
|
-
}
|
|
757
|
-
};
|
|
758
|
-
}
|
|
759
|
-
/** Access a database table for row-level assertions via expect() matchers. */
|
|
760
|
-
table(tableName, options) {
|
|
761
|
-
validateDatabaseOption("table", this.config, options?.database);
|
|
762
|
-
const db = this.resolveDatabase(options?.database);
|
|
763
|
-
if (!db) throw new Error(options?.database ? `table("${tableName}") requires database "${options.database}" but it was not found` : `table("${tableName}") requires a database adapter`);
|
|
764
|
-
return new TableAccessor(tableName, db, this.captures);
|
|
765
|
-
}
|
|
766
|
-
resolveDatabase(databaseKey) {
|
|
767
|
-
if (databaseKey && this.config.databases) return this.config.databases.get(databaseKey);
|
|
768
|
-
return this.config.database;
|
|
769
|
-
}
|
|
770
|
-
};
|
|
771
|
-
/**
|
|
772
|
-
* Enforce CONVENTIONS A7: with two or more databases in the services record
|
|
773
|
-
* the `database` option is mandatory on `.seed()` / `.table()`; with exactly
|
|
774
|
-
* one it is forbidden (redundant).
|
|
775
|
-
*
|
|
776
|
-
* @internal
|
|
777
|
-
*/
|
|
778
|
-
function validateDatabaseOption(method, config, database) {
|
|
779
|
-
const keys = config.databaseKeys;
|
|
780
|
-
if (!keys) return;
|
|
781
|
-
if (keys.length >= 2 && !database) throw new Error(`${method}(): ${keys.length} databases are declared (${keys.map((k) => `"${k}"`).join(", ")}) — pass { database: <key> } to target one of them.`);
|
|
782
|
-
if (keys.length === 1 && database) throw new Error(`${method}(): redundant database option — "${keys[0]}" is the only declared database.`);
|
|
783
|
-
}
|
|
784
|
-
//#endregion
|
|
785
|
-
//#region src/seams/docker/docker-lookup.ts
|
|
786
|
-
/**
|
|
787
|
-
* Thin synchronous wrappers around the `docker` CLI used by the docker() spec
|
|
788
|
-
* mode. Sync is deliberate — the calls are fast, run only on the host, and
|
|
789
|
-
* keep the dispose path straightforward (`Symbol.asyncDispose` can still be
|
|
790
|
-
* async without needing these to be).
|
|
791
|
-
*/
|
|
792
|
-
const DEFAULT_TIMEOUT = 1e4;
|
|
793
|
-
/** Return all container IDs (running or stopped) that carry `key=value`. */
|
|
794
|
-
function findContainersByLabel(key, value) {
|
|
795
|
-
try {
|
|
796
|
-
return execSync(`docker ps -aq -f label=${key}=${value}`, {
|
|
797
|
-
encoding: "utf8",
|
|
798
|
-
timeout: DEFAULT_TIMEOUT
|
|
799
|
-
}).split("\n").map((line) => line.trim()).filter((line) => line.length > 0);
|
|
800
|
-
} catch {
|
|
801
|
-
return [];
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
/** Return the raw `docker inspect` payload (object, not array) for a container. */
|
|
805
|
-
function inspectContainer(id) {
|
|
806
|
-
const raw = execSync(`docker inspect ${id}`, {
|
|
807
|
-
encoding: "utf8",
|
|
808
|
-
timeout: DEFAULT_TIMEOUT
|
|
809
|
-
});
|
|
810
|
-
const parsed = JSON.parse(raw);
|
|
811
|
-
return Array.isArray(parsed) ? parsed[0] : parsed;
|
|
812
|
-
}
|
|
813
|
-
/** Force-remove the given container IDs in a single call. Errors are swallowed. */
|
|
814
|
-
function removeContainers(ids) {
|
|
815
|
-
if (ids.length === 0) return;
|
|
816
|
-
try {
|
|
817
|
-
execSync(`docker rm -f ${ids.join(" ")}`, {
|
|
818
|
-
encoding: "utf8",
|
|
819
|
-
stdio: "pipe",
|
|
820
|
-
timeout: DEFAULT_TIMEOUT
|
|
821
|
-
});
|
|
822
|
-
} catch {}
|
|
823
|
-
}
|
|
824
|
-
//#endregion
|
|
825
|
-
//#region src/facets/cli/cli.result.ts
|
|
826
|
-
/**
|
|
827
|
-
* Result from a command action (`.exec()`).
|
|
828
|
-
*
|
|
829
|
-
* When the runner was configured with a `docker` option, the result also
|
|
830
|
-
* exposes container accessors and participates in `Symbol.asyncDispose`
|
|
831
|
-
* cleanup. The Docker shell-outs are **lazy**: a test that never calls
|
|
832
|
-
* `.container(...)` never queries the Docker daemon, so command-only tests
|
|
833
|
-
* pay zero Docker cost even when the runner is Docker-aware.
|
|
834
|
-
*
|
|
835
|
-
* Dispose always runs one final label-filtered `docker ps` to catch
|
|
836
|
-
* containers that were spawned during `.exec` but never asserted on —
|
|
837
|
-
* tests still get leak-free cleanup even if they forget to reach into
|
|
838
|
-
* a container.
|
|
839
|
-
*/
|
|
840
|
-
var CliResult = class extends BaseResult {
|
|
841
|
-
commandOutput;
|
|
842
|
-
containersCache = null;
|
|
843
|
-
dockerConfig;
|
|
844
|
-
testRunId;
|
|
845
|
-
transform;
|
|
846
|
-
constructor(options) {
|
|
847
|
-
super(options);
|
|
848
|
-
this.commandOutput = options.commandOutput;
|
|
849
|
-
this.dockerConfig = options.dockerConfig;
|
|
850
|
-
this.testRunId = options.testRunId;
|
|
851
|
-
this.transform = options.transform;
|
|
852
|
-
}
|
|
853
|
-
/** The process exit code. */
|
|
854
|
-
get exitCode() {
|
|
855
|
-
return this.commandOutput.exitCode;
|
|
856
|
-
}
|
|
857
|
-
/** Accessor for the captured standard output with file-based assertions. */
|
|
858
|
-
get stdout() {
|
|
859
|
-
return new TextAccessor(this.commandOutput.stdout, "stdout", this.testDir, {
|
|
860
|
-
captures: this.captures,
|
|
861
|
-
transform: this.transform
|
|
862
|
-
});
|
|
863
|
-
}
|
|
864
|
-
/** Accessor for the captured standard error with file-based assertions. */
|
|
865
|
-
get stderr() {
|
|
866
|
-
return new TextAccessor(this.commandOutput.stderr, "stderr", this.testDir, {
|
|
867
|
-
captures: this.captures,
|
|
868
|
-
transform: this.transform
|
|
869
|
-
});
|
|
870
|
-
}
|
|
871
|
-
/** Accessor for parsing stdout as JSON and asserting against JSON fixtures. */
|
|
872
|
-
get json() {
|
|
873
|
-
return new JsonAccessor(this.commandOutput.stdout, this.testDir, this.transform, this.captures);
|
|
874
|
-
}
|
|
875
|
-
/** Accessor for the temporary working directory the command ran in. */
|
|
876
|
-
get filesystem() {
|
|
877
|
-
if (!this.workDir) throw new Error("CliResult.filesystem requires a working directory");
|
|
878
|
-
return new FilesystemAccessor(this.workDir, this.testDir, this.captures);
|
|
879
|
-
}
|
|
880
|
-
/**
|
|
881
|
-
* Look up a container the command spawned during this run by the value of
|
|
882
|
-
* its name-label (as declared in the `docker.nameLabel` of the
|
|
883
|
-
* `specification.cli()` options).
|
|
884
|
-
* First access triggers a one-shot `docker ps` + `docker inspect`
|
|
885
|
-
* query and caches the result for the rest of the result's lifetime.
|
|
886
|
-
* Tests that don't call this never touch Docker.
|
|
887
|
-
*
|
|
888
|
-
* Returns an accessor with `.exists === false` when no container
|
|
889
|
-
* carries that name — callers can still assert absence without a try.
|
|
890
|
-
*/
|
|
891
|
-
container(name) {
|
|
892
|
-
this.ensureDockerAware("container");
|
|
893
|
-
this.loadContainers();
|
|
894
|
-
const captured = this.containersCache.get(name);
|
|
895
|
-
if (!captured) return new ContainerAccessor(null, null, this.testDir, this.transform);
|
|
896
|
-
return new ContainerAccessor(captured.id, captured.inspect, this.testDir, this.transform);
|
|
897
|
-
}
|
|
898
|
-
/** All captured container IDs. Triggers the same lazy query as `container()`. */
|
|
899
|
-
get containerIds() {
|
|
900
|
-
this.ensureDockerAware("containerIds");
|
|
901
|
-
this.loadContainers();
|
|
902
|
-
return [...this.containersCache.values()].map((c) => c.id);
|
|
903
|
-
}
|
|
904
|
-
async [Symbol.asyncDispose]() {
|
|
905
|
-
if (!this.dockerConfig || !this.testRunId) return;
|
|
906
|
-
removeContainers(this.containersCache === null ? findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId) : [...this.containersCache.values()].map((c) => c.id));
|
|
907
|
-
}
|
|
908
|
-
ensureDockerAware(member) {
|
|
909
|
-
if (!this.dockerConfig || !this.testRunId) throw new Error(`CliResult.${member}: runner was not configured with a docker option. Pass \`docker: { envVar, nameLabel, testRunLabel }\` in the specification.cli() options.`);
|
|
910
|
-
}
|
|
911
|
-
loadContainers() {
|
|
912
|
-
if (this.containersCache !== null) return;
|
|
913
|
-
const ids = findContainersByLabel(this.dockerConfig.testRunLabel, this.testRunId);
|
|
914
|
-
const map = /* @__PURE__ */ new Map();
|
|
915
|
-
for (const id of ids) {
|
|
916
|
-
let inspect;
|
|
917
|
-
try {
|
|
918
|
-
inspect = inspectContainer(id);
|
|
919
|
-
} catch {
|
|
920
|
-
continue;
|
|
921
|
-
}
|
|
922
|
-
const nameValue = (inspect?.Config?.Labels ?? inspect?.config?.labels ?? {})[this.dockerConfig.nameLabel];
|
|
923
|
-
const key = typeof nameValue === "string" && nameValue.length > 0 ? nameValue : id;
|
|
924
|
-
map.set(key, {
|
|
925
|
-
id,
|
|
926
|
-
inspect
|
|
927
|
-
});
|
|
928
|
-
}
|
|
929
|
-
this.containersCache = map;
|
|
930
|
-
}
|
|
931
|
-
};
|
|
932
|
-
//#endregion
|
|
933
|
-
//#region src/seams/docker/container-accessor.ts
|
|
934
|
-
const EXEC_TIMEOUT = 1e4;
|
|
935
|
-
function readInspectState(inspect) {
|
|
936
|
-
const state = inspect?.State ?? inspect?.state;
|
|
937
|
-
if (!state) return {
|
|
938
|
-
running: false,
|
|
939
|
-
status: "unknown"
|
|
940
|
-
};
|
|
941
|
-
return {
|
|
942
|
-
running: Boolean(state.Running ?? state.running ?? false),
|
|
943
|
-
status: String(state.Status ?? state.status ?? "unknown")
|
|
944
|
-
};
|
|
945
|
-
}
|
|
946
|
-
/**
|
|
947
|
-
* Assertion accessor for a single Docker container captured by the docker()
|
|
948
|
-
* spec mode. Mirrors the shape of {@link CliResult} so tests use the same
|
|
949
|
-
* vocabulary (`stdout.toContain`, `file(...).content`, etc.) regardless
|
|
950
|
-
* of where output came from.
|
|
951
|
-
*
|
|
952
|
-
* Sync state (`exists`, `running`, `status`) is derived from the `docker
|
|
953
|
-
* inspect` payload captured at result-construction time. Logs are fetched
|
|
954
|
-
* lazily on first access to `stdout`/`stderr`.
|
|
955
|
-
*/
|
|
956
|
-
var ContainerAccessor = class {
|
|
957
|
-
cachedLogs = null;
|
|
958
|
-
containerId;
|
|
959
|
-
exists;
|
|
960
|
-
inspectData;
|
|
961
|
-
running;
|
|
962
|
-
status;
|
|
963
|
-
testDir;
|
|
964
|
-
transform;
|
|
965
|
-
/**
|
|
966
|
-
* Underlying Docker container ID, or `null` if no container was captured
|
|
967
|
-
* for this name. Useful when a follow-up CLI command needs to reference
|
|
968
|
-
* the container by id (e.g. `spwn world inspect <id>`). Prefer the other
|
|
969
|
-
* accessors for common reads — pulling the raw id is the escape hatch.
|
|
970
|
-
*/
|
|
971
|
-
get id() {
|
|
972
|
-
return this.containerId;
|
|
973
|
-
}
|
|
974
|
-
constructor(containerId, inspectData, testDir, transform) {
|
|
975
|
-
this.containerId = containerId;
|
|
976
|
-
this.inspectData = inspectData;
|
|
977
|
-
this.testDir = testDir;
|
|
978
|
-
this.transform = transform;
|
|
979
|
-
this.exists = containerId !== null;
|
|
980
|
-
if (this.exists) {
|
|
981
|
-
const state = readInspectState(inspectData);
|
|
982
|
-
this.running = state.running;
|
|
983
|
-
this.status = state.status;
|
|
984
|
-
} else {
|
|
985
|
-
this.running = false;
|
|
986
|
-
this.status = "absent";
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
/**
|
|
990
|
-
* Raw `docker inspect` object for this container. Throws if the container
|
|
991
|
-
* was not captured (i.e. `.exists === false`).
|
|
992
|
-
*/
|
|
993
|
-
get inspect() {
|
|
994
|
-
this.requireExists("inspect");
|
|
995
|
-
return new JsonAccessor(JSON.stringify(this.inspectData), this.testDir, this.transform);
|
|
996
|
-
}
|
|
997
|
-
/** Captured logs (stdout+stderr combined for v1). */
|
|
998
|
-
get stdout() {
|
|
999
|
-
this.requireExists("stdout");
|
|
1000
|
-
return new TextAccessor(this.loadLogs(), "stdout", this.testDir, { transform: this.transform });
|
|
1001
|
-
}
|
|
1002
|
-
/** Captured logs (stdout+stderr combined for v1). */
|
|
1003
|
-
get stderr() {
|
|
1004
|
-
this.requireExists("stderr");
|
|
1005
|
-
return new TextAccessor(this.loadLogs(), "stderr", this.testDir, { transform: this.transform });
|
|
1006
|
-
}
|
|
1007
|
-
/**
|
|
1008
|
-
* Read a file from inside the container via `docker exec cat`. The
|
|
1009
|
-
* returned object satisfies the same {@link FileAccessor} shape used by
|
|
1010
|
-
* the host filesystem accessor, so tests do not need to learn a new API.
|
|
1011
|
-
*/
|
|
1012
|
-
file(path) {
|
|
1013
|
-
this.requireExists("file");
|
|
1014
|
-
const id = this.containerId;
|
|
1015
|
-
const exists = this.containerExec(id, [
|
|
1016
|
-
"test",
|
|
1017
|
-
"-e",
|
|
1018
|
-
path
|
|
1019
|
-
]).exitCode === 0;
|
|
1020
|
-
const { testDir } = this;
|
|
1021
|
-
const readContent = () => {
|
|
1022
|
-
if (!exists) throw new Error(`File not found in container: ${path}`);
|
|
1023
|
-
return execSync(`docker exec ${id} cat ${JSON.stringify(path)}`, {
|
|
1024
|
-
encoding: "utf8",
|
|
1025
|
-
timeout: EXEC_TIMEOUT
|
|
1026
|
-
});
|
|
1027
|
-
};
|
|
1028
|
-
return {
|
|
1029
|
-
get content() {
|
|
1030
|
-
return readContent();
|
|
1031
|
-
},
|
|
1032
|
-
exists,
|
|
1033
|
-
grep(pattern) {
|
|
1034
|
-
return new TextAccessor(readContent(), path, testDir).grep(pattern);
|
|
1035
|
-
}
|
|
1036
|
-
};
|
|
1037
|
-
}
|
|
1038
|
-
/**
|
|
1039
|
-
* Run a shell command inside the container and get back the same
|
|
1040
|
-
* {@link CliResult} used for host-side executions.
|
|
1041
|
-
*/
|
|
1042
|
-
async exec(cmd) {
|
|
1043
|
-
this.requireExists("exec");
|
|
1044
|
-
return new CliResult({
|
|
1045
|
-
commandOutput: this.containerExec(this.containerId, [
|
|
1046
|
-
"sh",
|
|
1047
|
-
"-c",
|
|
1048
|
-
cmd
|
|
1049
|
-
]),
|
|
1050
|
-
config: {},
|
|
1051
|
-
testDir: this.testDir,
|
|
1052
|
-
transform: this.transform,
|
|
1053
|
-
workDir: this.testDir
|
|
1054
|
-
});
|
|
1055
|
-
}
|
|
1056
|
-
requireExists(member) {
|
|
1057
|
-
if (!this.exists) throw new Error(`ContainerAccessor.${member}: container does not exist (check .exists first)`);
|
|
1058
|
-
}
|
|
1059
|
-
loadLogs() {
|
|
1060
|
-
if (this.cachedLogs !== null) return this.cachedLogs;
|
|
1061
|
-
try {
|
|
1062
|
-
const out = execSync(`docker logs ${this.containerId}`, {
|
|
1063
|
-
encoding: "utf8",
|
|
1064
|
-
stdio: [
|
|
1065
|
-
"ignore",
|
|
1066
|
-
"pipe",
|
|
1067
|
-
"pipe"
|
|
1068
|
-
],
|
|
1069
|
-
timeout: EXEC_TIMEOUT
|
|
1070
|
-
});
|
|
1071
|
-
this.cachedLogs = out;
|
|
1072
|
-
} catch (error) {
|
|
1073
|
-
this.cachedLogs = typeof error?.stdout === "string" ? error.stdout : String(error?.stderr ?? "");
|
|
1074
|
-
}
|
|
1075
|
-
return this.cachedLogs ?? "";
|
|
1076
|
-
}
|
|
1077
|
-
containerExec(id, argv) {
|
|
1078
|
-
try {
|
|
1079
|
-
const quoted = argv.map((a) => JSON.stringify(a)).join(" ");
|
|
1080
|
-
return {
|
|
1081
|
-
exitCode: 0,
|
|
1082
|
-
stderr: "",
|
|
1083
|
-
stdout: execSync(`docker exec ${id} ${quoted}`, {
|
|
1084
|
-
encoding: "utf8",
|
|
1085
|
-
stdio: [
|
|
1086
|
-
"ignore",
|
|
1087
|
-
"pipe",
|
|
1088
|
-
"pipe"
|
|
1089
|
-
],
|
|
1090
|
-
timeout: EXEC_TIMEOUT
|
|
1091
|
-
})
|
|
1092
|
-
};
|
|
1093
|
-
} catch (error) {
|
|
1094
|
-
return {
|
|
1095
|
-
exitCode: typeof error?.status === "number" ? error.status : 1,
|
|
1096
|
-
stderr: typeof error?.stderr === "string" ? error.stderr : String(error?.message ?? ""),
|
|
1097
|
-
stdout: typeof error?.stdout === "string" ? error.stdout : ""
|
|
1098
|
-
};
|
|
1099
|
-
}
|
|
1100
|
-
}
|
|
1101
|
-
};
|
|
1102
|
-
//#endregion
|
|
1103
|
-
//#region src/model/chain/reporter.ts
|
|
1104
|
-
const GREEN = "\x1B[32m";
|
|
1105
|
-
const RED = "\x1B[31m";
|
|
1106
|
-
const DIM = "\x1B[2m";
|
|
1107
|
-
const BOLD = "\x1B[1m";
|
|
1108
|
-
const RESET = "\x1B[0m";
|
|
1109
|
-
const BG_CYAN = "\x1B[46m";
|
|
1110
|
-
const BLACK = "\x1B[30m";
|
|
1111
|
-
const CHECK = "✓";
|
|
1112
|
-
const CROSS = "×";
|
|
1113
|
-
const ARROW = "→";
|
|
1114
|
-
/** The subject, in the words of the thing that started it. */
|
|
1115
|
-
function describeApp(app) {
|
|
1116
|
-
if (app.type === "in-process") return "in-process (Hono)";
|
|
1117
|
-
if (app.type === "process") return app.command === void 0 ? "a child process" : `${app.command} (child process)`;
|
|
1118
|
-
return app.url ?? "";
|
|
1119
|
-
}
|
|
1120
|
-
/**
|
|
1121
|
-
* The block printed while the world comes up: one line per declared service,
|
|
1122
|
-
* then the app the subject is. There is one shape of world to report on, so the
|
|
1123
|
-
* report takes no mode — the parameter that named one left with compose mode
|
|
1124
|
-
* (ADR-007).
|
|
1125
|
-
*/
|
|
1126
|
-
function formatStartupReport(services, app) {
|
|
1127
|
-
const lines = [];
|
|
1128
|
-
lines.push("", `${BG_CYAN}${BLACK}${BOLD} INFRA ${RESET} Starting infrastructure...`, "");
|
|
1129
|
-
for (const service of services) if (service.error) {
|
|
1130
|
-
lines.push(` ${RED}${CROSS}${RESET} ${service.type} (${service.name}) ${RED}${service.error}${RESET} ${DIM}${service.durationMs}ms${RESET}`);
|
|
1131
|
-
if (service.logs) {
|
|
1132
|
-
const logLines = service.logs.trim().split("\n").slice(-10);
|
|
1133
|
-
for (const logLine of logLines) lines.push(` ${DIM}${logLine}${RESET}`);
|
|
1134
|
-
}
|
|
1135
|
-
} else {
|
|
1136
|
-
const conn = service.connectionString ? `${DIM}${service.connectionString}${RESET}` : "";
|
|
1137
|
-
lines.push(` ${GREEN}${CHECK}${RESET} ${service.type} (${service.name}) ${conn} ${DIM}${service.durationMs}ms${RESET}`);
|
|
1138
|
-
}
|
|
1139
|
-
if (app && app.type !== "none") lines.push("", ` ${DIM}${ARROW} app: ${describeApp(app)}${RESET}`);
|
|
1140
|
-
lines.push("");
|
|
1141
|
-
return lines.join("\n");
|
|
1142
|
-
}
|
|
1143
|
-
function formatTableDiff(table, columns, expected, actual) {
|
|
1144
|
-
const lines = [];
|
|
1145
|
-
lines.push(`Table "${table}" mismatch`, `${DIM} query: ${columns.join(", ")}${RESET}`, `${DIM} expected: ${rowLabel(expected.length)}${RESET}`, `${DIM} received: ${rowLabel(actual.length)}${RESET}`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
|
|
1146
|
-
const header = columns.join(" | ");
|
|
1147
|
-
lines.push(`${DIM} ${header}${RESET}`);
|
|
1148
|
-
const maxRows = Math.max(expected.length, actual.length);
|
|
1149
|
-
for (let i = 0; i < maxRows; i++) {
|
|
1150
|
-
const exp = expected[i];
|
|
1151
|
-
const act = actual[i];
|
|
1152
|
-
if (exp && !act) lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`);
|
|
1153
|
-
else if (!exp && act) lines.push(`${RED}+ ${formatRow(act)}${RESET}`);
|
|
1154
|
-
else if (exp && act) {
|
|
1155
|
-
if (JSON.stringify(exp) === JSON.stringify(act)) lines.push(` ${formatRow(act)}`);
|
|
1156
|
-
else lines.push(`${GREEN}- ${formatRow(exp)}${RESET}`, `${RED}+ ${formatRow(act)}${RESET}`);
|
|
1157
|
-
}
|
|
1158
|
-
}
|
|
1159
|
-
if (expected.length === 0 && actual.length === 0) lines.push(` (empty)`);
|
|
1160
|
-
return lines.join("\n");
|
|
1161
|
-
}
|
|
1162
|
-
function formatResponseDiff(file, expected, actual) {
|
|
1163
|
-
const lines = [];
|
|
1164
|
-
lines.push(`Response mismatch (${file})`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
|
|
1165
|
-
const expectedLines = JSON.stringify(expected, null, 2).split("\n");
|
|
1166
|
-
const actualLines = JSON.stringify(actual, null, 2).split("\n");
|
|
1167
|
-
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
1168
|
-
for (let i = 0; i < maxLines; i++) {
|
|
1169
|
-
const exp = expectedLines[i];
|
|
1170
|
-
const act = actualLines[i];
|
|
1171
|
-
if (exp === act) lines.push(` ${exp}`);
|
|
1172
|
-
else {
|
|
1173
|
-
if (exp !== void 0) lines.push(`${GREEN}- ${exp}${RESET}`);
|
|
1174
|
-
if (act !== void 0) lines.push(`${RED}+ ${act}${RESET}`);
|
|
1175
|
-
}
|
|
1176
|
-
}
|
|
1177
|
-
return lines.join("\n");
|
|
1178
|
-
}
|
|
1179
|
-
const strictEquals = (expected, actual) => expected === actual;
|
|
1180
|
-
function renderLines(expectedLines, actualLines, equals) {
|
|
1181
|
-
const rendered = [];
|
|
1182
|
-
let marked = 0;
|
|
1183
|
-
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
1184
|
-
for (let i = 0; i < maxLines; i++) {
|
|
1185
|
-
const exp = expectedLines[i];
|
|
1186
|
-
const act = actualLines[i];
|
|
1187
|
-
if (exp !== void 0 && act !== void 0 && equals(exp, act)) {
|
|
1188
|
-
rendered.push(` ${exp}`);
|
|
1189
|
-
continue;
|
|
1190
|
-
}
|
|
1191
|
-
marked++;
|
|
1192
|
-
if (exp !== void 0) rendered.push(`${GREEN}- ${exp}${RESET}`);
|
|
1193
|
-
if (act !== void 0) rendered.push(`${RED}+ ${act}${RESET}`);
|
|
1194
|
-
}
|
|
1195
|
-
return {
|
|
1196
|
-
marked,
|
|
1197
|
-
rendered
|
|
1198
|
-
};
|
|
1199
|
-
}
|
|
1200
|
-
/**
|
|
1201
|
-
* Render an expected/received diff over two texts.
|
|
1202
|
-
*
|
|
1203
|
-
* `equals` is how a line is judged EQUAL. Callers that compare through the
|
|
1204
|
-
* `{{token}}` grammar pass a token-aware predicate, so a `{{url}}` line that
|
|
1205
|
-
* matched is shown as equal — with its token, not as a `-`/`+` pair against
|
|
1206
|
-
* the concrete URL. Marking a line the comparison ACCEPTED buries the one it
|
|
1207
|
-
* rejected: the reader then has to work out which of the differences is the
|
|
1208
|
-
* actual cause.
|
|
1209
|
-
*
|
|
1210
|
-
* Safety net: if the token-aware pass marks nothing at all, the whole diff
|
|
1211
|
-
* would render as a clean block against a failure that really happened (a
|
|
1212
|
-
* `#ref` captured inconsistently across lines, say). The strict rendering is
|
|
1213
|
-
* used instead, so a diff always shows the reader something.
|
|
1214
|
-
*/
|
|
1215
|
-
function formatStdoutDiff(file, expected, actual, options) {
|
|
1216
|
-
const lines = [];
|
|
1217
|
-
lines.push(`Output mismatch (${file})`, "", `${GREEN}- Expected${RESET}`, `${RED}+ Received${RESET}`, "");
|
|
1218
|
-
const expectedLines = expected.split("\n");
|
|
1219
|
-
const actualLines = actual.split("\n");
|
|
1220
|
-
const equals = options?.equals ?? strictEquals;
|
|
1221
|
-
const first = renderLines(expectedLines, actualLines, equals);
|
|
1222
|
-
const rendered = first.marked === 0 && equals !== strictEquals ? renderLines(expectedLines, actualLines, strictEquals).rendered : first.rendered;
|
|
1223
|
-
return [...lines, ...rendered].join("\n");
|
|
1224
|
-
}
|
|
1225
|
-
function formatDirectoryDiff(fixtureName, diff, hint) {
|
|
1226
|
-
const lines = [];
|
|
1227
|
-
const total = diff.added.length + diff.removed.length + diff.changed.length;
|
|
1228
|
-
lines.push(`Directory mismatch: ${BOLD}${fixtureName}${RESET}`, `${DIM} ${total} difference${total === 1 ? "" : "s"}: ${diff.added.length} added, ${diff.removed.length} removed, ${diff.changed.length} changed${RESET}`, "", `${GREEN}- Expected (fixture)${RESET}`, `${RED}+ Received (generated)${RESET}`, "");
|
|
1229
|
-
for (const path of diff.added) lines.push(`${RED}+ added ${path}${RESET} ${DIM}(not in fixture)${RESET}`);
|
|
1230
|
-
for (const path of diff.removed) lines.push(`${GREEN}- removed ${path}${RESET} ${DIM}(in fixture, not generated)${RESET}`);
|
|
1231
|
-
for (const { path, expected, actual } of diff.changed) {
|
|
1232
|
-
const expectedLines = expected.split("\n");
|
|
1233
|
-
const actualLines = actual.split("\n");
|
|
1234
|
-
const changedCount = countLineDifferences(expectedLines, actualLines);
|
|
1235
|
-
lines.push(`${BOLD}~ changed ${path}${RESET} ${DIM}(${changedCount} line${changedCount === 1 ? "" : "s"} differ)${RESET}`);
|
|
1236
|
-
let shown = 0;
|
|
1237
|
-
const maxShown = 5;
|
|
1238
|
-
const maxLines = Math.max(expectedLines.length, actualLines.length);
|
|
1239
|
-
for (let i = 0; i < maxLines && shown < maxShown; i++) {
|
|
1240
|
-
const exp = expectedLines[i];
|
|
1241
|
-
const act = actualLines[i];
|
|
1242
|
-
if (exp !== act) {
|
|
1243
|
-
lines.push(`${DIM} line ${i + 1}:${RESET}`);
|
|
1244
|
-
if (exp !== void 0) lines.push(` ${GREEN}- ${exp}${RESET}`);
|
|
1245
|
-
if (act !== void 0) lines.push(` ${RED}+ ${act}${RESET}`);
|
|
1246
|
-
shown++;
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
if (changedCount > maxShown) lines.push(` ${DIM}... ${changedCount - maxShown} more line(s)${RESET}`);
|
|
1250
|
-
}
|
|
1251
|
-
lines.push("", `${DIM}${hint}${RESET}`);
|
|
1252
|
-
return lines.join("\n");
|
|
1253
|
-
}
|
|
1254
|
-
function countLineDifferences(expected, actual) {
|
|
1255
|
-
let count = 0;
|
|
1256
|
-
const max = Math.max(expected.length, actual.length);
|
|
1257
|
-
for (let i = 0; i < max; i++) if (expected[i] !== actual[i]) count++;
|
|
1258
|
-
return count;
|
|
1259
|
-
}
|
|
1260
|
-
function rowLabel(n) {
|
|
1261
|
-
return n === 1 ? "1 row" : `${n} rows`;
|
|
1262
|
-
}
|
|
1263
|
-
function formatRow(row) {
|
|
1264
|
-
return row.map((v) => String(v ?? "null")).join(" | ");
|
|
1265
|
-
}
|
|
1266
|
-
//#endregion
|
|
1267
|
-
//#region src/seams/process/serve.adapter.ts
|
|
1268
|
-
/** Grace period between SIGTERM and the SIGKILL escalation. */
|
|
1269
|
-
const KILL_GRACE_MS = 2e3;
|
|
1270
|
-
const DEFAULT_READY_TIMEOUT = 3e4;
|
|
1271
|
-
const READY_POLL_INTERVAL_MS = 250;
|
|
1272
|
-
const delay = async (ms) => {
|
|
1273
|
-
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
1274
|
-
};
|
|
1275
|
-
/** Ask the OS for a free TCP port. */
|
|
1276
|
-
async function findFreePort() {
|
|
1277
|
-
return await new Promise((resolve, reject) => {
|
|
1278
|
-
const server = createServer();
|
|
1279
|
-
server.listen(0, () => {
|
|
1280
|
-
const address = server.address();
|
|
1281
|
-
if (address === null || typeof address === "string") {
|
|
1282
|
-
server.close();
|
|
1283
|
-
reject(/* @__PURE__ */ new Error("could not determine a free port"));
|
|
1284
|
-
return;
|
|
1285
|
-
}
|
|
1286
|
-
const { port } = address;
|
|
1287
|
-
server.close(() => {
|
|
1288
|
-
resolve(port);
|
|
1289
|
-
});
|
|
1290
|
-
});
|
|
1291
|
-
server.on("error", reject);
|
|
1292
|
-
});
|
|
1293
|
-
}
|
|
1294
|
-
/**
|
|
1295
|
-
* Starts the site under test as a child process and waits until it answers
|
|
1296
|
-
* HTTP. Any HTTP status counts as ready — a 404 on the ready path is still a
|
|
1297
|
-
* listening server; only connection failures keep the poll going.
|
|
1298
|
-
*
|
|
1299
|
-
* The child runs through the shell in its own process group (POSIX) so
|
|
1300
|
-
* `stop()` can terminate the whole tree — a `next start` grandchild must not
|
|
1301
|
-
* outlive the spec run (same escalation as the cli exec adapter: SIGTERM,
|
|
1302
|
-
* then SIGKILL after a 2 s grace).
|
|
1303
|
-
*/
|
|
1304
|
-
var ServeAdapter = class {
|
|
1305
|
-
/** The port the server ended up on — known only after {@link start}. */
|
|
1306
|
-
port = null;
|
|
1307
|
-
child = null;
|
|
1308
|
-
/** Extra environment injected into the child (e.g. the stub backend URL). */
|
|
1309
|
-
extraEnv;
|
|
1310
|
-
/** The constructor named in error messages — `website`, or `mobile` (appium server). */
|
|
1311
|
-
facet;
|
|
1312
|
-
options;
|
|
1313
|
-
output = "";
|
|
1314
|
-
root;
|
|
1315
|
-
constructor(options, root, facet = "website", extraEnv = {}) {
|
|
1316
|
-
this.extraEnv = extraEnv;
|
|
1317
|
-
this.facet = facet;
|
|
1318
|
-
this.options = options;
|
|
1319
|
-
this.root = root;
|
|
1320
|
-
}
|
|
1321
|
-
/** Start the server and resolve with its base URL once it is ready. */
|
|
1322
|
-
async start() {
|
|
1323
|
-
this.runBefore();
|
|
1324
|
-
const banner = this.options.ready instanceof RegExp ? this.options.ready : null;
|
|
1325
|
-
const port = banner ? null : this.options.port ?? await findFreePort();
|
|
1326
|
-
const timeout = this.options.timeout ?? DEFAULT_READY_TIMEOUT;
|
|
1327
|
-
this.child = spawn(this.options.command, [], {
|
|
1328
|
-
cwd: this.workingDirectory(),
|
|
1329
|
-
detached: process.platform !== "win32",
|
|
1330
|
-
env: {
|
|
1331
|
-
...process.env,
|
|
1332
|
-
...typeof this.options.env === "function" ? {} : this.options.env,
|
|
1333
|
-
...this.extraEnv,
|
|
1334
|
-
...port === null ? {} : { PORT: String(port) }
|
|
1335
|
-
},
|
|
1336
|
-
shell: true,
|
|
1337
|
-
stdio: [
|
|
1338
|
-
"ignore",
|
|
1339
|
-
"pipe",
|
|
1340
|
-
"pipe"
|
|
1341
|
-
]
|
|
1342
|
-
});
|
|
1343
|
-
this.child.stdout?.on("data", (data) => {
|
|
1344
|
-
this.output += data.toString();
|
|
1345
|
-
});
|
|
1346
|
-
this.child.stderr?.on("data", (data) => {
|
|
1347
|
-
this.output += data.toString();
|
|
1348
|
-
});
|
|
1349
|
-
let exited = false;
|
|
1350
|
-
this.child.on("exit", () => {
|
|
1351
|
-
exited = true;
|
|
1352
|
-
});
|
|
1353
|
-
const deadline = Date.now() + timeout;
|
|
1354
|
-
const died = () => {
|
|
1355
|
-
throw new Error(`specification.${this.facet}(): server command exited before it was ready.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
|
|
1356
|
-
};
|
|
1357
|
-
if (banner) for (;;) {
|
|
1358
|
-
banner.lastIndex = 0;
|
|
1359
|
-
const found = banner.exec(this.output);
|
|
1360
|
-
if (found) {
|
|
1361
|
-
const captured = Number(found[1]);
|
|
1362
|
-
if (!Number.isInteger(captured)) {
|
|
1363
|
-
await this.stop();
|
|
1364
|
-
throw new Error(`specification.${this.facet}(): the ready pattern ${String(banner)} matched but its capture group is not a port ("${found[1]}").\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
|
|
1365
|
-
}
|
|
1366
|
-
this.port = captured;
|
|
1367
|
-
return `http://127.0.0.1:${captured}`;
|
|
1368
|
-
}
|
|
1369
|
-
if (exited) died();
|
|
1370
|
-
if (Date.now() >= deadline) {
|
|
1371
|
-
await this.stop();
|
|
1372
|
-
throw new Error(`specification.${this.facet}(): server did not print ${String(banner)} within ${timeout}ms.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
|
|
1373
|
-
}
|
|
1374
|
-
await delay(READY_POLL_INTERVAL_MS);
|
|
1375
|
-
}
|
|
1376
|
-
const baseUrl = `http://127.0.0.1:${port}`;
|
|
1377
|
-
const readyUrl = `${baseUrl}${this.options.ready ?? "/"}`;
|
|
1378
|
-
for (;;) {
|
|
1379
|
-
if (exited) died();
|
|
1380
|
-
try {
|
|
1381
|
-
await fetch(readyUrl, { redirect: "manual" });
|
|
1382
|
-
this.port = port;
|
|
1383
|
-
return baseUrl;
|
|
1384
|
-
} catch {
|
|
1385
|
-
if (Date.now() >= deadline) {
|
|
1386
|
-
await this.stop();
|
|
1387
|
-
throw new Error(`specification.${this.facet}(): server did not answer on ${readyUrl} within ${timeout}ms.\nCommand: ${this.options.command}\nOutput:\n${this.output}`);
|
|
1388
|
-
}
|
|
1389
|
-
await delay(READY_POLL_INTERVAL_MS);
|
|
1390
|
-
}
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
/** Where the child runs: the stated `cwd` under the root, or the root. */
|
|
1394
|
-
workingDirectory() {
|
|
1395
|
-
return this.options.cwd === void 0 ? this.root : resolve(this.root, this.options.cwd);
|
|
1396
|
-
}
|
|
1397
|
-
/**
|
|
1398
|
-
* The one-shot command, run before the process is spawned. It must exit 0:
|
|
1399
|
-
* a build that failed is not a spec that failed later on a missing file,
|
|
1400
|
-
* and the difference is what this reports.
|
|
1401
|
-
*/
|
|
1402
|
-
runBefore() {
|
|
1403
|
-
const { before } = this.options;
|
|
1404
|
-
if (before === void 0) return;
|
|
1405
|
-
const outcome = spawnSync(before, [], {
|
|
1406
|
-
cwd: this.workingDirectory(),
|
|
1407
|
-
encoding: "utf8",
|
|
1408
|
-
shell: true
|
|
1409
|
-
});
|
|
1410
|
-
if (outcome.status !== 0) throw new Error(`specification.${this.facet}(): \`before\` exited ${outcome.status ?? "without a status"}.\nCommand: ${before}\nOutput:\n${(outcome.stdout ?? "") + (outcome.stderr ?? "")}`);
|
|
1411
|
-
}
|
|
1412
|
-
/** Terminate the server process group (idempotent). */
|
|
1413
|
-
async stop() {
|
|
1414
|
-
const { child } = this;
|
|
1415
|
-
if (!child) return;
|
|
1416
|
-
this.child = null;
|
|
1417
|
-
await new Promise((resolve) => {
|
|
1418
|
-
let exited = false;
|
|
1419
|
-
child.on("exit", () => {
|
|
1420
|
-
exited = true;
|
|
1421
|
-
resolve();
|
|
1422
|
-
});
|
|
1423
|
-
if (child.exitCode !== null || child.signalCode !== null) {
|
|
1424
|
-
resolve();
|
|
1425
|
-
return;
|
|
1426
|
-
}
|
|
1427
|
-
this.kill(child, "SIGTERM");
|
|
1428
|
-
setTimeout(() => {
|
|
1429
|
-
if (!exited) {
|
|
1430
|
-
this.kill(child, "SIGKILL");
|
|
1431
|
-
resolve();
|
|
1432
|
-
}
|
|
1433
|
-
}, KILL_GRACE_MS).unref?.();
|
|
1434
|
-
});
|
|
1435
|
-
}
|
|
1436
|
-
/** Group-aware kill: signal the process group, fall back to the child. */
|
|
1437
|
-
kill(child, signal) {
|
|
1438
|
-
if (child.pid !== void 0 && process.platform !== "win32") try {
|
|
1439
|
-
process.kill(-child.pid, signal);
|
|
1440
|
-
return;
|
|
1441
|
-
} catch {}
|
|
1442
|
-
child.kill(signal);
|
|
1443
|
-
}
|
|
1444
|
-
};
|
|
1445
|
-
//#endregion
|
|
1446
|
-
//#region src/runner/update.ts
|
|
1447
|
-
/**
|
|
1448
|
-
* Snapshot update-mode detection. `TEST_UPDATE=1` (framework convention, see
|
|
1449
|
-
* CONVENTIONS E1) or vitest's `-u` / `--update` flag turn fixture mismatches
|
|
1450
|
-
* into fixture rewrites.
|
|
1451
|
-
*/
|
|
1452
|
-
function shouldUpdateSnapshots() {
|
|
1453
|
-
if (process.env.TEST_UPDATE === "1") return true;
|
|
1454
|
-
return process.argv.includes("-u") || process.argv.includes("--update");
|
|
1455
|
-
}
|
|
1456
|
-
/** Standard hint appended to missing-fixture errors. */
|
|
1457
|
-
const UPDATE_HINT = "Run with TEST_UPDATE=1 (or vitest -u) to create it.";
|
|
1458
|
-
/**
|
|
1459
|
-
* The hint a FROZEN golden gets instead. `{ frozen }` is the switch that tells
|
|
1460
|
-
* update mode to leave this file alone, so sending its author to `TEST_UPDATE=1`
|
|
1461
|
-
* would send them to a run that does nothing.
|
|
1462
|
-
*/
|
|
1463
|
-
const FROZEN_HINT = "This golden is `{ frozen }`: update mode never writes it. Write the file by hand, or drop `{ frozen }` for one TEST_UPDATE=1 run and put it back.";
|
|
1464
|
-
/** The hint a missing golden earns, given whether it is frozen. */
|
|
1465
|
-
function missingHint(frozen) {
|
|
1466
|
-
return frozen ? FROZEN_HINT : UPDATE_HINT;
|
|
1467
|
-
}
|
|
1468
|
-
//#endregion
|
|
1469
|
-
//#region src/facets/cli/literate.ts
|
|
1470
|
-
/**
|
|
1471
|
-
* The form a stream is compared in: ANSI stripped (rule D6) and the runner's
|
|
1472
|
-
* `transform` applied. Nothing else — a block scalar states its own final
|
|
1473
|
-
* newline (`|` keeps it, `|-` drops it), so the comparison is byte-exact and
|
|
1474
|
-
* has no normalisation to hide behind.
|
|
1475
|
-
*/
|
|
1476
|
-
function comparable(raw, transform) {
|
|
1477
|
-
const stripped = stripAnsiCodes(raw);
|
|
1478
|
-
return transform ? transform(stripped) : stripped;
|
|
1479
|
-
}
|
|
1480
|
-
/**
|
|
1481
|
-
* Line equality AS THE COMPARISON JUDGED IT — a `{{url}}` line that matched is
|
|
1482
|
-
* equal, whatever port the run picked. Each line is judged on its own fresh
|
|
1483
|
-
* scope: this is a rendering, and a ref captured by an earlier line must not
|
|
1484
|
-
* decide how a later one is displayed.
|
|
1485
|
-
*/
|
|
1486
|
-
function tokenAwareEquals(scope) {
|
|
1487
|
-
return (expected, actual) => textEquals(expected, actual, new CaptureScope(scope.workdir));
|
|
1488
|
-
}
|
|
1489
|
-
/** A path under the working directory, refusing anything that would escape it. */
|
|
1490
|
-
function resolveUnderWorkDir(path, workDir, context) {
|
|
1491
|
-
const target = resolve(workDir, path);
|
|
1492
|
-
const inside = relative(workDir, target);
|
|
1493
|
-
if (isAbsolute(path) || inside.startsWith("..") || isAbsolute(inside)) throw new Error(`${context.displayPath}: files: "${path}" leaves the working directory — a files: key is a relative path under the cwd`);
|
|
1494
|
-
return target;
|
|
1495
|
-
}
|
|
1496
|
-
/** A one-line preview of a file's content for the failure rendering. */
|
|
1497
|
-
function preview(text) {
|
|
1498
|
-
return JSON.stringify(text.length > 160 ? `${text.slice(0, 160)}…` : text);
|
|
1499
|
-
}
|
|
1500
|
-
function readIfPresent(path) {
|
|
1501
|
-
try {
|
|
1502
|
-
return statSync(path).isFile() ? readFileSync(path, "utf8") : null;
|
|
1503
|
-
} catch {
|
|
1504
|
-
return null;
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
/** Check one `files:` entry and render its failure, or `null` when it holds. */
|
|
1508
|
-
function checkFile(assertion, workDir, context, scope) {
|
|
1509
|
-
const target = resolveUnderWorkDir(assertion.path, workDir, context);
|
|
1510
|
-
let exists;
|
|
1511
|
-
try {
|
|
1512
|
-
exists = statSync(target) !== void 0;
|
|
1513
|
-
} catch {
|
|
1514
|
-
exists = false;
|
|
1515
|
-
}
|
|
1516
|
-
if (assertion.kind === "absent") return exists ? `${assertion.path}: expected absent, got a file` : null;
|
|
1517
|
-
if (!exists) return `${assertion.path}: expected to exist, got nothing at that path`;
|
|
1518
|
-
if (assertion.kind === "exists") return null;
|
|
1519
|
-
const content = readIfPresent(target);
|
|
1520
|
-
if (content === null) return `${assertion.path}: expected a readable file, got a directory`;
|
|
1521
|
-
for (const needle of assertion.contains) if (!textContains(needle.text, content, scope)) return `${assertion.path}: expected to contain ${preview(needle.text)}, got ${preview(content)}`;
|
|
1522
|
-
if (assertion.equals !== null && !textEquals(assertion.equals.text, content, scope)) return `${assertion.path}: expected ${preview(assertion.equals.text)}, got ${preview(content)}`;
|
|
1523
|
-
return null;
|
|
1524
|
-
}
|
|
1525
|
-
/**
|
|
1526
|
-
* The failure a mismatching run throws: the description, the command that ran,
|
|
1527
|
-
* every detail the comparison rejected, and where to open the document. The
|
|
1528
|
-
* update hint names exactly what a rewrite touches, so it cannot be read as
|
|
1529
|
-
* "this will re-generate my `files:`".
|
|
1530
|
-
*
|
|
1531
|
-
* The stack is REPLACED by the run's own line. Left alone it would carry the
|
|
1532
|
-
* engine's frames and end inside the module the plugin generates — pointing the
|
|
1533
|
-
* reader at a line of a file they never wrote. One frame, on the `command:` key,
|
|
1534
|
-
* is the whole truth here.
|
|
1535
|
-
*/
|
|
1536
|
-
function failure(document, run, context, details) {
|
|
1537
|
-
return atLine(new Error([
|
|
1538
|
-
"Spec mismatch",
|
|
1539
|
-
"",
|
|
1540
|
-
document.description,
|
|
1541
|
-
"",
|
|
1542
|
-
`$ ${run.command}`,
|
|
1543
|
-
"",
|
|
1544
|
-
details.join("\n\n"),
|
|
1545
|
-
"",
|
|
1546
|
-
`${context.displayPath}:${run.commandLine}`,
|
|
1547
|
-
"Run with TEST_UPDATE=1 to rewrite the runs — the exit code and the streams, and nothing else."
|
|
1548
|
-
].join("\n")), context.filePath, run.commandLine);
|
|
1549
|
-
}
|
|
1550
|
-
/**
|
|
1551
|
-
* Point an error's stack at one frame: the line of the `.spec.yaml` that owns
|
|
1552
|
-
* the failure. The framework's own frames carry nothing a reader of a spec
|
|
1553
|
-
* document can act on, and the generated module's frame actively misleads.
|
|
1554
|
-
*/
|
|
1555
|
-
function atLine(error, filePath, line) {
|
|
1556
|
-
error.stack = `${error.name}: ${error.message}\n at ${filePath}:${line}:1`;
|
|
1557
|
-
return error;
|
|
1558
|
-
}
|
|
1559
|
-
/**
|
|
1560
|
-
* Compare one run against what the command actually did — its exit code, its
|
|
1561
|
-
* streams and its `files:`, all read at the same instant: the moment that
|
|
1562
|
-
* command returned.
|
|
1563
|
-
*/
|
|
1564
|
-
function assertRun(document, outcome, context, workDir, scope) {
|
|
1565
|
-
const { actual, run } = outcome;
|
|
1566
|
-
if (actual.exitCode !== run.exitCode) throw failure(document, run, context, [[
|
|
1567
|
-
"exit code mismatch",
|
|
1568
|
-
` expected: ${run.exitCode}`,
|
|
1569
|
-
` received: ${actual.exitCode}`,
|
|
1570
|
-
...outcome.stderr.length > 0 ? [
|
|
1571
|
-
"",
|
|
1572
|
-
"stderr was:",
|
|
1573
|
-
outcome.stderr.replace(/\n$/u, "")
|
|
1574
|
-
] : []
|
|
1575
|
-
].join("\n")]);
|
|
1576
|
-
const equals = tokenAwareEquals(scope);
|
|
1577
|
-
const details = [];
|
|
1578
|
-
for (const [name, expected, received] of [[
|
|
1579
|
-
"stdout",
|
|
1580
|
-
run.stdout?.text ?? "",
|
|
1581
|
-
outcome.stdout
|
|
1582
|
-
], [
|
|
1583
|
-
"stderr",
|
|
1584
|
-
run.stderr?.text ?? "",
|
|
1585
|
-
outcome.stderr
|
|
1586
|
-
]]) if (!textEquals(expected, received, scope)) details.push(formatStdoutDiff(name, expected, received, { equals }));
|
|
1587
|
-
const fileFailures = run.files.map((assertion) => checkFile(assertion, workDir, context, scope)).filter((line) => line !== null);
|
|
1588
|
-
if (fileFailures.length > 0) details.push([
|
|
1589
|
-
"files mismatch",
|
|
1590
|
-
"",
|
|
1591
|
-
...fileFailures.map((line) => ` ${line}`)
|
|
1592
|
-
].join("\n"));
|
|
1593
|
-
if (details.length > 0) throw failure(document, run, context, details);
|
|
1594
|
-
}
|
|
1595
|
-
/**
|
|
1596
|
-
* The rewritten runs: exit codes and streams from the run, placeholders of the
|
|
1597
|
-
* previous golden preserved by pattern (rule D5). A stream that ends up empty
|
|
1598
|
-
* loses its key, so a rewritten document passes the next run.
|
|
1599
|
-
*/
|
|
1600
|
-
function updates(outcomes, scope) {
|
|
1601
|
-
return outcomes.map(({ actual, run, stderr, stdout }) => ({
|
|
1602
|
-
exitCode: actual.exitCode,
|
|
1603
|
-
stderr: mergeTextPreservingPlaceholders(run.stderr?.text ?? null, stderr, scope),
|
|
1604
|
-
stdout: mergeTextPreservingPlaceholders(run.stdout?.text ?? null, stdout, scope)
|
|
1605
|
-
}));
|
|
1606
|
-
}
|
|
1607
|
-
/**
|
|
1608
|
-
* Start every server the document names, in declaration order, after `fixture:`
|
|
1609
|
-
* has been copied and before the first run. They all stay live for the whole
|
|
1610
|
-
* file and are stopped together — one document is one scenario, and its servers
|
|
1611
|
-
* are part of its ground.
|
|
1612
|
-
*
|
|
1613
|
-
* Every child is seeded with `TEST_WORKDIR`, the resolved working directory the
|
|
1614
|
-
* `{{workdir}}` token holds and `$WORKDIR` expands to. The fixtures are already
|
|
1615
|
-
* on disk when the child starts, so a served stub reads the world the document
|
|
1616
|
-
* laid down exactly as a stub binary reads its own fixture tree: a scenario is
|
|
1617
|
-
* a layer of files, never a flag threaded through the server. A document's
|
|
1618
|
-
* mapping-form env wins over it, key by key.
|
|
1619
|
-
*/
|
|
1620
|
-
async function startServers(document, config, displayPath, workdir) {
|
|
1621
|
-
const registry = config.serveRegistry ?? {};
|
|
1622
|
-
const started = [];
|
|
1623
|
-
const env = {};
|
|
1624
|
-
const stop = async () => {
|
|
1625
|
-
for (const adapter of started.toReversed()) await adapter.stop();
|
|
1626
|
-
};
|
|
1627
|
-
for (const entry of document.serve) {
|
|
1628
|
-
const registration = registry[entry.name];
|
|
1629
|
-
if (!registration) {
|
|
1630
|
-
await stop();
|
|
1631
|
-
const known = Object.keys(registry);
|
|
1632
|
-
throw new Error(`${displayPath}:${entry.line}: serve: "${entry.name}" is not registered — ${known.length === 0 ? "specification.cli() declares no `serve` option." : `registered servers: ${known.join(", ")}.`}`);
|
|
1633
|
-
}
|
|
1634
|
-
const adapter = new ServeAdapter({
|
|
1635
|
-
command: registration.command,
|
|
1636
|
-
ready: registration.ready
|
|
1637
|
-
}, config.root ?? process.cwd(), "cli", {
|
|
1638
|
-
TEST_WORKDIR: workdir,
|
|
1639
|
-
...entry.env
|
|
1640
|
-
});
|
|
1641
|
-
started.push(adapter);
|
|
1642
|
-
try {
|
|
1643
|
-
await adapter.start();
|
|
1644
|
-
} catch (error) {
|
|
1645
|
-
await stop();
|
|
1646
|
-
throw error;
|
|
1647
|
-
}
|
|
1648
|
-
env[registration.env] = registration.url(adapter.port);
|
|
1649
|
-
}
|
|
1650
|
-
return {
|
|
1651
|
-
env,
|
|
1652
|
-
stop
|
|
1653
|
-
};
|
|
1654
|
-
}
|
|
1655
|
-
/** Resolve the document's `env:` entries against the registered sets. `$WORKDIR` expands. */
|
|
1656
|
-
function documentEnv(document, config, displayPath) {
|
|
1657
|
-
const sets = config.envSets ?? {};
|
|
1658
|
-
const env = {};
|
|
1659
|
-
for (const token of document.env) {
|
|
1660
|
-
if (token.kind === "set") {
|
|
1661
|
-
const declared = sets[token.name];
|
|
1662
|
-
if (!declared) {
|
|
1663
|
-
const known = Object.keys(sets);
|
|
1664
|
-
throw new Error(`${displayPath}:${token.line}: env: "${token.name}" is not a registered env set — ${known.length === 0 ? "specification.cli() declares no `env` option." : `registered sets: ${known.join(", ")}.`}`);
|
|
1665
|
-
}
|
|
1666
|
-
Object.assign(env, declared);
|
|
1667
|
-
continue;
|
|
1668
|
-
}
|
|
1669
|
-
env[token.key] = token.value;
|
|
1670
|
-
}
|
|
1671
|
-
return env;
|
|
1672
|
-
}
|
|
1673
|
-
/**
|
|
1674
|
-
* Run one spec document end to end and resolve with the LAST run's result —
|
|
1675
|
-
* the handle a `.test.ts` adds its own assertion to (a directory golden, a
|
|
1676
|
-
* grep) after `cli.run('<case>.spec.yaml')`.
|
|
1677
|
-
*
|
|
1678
|
-
* Under `TEST_UPDATE=1` nothing is asserted: every run executes, and each one's
|
|
1679
|
-
* exit code and streams are rewritten from the actual output with the previous
|
|
1680
|
-
* golden's placeholders preserved.
|
|
1681
|
-
*/
|
|
1682
|
-
async function runSpecDocument(options) {
|
|
1683
|
-
const { config, displayPath, filePath, testDir, workDir } = options;
|
|
1684
|
-
if (!config.command) throw new Error(".run(): spec documents require a command adapter (use specification.cli())");
|
|
1685
|
-
let file;
|
|
1686
|
-
try {
|
|
1687
|
-
file = readSpecFile(readFileSync(filePath, "utf8"), displayPath);
|
|
1688
|
-
} catch (error) {
|
|
1689
|
-
throw error instanceof SpecSyntaxError ? atLine(error, filePath, error.line) : error;
|
|
1690
|
-
}
|
|
1691
|
-
const { document } = file;
|
|
1692
|
-
const fileDir = filePath.replace(/[/\\][^/\\]*$/u, "");
|
|
1693
|
-
for (const fixture of document.fixtures) {
|
|
1694
|
-
const { dest, src } = copyPlan(fixture.path, fileDir, workDir);
|
|
1695
|
-
cpSync(src, dest, { recursive: true });
|
|
1696
|
-
}
|
|
1697
|
-
const workdir = safeRealpath(workDir);
|
|
1698
|
-
const servers = await startServers(document, config, displayPath, workdir);
|
|
1699
|
-
const scope = new CaptureScope(workdir);
|
|
1700
|
-
const env = expandWorkdir({
|
|
1701
|
-
...options.baseEnv,
|
|
1702
|
-
...servers.env,
|
|
1703
|
-
...documentEnv(document, config, displayPath)
|
|
1704
|
-
}, workDir);
|
|
1705
|
-
const rewriting = shouldUpdateSnapshots() && options.frozen !== true;
|
|
1706
|
-
const outcomes = [];
|
|
1707
|
-
try {
|
|
1708
|
-
for (const run of document.runs) {
|
|
1709
|
-
const actual = run.waitFor === null ? await config.command.exec(run.command, workDir, env, {
|
|
1710
|
-
stdin: run.stdin ?? void 0,
|
|
1711
|
-
timeout: run.timeout ?? void 0
|
|
1712
|
-
}) : await config.command.watch(run.command, workDir, {
|
|
1713
|
-
timeout: run.timeout ?? void 0,
|
|
1714
|
-
waitFor: run.waitFor
|
|
1715
|
-
}, env);
|
|
1716
|
-
const outcome = {
|
|
1717
|
-
actual,
|
|
1718
|
-
run,
|
|
1719
|
-
stderr: comparable(actual.stderr, config.transform),
|
|
1720
|
-
stdout: comparable(actual.stdout, config.transform)
|
|
1721
|
-
};
|
|
1722
|
-
outcomes.push(outcome);
|
|
1723
|
-
if (!rewriting) assertRun(document, outcome, {
|
|
1724
|
-
displayPath,
|
|
1725
|
-
filePath
|
|
1726
|
-
}, workDir, scope);
|
|
1727
|
-
}
|
|
1728
|
-
} finally {
|
|
1729
|
-
await servers.stop();
|
|
1730
|
-
}
|
|
1731
|
-
if (rewriting) writeFileSync(filePath, updateSpecFile(file, updates(outcomes, scope)));
|
|
1732
|
-
return new CliResult({
|
|
1733
|
-
commandOutput: outcomes.at(-1).actual,
|
|
1734
|
-
config,
|
|
1735
|
-
dockerConfig: config.dockerConfig,
|
|
1736
|
-
testDir,
|
|
1737
|
-
testRunId: config.dockerTestRunId,
|
|
1738
|
-
transform: config.transform,
|
|
1739
|
-
workDir
|
|
1740
|
-
});
|
|
1741
|
-
}
|
|
1742
|
-
/** The title a transformed `.spec.yaml` module runs under — the `description:` line. */
|
|
1743
|
-
function specDescription(content, displayPath) {
|
|
1744
|
-
return readSpecFile(content, displayPath).document.description;
|
|
1745
|
-
}
|
|
1746
|
-
//#endregion
|
|
1747
|
-
export { placeholderPatternSource as A, FilesystemAccessor as C, hasPlaceholders as D, walkDirectory as E, structuralEquals as M, structuralSubset as N, mergePreservingPlaceholders as O, textEquals as P, TextAccessor as S, diffDirectories as T, discoverRoot as _, ServeAdapter as a, resolveRoot as b, formatStartupReport as c, ContainerAccessor as d, CliResult as f, TableAccessor as g, validateDatabaseOption as h, shouldUpdateSnapshots as i, renderExpected as j, mergeTextPreservingPlaceholders as k, formatStdoutDiff as l, BaseResult as m, specDescription as n, formatDirectoryDiff as o, inspectContainer as p, missingHint as r, formatResponseDiff as s, runSpecDocument as t, formatTableDiff as u, expandWorkdir as v, DirectoryAccessor as w, JsonAccessor as x, resolveCommand as y };
|