@jterrazz/test 16.0.0 → 16.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/appium.adapter.js +59 -4
- package/dist/browser/index.js +203 -42
- package/dist/checker.js +305 -63
- package/dist/index.d.ts +35 -4
- package/dist/index.js +47 -24
- package/dist/oxlint.cjs +123 -28
- package/dist/oxlint.js +123 -28
- package/dist/playwright.adapter.js +88 -50
- package/dist/substring-warning.js +282 -0
- package/dist/vitest.js +91 -12
- package/docs/01-architecture.md +95 -0
- package/docs/02-developing.md +351 -0
- package/docs/03-testing.md +495 -0
- package/docs/04-operating.md +101 -0
- package/docs/05-module-tests.md +152 -0
- package/docs/06-integration.md +180 -0
- package/docs/07-component.md +246 -0
- package/docs/08-website.md +404 -0
- package/docs/09-mobile.md +270 -0
- package/docs/10-api.md +264 -0
- package/docs/11-jobs.md +157 -0
- package/docs/12-cli.md +677 -0
- package/docs/13-elements.md +248 -0
- package/docs/14-assertions.md +314 -0
- package/docs/15-tokens.md +253 -0
- package/docs/16-contracts.md +438 -0
- package/docs/17-services.md +188 -0
- package/docs/18-conventions.md +332 -0
- package/docs/19-linting.md +512 -0
- package/docs/README.md +36 -0
- package/docs/decisions/001-v9-conventions-on-three-channels.md +50 -0
- package/docs/decisions/002-the-statique-channel-holds-only-what-upstream-does-not.md +49 -0
- package/docs/decisions/003-a-rendered-component-is-a-facet-on-vitest-browser-mode.md +84 -0
- package/docs/decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-facet.md +81 -0
- package/docs/decisions/005-the-doubles-ladder-is-closed.md +86 -0
- package/docs/decisions/006-the-catalogue-reaches-a-file-by-its-role.md +245 -0
- package/docs/decisions/007-compose-mode-leaves-the-package.md +53 -0
- package/docs/decisions/008-vitest-5-node-24-and-the-native-seams-as-optional-peers.md +75 -0
- package/docs/decisions/_template.md +22 -0
- package/docs/reference/classes/Matcher.md +120 -0
- package/docs/reference/classes/ProcessHandle.md +368 -0
- package/docs/reference/functions/defineContract.md +39 -0
- package/docs/reference/functions/defineContracts.md +36 -0
- package/docs/reference/functions/disabled.md +28 -0
- package/docs/reference/functions/enabled.md +20 -0
- package/docs/reference/functions/focused.md +26 -0
- package/docs/reference/functions/mockOf.md +79 -0
- package/docs/reference/functions/postgres.md +31 -0
- package/docs/reference/functions/process.md +33 -0
- package/docs/reference/functions/redis.md +28 -0
- package/docs/reference/functions/required.md +41 -0
- package/docs/reference/functions/selected.md +26 -0
- package/docs/reference/functions/sqlite.md +33 -0
- package/docs/reference/functions/testId.md +20 -0
- package/docs/reference/functions/text.md +40 -0
- package/docs/reference/functions/valued.md +28 -0
- package/docs/reference/functions/waitUntil.md +46 -0
- package/docs/reference/functions/within.md +29 -0
- package/docs/reference/index.md +196 -0
- package/docs/reference/interfaces/BaseResult.md +87 -0
- package/docs/reference/interfaces/CallResult.md +160 -0
- package/docs/reference/interfaces/CaptureScope.md +82 -0
- package/docs/reference/interfaces/CliResult.md +253 -0
- package/docs/reference/interfaces/ContainerAccessor.md +166 -0
- package/docs/reference/interfaces/DirectoryAccessor.md +33 -0
- package/docs/reference/interfaces/FetchResult.md +180 -0
- package/docs/reference/interfaces/FilesystemAccessor.md +46 -0
- package/docs/reference/interfaces/HttpResult.md +126 -0
- package/docs/reference/interfaces/JsonAccessor.md +46 -0
- package/docs/reference/interfaces/PageResult.md +356 -0
- package/docs/reference/interfaces/RenderResult.md +72 -0
- package/docs/reference/interfaces/RenderedText.md +110 -0
- package/docs/reference/interfaces/ResponseAccessor.md +47 -0
- package/docs/reference/interfaces/ScreenResult.md +128 -0
- package/docs/reference/interfaces/TableAccessor.md +26 -0
- package/docs/reference/interfaces/TextAccessor.md +96 -0
- package/docs/reference/type-aliases/ApiHandle.md +68 -0
- package/docs/reference/type-aliases/ApiSpecification.md +213 -0
- package/docs/reference/type-aliases/ApiSpecificationOptions.md +68 -0
- package/docs/reference/type-aliases/BrowserConsoleMessage.md +30 -0
- package/docs/reference/type-aliases/BrowserLinkElement.md +50 -0
- package/docs/reference/type-aliases/BrowserMetaElement.md +40 -0
- package/docs/reference/type-aliases/BrowserOpenOptions.md +87 -0
- package/docs/reference/type-aliases/BrowserPage.md +132 -0
- package/docs/reference/type-aliases/BrowserPort.md +51 -0
- package/docs/reference/type-aliases/CliEnv.md +11 -0
- package/docs/reference/type-aliases/CliHandle.md +80 -0
- package/docs/reference/type-aliases/CliInput.md +36 -0
- package/docs/reference/type-aliases/CliOutput.md +46 -0
- package/docs/reference/type-aliases/CliPort.md +58 -0
- package/docs/reference/type-aliases/CliSpecification.md +144 -0
- package/docs/reference/type-aliases/CliSpecificationOptions.md +134 -0
- package/docs/reference/type-aliases/ComponentChain.md +156 -0
- package/docs/reference/type-aliases/ComponentScenario.md +20 -0
- package/docs/reference/type-aliases/ComponentUi.md +47 -0
- package/docs/reference/type-aliases/ComponentVisitor.md +10 -0
- package/docs/reference/type-aliases/ContainerPort.md +113 -0
- package/docs/reference/type-aliases/Contract.md +68 -0
- package/docs/reference/type-aliases/ContractInput.md +13 -0
- package/docs/reference/type-aliases/ContractRequest.md +95 -0
- package/docs/reference/type-aliases/ContractResponder.md +23 -0
- package/docs/reference/type-aliases/ContractResponse.md +78 -0
- package/docs/reference/type-aliases/ContractResponseValue.md +13 -0
- package/docs/reference/type-aliases/Contracts.md +48 -0
- package/docs/reference/type-aliases/DatabaseKeys.md +16 -0
- package/docs/reference/type-aliases/DatabasePort.md +72 -0
- package/docs/reference/type-aliases/DeviceOpenOptions.md +46 -0
- package/docs/reference/type-aliases/DevicePort.md +51 -0
- package/docs/reference/type-aliases/DeviceScreen.md +32 -0
- package/docs/reference/type-aliases/DeviceTimeouts.md +39 -0
- package/docs/reference/type-aliases/DockerSpecConfig.md +43 -0
- package/docs/reference/type-aliases/DomMount.md +21 -0
- package/docs/reference/type-aliases/ElementKind.md +27 -0
- package/docs/reference/type-aliases/ElementMatch.md +74 -0
- package/docs/reference/type-aliases/ElementOptions.md +29 -0
- package/docs/reference/type-aliases/ElementRef.md +128 -0
- package/docs/reference/type-aliases/ExecOptions.md +37 -0
- package/docs/reference/type-aliases/FileAccessor.md +54 -0
- package/docs/reference/type-aliases/HonoApp.md +31 -0
- package/docs/reference/type-aliases/HttpContractFilter.md +49 -0
- package/docs/reference/type-aliases/HttpResponseInit.md +46 -0
- package/docs/reference/type-aliases/HttpStreamInit.md +58 -0
- package/docs/reference/type-aliases/IntegrationHandle.md +46 -0
- package/docs/reference/type-aliases/IntegrationSpecification.md +111 -0
- package/docs/reference/type-aliases/IntegrationSpecificationOptions.md +42 -0
- package/docs/reference/type-aliases/Intercept.md +19 -0
- package/docs/reference/type-aliases/InterceptOptions.md +28 -0
- package/docs/reference/type-aliases/InterceptScope.md +11 -0
- package/docs/reference/type-aliases/IsolationStrategy.md +75 -0
- package/docs/reference/type-aliases/JobHandle.md +34 -0
- package/docs/reference/type-aliases/JobsHandle.md +57 -0
- package/docs/reference/type-aliases/JobsResult.md +16 -0
- package/docs/reference/type-aliases/JobsSpecification.md +94 -0
- package/docs/reference/type-aliases/JobsSpecificationOptions.md +56 -0
- package/docs/reference/type-aliases/LandmarkKind.md +20 -0
- package/docs/reference/type-aliases/LiterateRunFlags.md +27 -0
- package/docs/reference/type-aliases/LiterateServeRegistration.md +70 -0
- package/docs/reference/type-aliases/MatchFixtureOptions.md +27 -0
- package/docs/reference/type-aliases/MatchableRequest.md +61 -0
- package/docs/reference/type-aliases/MatcherKind.md +34 -0
- package/docs/reference/type-aliases/MobileBackendOptions.md +28 -0
- package/docs/reference/type-aliases/MobileElementKind.md +14 -0
- package/docs/reference/type-aliases/MobileElementMatch.md +60 -0
- package/docs/reference/type-aliases/MobileElementRef.md +20 -0
- package/docs/reference/type-aliases/MobileHandle.md +65 -0
- package/docs/reference/type-aliases/MobileScenario.md +20 -0
- package/docs/reference/type-aliases/MobileSpecification.md +51 -0
- package/docs/reference/type-aliases/MobileSpecificationOptions.md +126 -0
- package/docs/reference/type-aliases/MobileVisitor.md +81 -0
- package/docs/reference/type-aliases/MockOfOptions.md +26 -0
- package/docs/reference/type-aliases/MockPort.md +10 -0
- package/docs/reference/type-aliases/PinnedClock.md +10 -0
- package/docs/reference/type-aliases/PostgresOptions.md +32 -0
- package/docs/reference/type-aliases/ProcessOptions.md +112 -0
- package/docs/reference/type-aliases/RedisOptions.md +20 -0
- package/docs/reference/type-aliases/RenderSubject.md +12 -0
- package/docs/reference/type-aliases/ScreenNode.md +67 -0
- package/docs/reference/type-aliases/ServerPort.md +36 -0
- package/docs/reference/type-aliases/ServerResponse.md +46 -0
- package/docs/reference/type-aliases/ServerSpec.md +21 -0
- package/docs/reference/type-aliases/ServiceHandle.md +216 -0
- package/docs/reference/type-aliases/ServiceRecord.md +12 -0
- package/docs/reference/type-aliases/SpecDocument.md +84 -0
- package/docs/reference/type-aliases/SpecEnvToken.md +21 -0
- package/docs/reference/type-aliases/SpecFileAssertion.md +27 -0
- package/docs/reference/type-aliases/SpecFixture.md +30 -0
- package/docs/reference/type-aliases/SpecKind.md +8 -0
- package/docs/reference/type-aliases/SpecRun.md +108 -0
- package/docs/reference/type-aliases/SpecServeEntry.md +40 -0
- package/docs/reference/type-aliases/SpecStream.md +32 -0
- package/docs/reference/type-aliases/SpecificationConfig.md +290 -0
- package/docs/reference/type-aliases/SqliteOptions.md +36 -0
- package/docs/reference/type-aliases/SseEvent.md +58 -0
- package/docs/reference/type-aliases/StreamBody.md +61 -0
- package/docs/reference/type-aliases/TextFilter.md +22 -0
- package/docs/reference/type-aliases/VisitScenario.md +20 -0
- package/docs/reference/type-aliases/Visitor.md +215 -0
- package/docs/reference/type-aliases/WaitUntilOptions.md +46 -0
- package/docs/reference/type-aliases/WebsiteBackendOptions.md +37 -0
- package/docs/reference/type-aliases/WebsiteHandle.md +51 -0
- package/docs/reference/type-aliases/WebsiteSpecification.md +118 -0
- package/docs/reference/type-aliases/WebsiteSpecificationOptions.md +40 -0
- package/docs/reference/variables/anthropic.md +19 -0
- package/docs/reference/variables/banner.md +10 -0
- package/docs/reference/variables/button.md +10 -0
- package/docs/reference/variables/clock.md +16 -0
- package/docs/reference/variables/complementary.md +10 -0
- package/docs/reference/variables/component.md +10 -0
- package/docs/reference/variables/content.md +10 -0
- package/docs/reference/variables/contentinfo.md +10 -0
- package/docs/reference/variables/dialog.md +10 -0
- package/docs/reference/variables/field.md +10 -0
- package/docs/reference/variables/form.md +10 -0
- package/docs/reference/variables/heading.md +10 -0
- package/docs/reference/variables/http.md +40 -0
- package/docs/reference/variables/intercept.md +11 -0
- package/docs/reference/variables/link.md +10 -0
- package/docs/reference/variables/listitem.md +10 -0
- package/docs/reference/variables/main.md +10 -0
- package/docs/reference/variables/match.md +8 -0
- package/docs/reference/variables/navigation.md +10 -0
- package/docs/reference/variables/openai.md +21 -0
- package/docs/reference/variables/option.md +17 -0
- package/docs/reference/variables/region.md +10 -0
- package/docs/reference/variables/row.md +10 -0
- package/docs/reference/variables/search.md +10 -0
- package/docs/reference/variables/specification.md +8 -0
- package/docs/reference/variables/status.md +10 -0
- package/docs/reference/variables/table.md +10 -0
- package/package.json +3 -2
- package/dist/ambiguity.js +0 -146
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
//#region src/model/elements/ambiguity.ts
|
|
2
|
+
/**
|
|
3
|
+
* The ambiguity refusal — CONVENTIONS W3.
|
|
4
|
+
*
|
|
5
|
+
* A visit descriptor must designate exactly one element. Acting on "the
|
|
6
|
+
* first match" is the failure mode this module exists to prevent: the spec
|
|
7
|
+
* keeps passing while the visitor clicks something else, and nothing ever
|
|
8
|
+
* reports it. So when a descriptor matches several elements the framework
|
|
9
|
+
* refuses, and the refusal has to carry everything needed to fix it without
|
|
10
|
+
* opening a browser — the descriptor in the caller's own vocabulary, every
|
|
11
|
+
* candidate, and the concrete rewrites that would resolve it.
|
|
12
|
+
*
|
|
13
|
+
* Pure string building: it takes captured data and returns a message, so the
|
|
14
|
+
* wording is unit-testable and the browser integration stays a thin adapter.
|
|
15
|
+
*/
|
|
16
|
+
/** `kind` → the constructor that builds it, so errors echo the caller's source. */
|
|
17
|
+
const CONSTRUCTORS = {
|
|
18
|
+
banner: "banner",
|
|
19
|
+
button: "button",
|
|
20
|
+
complementary: "complementary",
|
|
21
|
+
contentinfo: "contentinfo",
|
|
22
|
+
dialog: "dialog",
|
|
23
|
+
field: "field",
|
|
24
|
+
form: "form",
|
|
25
|
+
heading: "heading",
|
|
26
|
+
link: "link",
|
|
27
|
+
listitem: "listitem",
|
|
28
|
+
main: "main",
|
|
29
|
+
navigation: "navigation",
|
|
30
|
+
option: "option",
|
|
31
|
+
region: "region",
|
|
32
|
+
row: "row",
|
|
33
|
+
search: "search",
|
|
34
|
+
status: "status",
|
|
35
|
+
table: "table",
|
|
36
|
+
testId: "testId",
|
|
37
|
+
text: "content"
|
|
38
|
+
};
|
|
39
|
+
/** The landmark a context string maps back to, for a copy-pasteable suggestion. */
|
|
40
|
+
const CONTEXT_LANDMARKS = {
|
|
41
|
+
aside: "complementary()",
|
|
42
|
+
banner: "banner()",
|
|
43
|
+
complementary: "complementary()",
|
|
44
|
+
contentinfo: "contentinfo()",
|
|
45
|
+
footer: "contentinfo()",
|
|
46
|
+
form: "form()",
|
|
47
|
+
header: "banner()",
|
|
48
|
+
main: "main()",
|
|
49
|
+
nav: "navigation()",
|
|
50
|
+
navigation: "navigation()",
|
|
51
|
+
search: "search()"
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Render a descriptor as the source that would build it — `link('Articles')`,
|
|
55
|
+
* `within(navigation(), link('Articles', { exact: false }))`. The error speaks
|
|
56
|
+
* the vocabulary the author wrote, never playwright's — so the option appears
|
|
57
|
+
* only when the AUTHOR stated one, whichever way.
|
|
58
|
+
*/
|
|
59
|
+
function formatElement(element) {
|
|
60
|
+
const bare = formatBare(element);
|
|
61
|
+
return element.scope ? `within(${formatElement(element.scope)}, ${bare})` : bare;
|
|
62
|
+
}
|
|
63
|
+
function formatBare(element) {
|
|
64
|
+
const args = [];
|
|
65
|
+
if (element.name !== void 0) args.push(JSON.stringify(element.name));
|
|
66
|
+
if (element.exact !== void 0) args.push(`{ exact: ${String(element.exact)} }`);
|
|
67
|
+
return `${CONSTRUCTORS[element.kind]}(${args.join(", ")})`;
|
|
68
|
+
}
|
|
69
|
+
/** `1. <a href="/articles">Articles</a> in <nav>` — one evidence line per candidate. */
|
|
70
|
+
function formatMatch(match, index) {
|
|
71
|
+
const attribute = match.detail ? ` ${quoteDetail(match)}` : "";
|
|
72
|
+
const named = match.accessibleName ? ` named ${JSON.stringify(match.accessibleName)}` : "";
|
|
73
|
+
const context = match.context ? ` in <${match.context}>` : "";
|
|
74
|
+
return ` ${index + 1}. <${match.tag}${attribute}>${match.text}</${match.tag}>${named}${context}`;
|
|
75
|
+
}
|
|
76
|
+
function quoteDetail(match) {
|
|
77
|
+
return `${match.tag === "a" ? "href" : "name"}=${JSON.stringify(match.detail)}`;
|
|
78
|
+
}
|
|
79
|
+
/** Each landmark the candidates sit in, and how many of them it holds. */
|
|
80
|
+
function landmarkCounts(matches) {
|
|
81
|
+
const counts = /* @__PURE__ */ new Map();
|
|
82
|
+
for (const match of matches) {
|
|
83
|
+
const landmark = match.context === void 0 ? void 0 : CONTEXT_LANDMARKS[match.context];
|
|
84
|
+
if (landmark !== void 0) counts.set(landmark, (counts.get(landmark) ?? 0) + 1);
|
|
85
|
+
}
|
|
86
|
+
return [...counts].toSorted(([, a], [, b]) => a - b);
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The rewrites worth offering, in the order they should be tried. Scoping is
|
|
90
|
+
* always available; `exact` only when it would actually narrow the set, and
|
|
91
|
+
* the count it would leave is stated so a still-ambiguous suggestion never
|
|
92
|
+
* reads as a fix.
|
|
93
|
+
*/
|
|
94
|
+
function formatFixes(element, matches) {
|
|
95
|
+
const fixes = [];
|
|
96
|
+
const narrowing = landmarkCounts(matches).filter(([, count]) => count < matches.length);
|
|
97
|
+
if (!element.scope) {
|
|
98
|
+
const [first, ...rest] = narrowing;
|
|
99
|
+
const alsoHere = rest.length > 0 ? ` [also here: ${rest.map(([name]) => name).join(", ")}]` : "";
|
|
100
|
+
fixes.push(first === void 0 ? `scope it within(<a container holding only this one>, ${formatBare(element)})` : `scope it within(${first[0]}, ${formatBare(element)}) [leaves ${first[1]} of ${matches.length}]${alsoHere}`);
|
|
101
|
+
}
|
|
102
|
+
if (element.exact === false && element.name !== void 0) {
|
|
103
|
+
const remaining = matches.filter((match) => match.text === element.name).length;
|
|
104
|
+
if (remaining > 0 && remaining < matches.length) fixes.push(`exact name ${formatBare({
|
|
105
|
+
...element,
|
|
106
|
+
exact: true
|
|
107
|
+
})} [leaves ${remaining} of ${matches.length}]`);
|
|
108
|
+
}
|
|
109
|
+
fixes.push("other element a heading(), button() or field() may name one thing where this does not");
|
|
110
|
+
return fixes;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The W3 refusal. A distinct class so the visit wrapper can recognize an
|
|
114
|
+
* already-complete message and let it through instead of nesting it inside
|
|
115
|
+
* `visit scenario failed: …`, which would print the whole thing twice.
|
|
116
|
+
*/
|
|
117
|
+
var AmbiguousElementError = class extends Error {
|
|
118
|
+
name = "AmbiguousElementError";
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Build the refusal thrown when a descriptor matches more than one element.
|
|
122
|
+
*
|
|
123
|
+
* @param options.element The descriptor as the caller wrote it.
|
|
124
|
+
* @param options.matches Every candidate, in DOM order (already truncated).
|
|
125
|
+
* @param options.url The page the visitor was on when it happened.
|
|
126
|
+
*/
|
|
127
|
+
function describeAmbiguity(options) {
|
|
128
|
+
const { element, matches, url } = options;
|
|
129
|
+
const fixes = formatFixes(element, matches).map((fix) => ` • ${fix}`);
|
|
130
|
+
return [
|
|
131
|
+
`Ambiguous element: ${formatElement(element)} matched ${matches.length} elements on ${url}.`,
|
|
132
|
+
"",
|
|
133
|
+
"A spec must designate exactly one element. Acting on the first match would let",
|
|
134
|
+
"this test keep passing while the visitor interacts with something else.",
|
|
135
|
+
"",
|
|
136
|
+
"Matched:",
|
|
137
|
+
...matches.map((match, index) => formatMatch(match, index)),
|
|
138
|
+
"",
|
|
139
|
+
"Disambiguate with one of:",
|
|
140
|
+
...fixes,
|
|
141
|
+
"",
|
|
142
|
+
"Docs: docs/13-elements.md#designating-exactly-one-element (CONVENTIONS W3)"
|
|
143
|
+
].join("\n");
|
|
144
|
+
}
|
|
145
|
+
//#endregion
|
|
146
|
+
//#region src/model/elements/substring-warning.ts
|
|
147
|
+
/**
|
|
148
|
+
* The transitional warning for a name that matches only as a SUBSTRING.
|
|
149
|
+
*
|
|
150
|
+
* Until 16.0 a descriptor's name matched any element whose accessible name
|
|
151
|
+
* CONTAINED it, so `link('Articles')` also designated "Read Articles" — and a
|
|
152
|
+
* test could pass for years against the wrong element without anyone noticing,
|
|
153
|
+
* because the wrong element was usually next to the right one. Exact is the
|
|
154
|
+
* default now.
|
|
155
|
+
*
|
|
156
|
+
* A lint rule cannot carry this: whether a name matched whole or in part is a
|
|
157
|
+
* fact about a RUN, and a verdict fed by a previous run's artefact is not
|
|
158
|
+
* deterministic. So it is a runtime warning, printed by the adapter that did
|
|
159
|
+
* the looking, at the moment it has both answers in hand — nothing found under
|
|
160
|
+
* exact, something found under substring.
|
|
161
|
+
*
|
|
162
|
+
* It is printed ONCE per descriptor per process: a scenario that clicks the
|
|
163
|
+
* same link in ten tests has one thing to fix, not ten lines to read.
|
|
164
|
+
*/
|
|
165
|
+
/** How long the window lasts, stated in the message itself. */
|
|
166
|
+
const WINDOW = "through 16.x";
|
|
167
|
+
/** The node side's printer — the default every adapter but the page's uses. */
|
|
168
|
+
function toStandardError(line) {
|
|
169
|
+
if (typeof process === "undefined") {
|
|
170
|
+
console.warn(line);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
process.stderr.write(`${line}\n`);
|
|
174
|
+
}
|
|
175
|
+
/** Descriptors already reported in this process. */
|
|
176
|
+
const reported = /* @__PURE__ */ new Set();
|
|
177
|
+
/** The identity a descriptor is deduplicated by — kind, name, and its scope chain. */
|
|
178
|
+
function identityOf(element) {
|
|
179
|
+
const own = `${element.kind}:${element.name ?? ""}`;
|
|
180
|
+
return element.scope === void 0 ? own : `${identityOf(element.scope)}>${own}`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Report that `element` designated nothing as a whole name, but would have
|
|
184
|
+
* designated something as a substring. Returns whether a line was printed,
|
|
185
|
+
* which is what the adapters' own tests assert on.
|
|
186
|
+
*/
|
|
187
|
+
async function warnSubstringOnly(element, where, found, print = toStandardError) {
|
|
188
|
+
const identity = identityOf(element);
|
|
189
|
+
if (reported.has(identity)) return false;
|
|
190
|
+
reported.add(identity);
|
|
191
|
+
const spelling = found === void 0 ? "" : ` The name to write is '${found.replaceAll(/\s+/gu, " ").trim()}'.`;
|
|
192
|
+
await print(`@jterrazz/test: ${element.kind}('${element.name ?? ""}') matched nothing as a whole accessible name, and matched only as a SUBSTRING of a longer one (${where}).${spelling} Names are exact from 16.0: write the name in full, scope it with \`within(…)\`, or state \`{ exact: false }\` to keep the old behaviour. The substring is still resolved, with this warning, ${WINDOW}; gone in 17.0, where the same descriptor is simply not found.`);
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* How long a descriptor is given to ARRIVE before the window concludes it is
|
|
197
|
+
* absent — the budget `settles` spends and no more.
|
|
198
|
+
*
|
|
199
|
+
* A count is one observation, and a verb called right after a navigating
|
|
200
|
+
* `click()` takes it on the page being LEFT. The window may not decide
|
|
201
|
+
* "nothing matches this whole name" on that reading: the destination has not
|
|
202
|
+
* been parsed yet. Long enough for a navigation to commit, far short of the
|
|
203
|
+
* verb's own 30 s, and paid only when there is a substring to widen to.
|
|
204
|
+
*/
|
|
205
|
+
const WINDOW_SETTLE_MS = 5e3;
|
|
206
|
+
/**
|
|
207
|
+
* The descriptor to retry with, LEVEL by level, or `undefined` when nothing
|
|
208
|
+
* would change.
|
|
209
|
+
*
|
|
210
|
+
* Every level of the chain is asked the same question the target is asked —
|
|
211
|
+
* `within(button('Proof of authorship'), content('Verified'))` against a
|
|
212
|
+
* button named "Proof of authorship Verified" failed on the SCOPE, and a
|
|
213
|
+
* warning that only ever widened the target printed nothing at all. The chain
|
|
214
|
+
* is walked outside-in and rebuilt as it goes, since an inner level's answer
|
|
215
|
+
* is meaningless under a scope that designates nothing.
|
|
216
|
+
*
|
|
217
|
+
* A level that states an `exact` of its own is left alone: an author who wrote
|
|
218
|
+
* `{ exact: false }` chose the substring, and one who wrote `{ exact: true }`
|
|
219
|
+
* was already exact.
|
|
220
|
+
*
|
|
221
|
+
* Asked BEFORE the action, not after it fails: a verb waits its whole
|
|
222
|
+
* actionability budget, which is the test's own budget, so a retry afterwards
|
|
223
|
+
* never runs — the test is already over. The common case (the name matches
|
|
224
|
+
* whole) costs one count per level, which waits for nothing.
|
|
225
|
+
*/
|
|
226
|
+
async function widenedForWindow(element, where, probe) {
|
|
227
|
+
const chain = [];
|
|
228
|
+
for (let level = element; level; level = level.scope) chain.unshift(level);
|
|
229
|
+
let rebuilt;
|
|
230
|
+
let widened = false;
|
|
231
|
+
for (const level of chain) {
|
|
232
|
+
const candidate = {
|
|
233
|
+
...level,
|
|
234
|
+
...rebuilt === void 0 ? {} : { scope: rebuilt }
|
|
235
|
+
};
|
|
236
|
+
const loose = await widenedLevel(candidate, where, probe);
|
|
237
|
+
widened ||= loose !== void 0;
|
|
238
|
+
rebuilt = loose ?? candidate;
|
|
239
|
+
}
|
|
240
|
+
return widened ? rebuilt : void 0;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The one level, widened and warned about, or `undefined` when it designates
|
|
244
|
+
* something already, states its own `exact`, carries no name, would designate
|
|
245
|
+
* nothing either way, or turns out to be exact on the surface that ARRIVES.
|
|
246
|
+
*
|
|
247
|
+
* The last is the navigation case, and it is the reason the verdict is not two
|
|
248
|
+
* counts: a `see()` fired right after a navigating `click()` counts the page
|
|
249
|
+
* being left, so a descriptor that is perfectly exact on the DESTINATION read
|
|
250
|
+
* as substring-only — non-deterministically, since which page each count
|
|
251
|
+
* landed on was a race. A widening is therefore only decided once the exact
|
|
252
|
+
* descriptor has been given the settle budget and still designates nothing.
|
|
253
|
+
* The tell of the old fault was the printed name equalling the name written.
|
|
254
|
+
*/
|
|
255
|
+
async function widenedLevel(candidate, where, probe) {
|
|
256
|
+
if (candidate.exact !== void 0 || candidate.name === void 0) return;
|
|
257
|
+
try {
|
|
258
|
+
if (await probe.count(candidate) > 0) return;
|
|
259
|
+
const loose = {
|
|
260
|
+
...candidate,
|
|
261
|
+
exact: false
|
|
262
|
+
};
|
|
263
|
+
if (await probe.count(loose) !== 1) return;
|
|
264
|
+
if (!alreadyJudged(candidate) && await probe.settles(candidate)) return;
|
|
265
|
+
await warnSubstringOnly(candidate, where, await probe.nameOf(loose), probe.print);
|
|
266
|
+
return loose;
|
|
267
|
+
} catch {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Has this descriptor already been judged substring-only in this run?
|
|
273
|
+
*
|
|
274
|
+
* The verdict is made once: a scenario that clicks the same link in ten tests
|
|
275
|
+
* pays the settle budget on the first of them and is widened straight away on
|
|
276
|
+
* the nine after it.
|
|
277
|
+
*/
|
|
278
|
+
function alreadyJudged(element) {
|
|
279
|
+
return reported.has(identityOf(element));
|
|
280
|
+
}
|
|
281
|
+
//#endregion
|
|
282
|
+
export { describeAmbiguity as a, AmbiguousElementError as i, warnSubstringOnly as n, formatElement as o, widenedForWindow as r, WINDOW_SETTLE_MS as t };
|
package/dist/vitest.js
CHANGED
|
@@ -214,6 +214,60 @@ function rootDefaults() {
|
|
|
214
214
|
}
|
|
215
215
|
};
|
|
216
216
|
}
|
|
217
|
+
/** A glob list with its repeats taken out, or nothing where there is no list. */
|
|
218
|
+
function eachOnce(globs) {
|
|
219
|
+
return globs === void 0 ? void 0 : [...new Set(globs)];
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* The glob lists vite CONCATENATES rather than overrides, each glob kept once.
|
|
223
|
+
*
|
|
224
|
+
* A project built ON the defaults and then merged INTO them by
|
|
225
|
+
* `defineSpecConfig` carried every default twice. Glob matching is idempotent,
|
|
226
|
+
* so nothing collected differently — but the no-tests banner is the one place a
|
|
227
|
+
* consumer ever reads that list, and it read as three merges of the same three
|
|
228
|
+
* globs.
|
|
229
|
+
*/
|
|
230
|
+
function statedOnce(test) {
|
|
231
|
+
const exclude = eachOnce(test.exclude);
|
|
232
|
+
const include = eachOnce(test.include);
|
|
233
|
+
return {
|
|
234
|
+
...test,
|
|
235
|
+
...exclude === void 0 ? {} : { exclude },
|
|
236
|
+
...include === void 0 ? {} : { include }
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Build a project ON the defaults — the ONE way a project is built here.
|
|
241
|
+
*
|
|
242
|
+
* @internal
|
|
243
|
+
*/
|
|
244
|
+
function onProjectDefaults(stated) {
|
|
245
|
+
const merged = mergeConfig(projectDefaults(), stated);
|
|
246
|
+
return merged.test === void 0 ? merged : {
|
|
247
|
+
...merged,
|
|
248
|
+
test: statedOnce(merged.test)
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Is every project an inline object — one this preset merged its defaults into?
|
|
253
|
+
* A project named by a glob or handed as a promise carries a config this call
|
|
254
|
+
* never saw, and the root's exclusions are the only ones it gets from here.
|
|
255
|
+
*/
|
|
256
|
+
function allInline(projects) {
|
|
257
|
+
return projects.every((project) => typeof project === "object" && project !== null && !("then" in project));
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The root's exclusions, beside projects that already carry them.
|
|
261
|
+
*
|
|
262
|
+
* With `projects`, the root collects nothing itself: its `exclude` only travels
|
|
263
|
+
* INTO each project, where vitest CONCATENATES it with the project's own — so
|
|
264
|
+
* every preset glob reached the banner twice. The preset's own are dropped
|
|
265
|
+
* here, since each inline project states them in full; what the CONSUMER stated
|
|
266
|
+
* at the root stays, because no project holds a copy of that.
|
|
267
|
+
*/
|
|
268
|
+
function rootExcludeBeside(projects, exclude) {
|
|
269
|
+
return allInline(projects) ? exclude.filter((glob) => !EXCLUDE.includes(glob)) : exclude;
|
|
270
|
+
}
|
|
217
271
|
/**
|
|
218
272
|
* A project inherits NOTHING from the root `test` block — vitest resolves each
|
|
219
273
|
* project as its own config — so the defaults are merged into every inline one.
|
|
@@ -222,19 +276,26 @@ function rootDefaults() {
|
|
|
222
276
|
*/
|
|
223
277
|
function withProjectDefaults(project) {
|
|
224
278
|
if (typeof project !== "object" || project === null || "then" in project) return project;
|
|
225
|
-
return
|
|
279
|
+
return onProjectDefaults(project);
|
|
226
280
|
}
|
|
227
281
|
function defineSpecConfig(config = {}) {
|
|
228
282
|
const { literate: literateOptions, ...userConfig } = config;
|
|
229
283
|
const stated = literateOptions ? mergeConfig({ plugins: [literate(literateOptions)] }, userConfig) : userConfig;
|
|
230
284
|
const merged = mergeConfig(rootDefaults(), stated);
|
|
231
|
-
const
|
|
232
|
-
if (
|
|
285
|
+
const root = merged.test === void 0 ? void 0 : statedOnce(merged.test);
|
|
286
|
+
if (root === void 0) return merged;
|
|
287
|
+
const { projects } = root;
|
|
288
|
+
if (projects === void 0) return {
|
|
289
|
+
...merged,
|
|
290
|
+
test: root
|
|
291
|
+
};
|
|
292
|
+
const inline = projects.map(withProjectDefaults);
|
|
233
293
|
return {
|
|
234
294
|
...merged,
|
|
235
295
|
test: {
|
|
236
|
-
...
|
|
237
|
-
|
|
296
|
+
...root,
|
|
297
|
+
...root.exclude === void 0 ? {} : { exclude: rootExcludeBeside(inline, root.exclude) },
|
|
298
|
+
projects: inline
|
|
238
299
|
}
|
|
239
300
|
};
|
|
240
301
|
}
|
|
@@ -242,7 +303,7 @@ function defineSpecConfig(config = {}) {
|
|
|
242
303
|
//#region src/runner/facet-project.ts
|
|
243
304
|
/** The globs a node facet collects, and the ones a caller stated instead. */
|
|
244
305
|
function facetProject(facet, options) {
|
|
245
|
-
return
|
|
306
|
+
return onProjectDefaults({ test: {
|
|
246
307
|
...options.exclude === void 0 ? {} : { exclude: options.exclude },
|
|
247
308
|
include: options.include ?? [`specs/${facet}/**/*.spec.ts`],
|
|
248
309
|
...options.serial === true ? { fileParallelism: false } : {},
|
|
@@ -289,6 +350,23 @@ function goldenPath(testPath, name) {
|
|
|
289
350
|
const ariaTree = async (context) => {
|
|
290
351
|
return await (await context.frame()).locator("body").ariaSnapshot();
|
|
291
352
|
};
|
|
353
|
+
/**
|
|
354
|
+
* The ARIA snapshot of ONE node, named by the selector the page's locator
|
|
355
|
+
* carries — the accessible name, as the browser computed it, for a page that
|
|
356
|
+
* cannot compute one itself.
|
|
357
|
+
*/
|
|
358
|
+
const ariaNode = async (context, selector) => {
|
|
359
|
+
return await (await context.frame()).locator(selector).ariaSnapshot();
|
|
360
|
+
};
|
|
361
|
+
/**
|
|
362
|
+
* A line the page has for whoever is running the suite, written on the node
|
|
363
|
+
* side's stderr — the one channel vitest's default reporter passes through.
|
|
364
|
+
* The console is captured and dropped, so a page that printed to it printed to
|
|
365
|
+
* nobody.
|
|
366
|
+
*/
|
|
367
|
+
const notify = (_context, line) => {
|
|
368
|
+
process.stderr.write(`${line}\n`);
|
|
369
|
+
};
|
|
292
370
|
/** The golden's current content, or `null` when it does not exist yet. */
|
|
293
371
|
const goldenRead = (context, name) => {
|
|
294
372
|
const path = goldenPath(context.testPath, name);
|
|
@@ -306,9 +384,11 @@ const goldenWrite = (context, name, content) => {
|
|
|
306
384
|
* page calls and the names the project registers cannot drift.
|
|
307
385
|
*/
|
|
308
386
|
const COMPONENT_COMMANDS = {
|
|
387
|
+
ariaNode,
|
|
309
388
|
ariaTree,
|
|
310
389
|
goldenRead,
|
|
311
|
-
goldenWrite
|
|
390
|
+
goldenWrite,
|
|
391
|
+
notify
|
|
312
392
|
};
|
|
313
393
|
//#endregion
|
|
314
394
|
//#region src/facets/component/component.project.ts
|
|
@@ -578,7 +658,7 @@ async function component(options = {}) {
|
|
|
578
658
|
const { playwright } = await import("@vitest/browser-playwright");
|
|
579
659
|
const pipeline = options.vite === void 0 ? {} : await reroot(options.vite, root);
|
|
580
660
|
const consumerPlugins = Array.isArray(pipeline.plugins) ? pipeline.plugins : [];
|
|
581
|
-
|
|
661
|
+
return onProjectDefaults({
|
|
582
662
|
...pipeline,
|
|
583
663
|
...jsxPipeline(pipeline),
|
|
584
664
|
optimizeDeps: { include: SEAM_DEPENDENCIES.filter(resolves) },
|
|
@@ -605,8 +685,7 @@ async function component(options = {}) {
|
|
|
605
685
|
sequence: { groupOrder: 2 },
|
|
606
686
|
...options.timeout === void 0 ? {} : { testTimeout: options.timeout }
|
|
607
687
|
}
|
|
608
|
-
};
|
|
609
|
-
return mergeConfig(projectDefaults(), project);
|
|
688
|
+
});
|
|
610
689
|
}
|
|
611
690
|
//#endregion
|
|
612
691
|
//#region src/facets/integration/integration.project.ts
|
|
@@ -638,7 +717,7 @@ function mobile(options = {}) {
|
|
|
638
717
|
* a 2-vCPU runner.
|
|
639
718
|
*/
|
|
640
719
|
function website(options = {}) {
|
|
641
|
-
return
|
|
720
|
+
return onProjectDefaults({ test: {
|
|
642
721
|
...options.exclude === void 0 ? {} : { exclude: options.exclude },
|
|
643
722
|
...options.serial === true ? { fileParallelism: false } : {},
|
|
644
723
|
include: options.include ?? ["specs/website/**/*.spec.ts"],
|
|
@@ -660,7 +739,7 @@ function website(options = {}) {
|
|
|
660
739
|
*/
|
|
661
740
|
function unit(options = {}) {
|
|
662
741
|
const fromRoots = options.roots?.map((root) => `${root.replace(/\/+$/u, "")}/**/*.test.ts`);
|
|
663
|
-
return
|
|
742
|
+
return onProjectDefaults({ test: {
|
|
664
743
|
exclude: options.exclude ?? ["specs/**", "**/*.test.tsx"],
|
|
665
744
|
include: options.include ?? fromRoots ?? ["**/*.test.ts"],
|
|
666
745
|
...options.serial === true ? { fileParallelism: false } : {},
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# 01 — Architecture
|
|
2
|
+
|
|
3
|
+
What this package IS: one runner model behind six constructors, one chain that needs none, five source trees with declared edges, and seven channels through which its conventions are enforced — each row of the catalogue naming exactly one. The chapters that follow explain how to USE each facet; this one draws the lines they sit inside.
|
|
4
|
+
|
|
5
|
+
| The shape | Held below |
|
|
6
|
+
| -------------------------- | -------------------------------------------------------------------------------- |
|
|
7
|
+
| The runner model | [The runner model](#the-runner-model) — constructor, handle, chain, result |
|
|
8
|
+
| The source trees | [The five trees](#the-five-trees) — `model`, `facets`, `seams`, `runner`, `lint` |
|
|
9
|
+
| How a convention is held | [The seven enforcement channels](#the-seven-enforcement-channels) |
|
|
10
|
+
| What ships out of the tree | [What the tree publishes](#what-the-tree-publishes) |
|
|
11
|
+
|
|
12
|
+
The rules themselves — what each family says, what a reviewer must judge — are the constitution, [18 — Conventions](18-conventions.md); their normative sentences are the generated catalogue, [19 — Linting](19-linting.md).
|
|
13
|
+
|
|
14
|
+
## The runner model
|
|
15
|
+
|
|
16
|
+
Every facet is the same four steps. A **constructor** takes options and returns a **handle**; the handle opens a **chain** of zero or more setups closed by exactly one terminal action; the action executes and resolves to a **typed result**; the result is asserted through vitest's `expect()`. Nothing else is public — there is no imperative escape hatch, because a spec that can do anything proves nothing in particular.
|
|
17
|
+
|
|
18
|
+
Six constructors exist and the list is closed (`src/facets/specification.ts`):
|
|
19
|
+
|
|
20
|
+
| Constructor | Handle destructures to | Subject under test |
|
|
21
|
+
| ----------------------------- | ---------------------------------- | ------------------------------------------------ |
|
|
22
|
+
| `specification.api()` | `{ api, cleanup, docker }` | An HTTP API, built and served in this process |
|
|
23
|
+
| `specification.jobs()` | `{ jobs, cleanup }` | A background pipeline, triggered by name |
|
|
24
|
+
| `specification.cli()` | `{ cli, cleanup, docker }` | A command binary in a fresh temp directory |
|
|
25
|
+
| `specification.integration()` | `{ integration, cleanup, docker }` | A module against real services, or a golden |
|
|
26
|
+
| `specification.website()` | `{ website, cleanup, url }` | A rendered page — fetched, or driven in chromium |
|
|
27
|
+
| `specification.mobile()` | `{ mobile, cleanup, udid }` | A native screen on an iOS simulator |
|
|
28
|
+
|
|
29
|
+
The asymmetry in that column is the model, not an oversight: `jobs` never spawns a container, so it is handed no `docker`; `website` and `mobile` drive a browser and a simulator rather than an orchestrated stack, so they carry neither.
|
|
30
|
+
|
|
31
|
+
**One facet has no constructor**, and the same reason explains it: a rendered component starts nothing. `component` ([07 — Component specs](07-component.md)) is a chain the package exports directly — `component.intercept(…).render(<X />)` — so there is no handle to destructure and no `cleanup` to pass to `afterAll`. What every render of a project shares (the providers, the Vite pipeline, the viewport) is the PROJECT's, stated once by `component()` in `vitest.config.ts`.
|
|
32
|
+
|
|
33
|
+
A runner is created **once per suite**, in a `*.specification.ts` file, and imported by the test files beside it. That split is what makes the container lifecycle affordable — one Postgres per suite, not one per test — and it is why `afterAll(cleanup)` belongs in the specification file and nowhere else.
|
|
34
|
+
|
|
35
|
+
### The seam under the chain
|
|
36
|
+
|
|
37
|
+
`SpecificationBuilder` (`src/model/chain/builder.ts`) holds the chain for every facet; each facet contributes its own setups and its own terminal actions on top. The infrastructure a chain needs is reached through **ports** — `src/model/ports/` declares eight of them (`browser`, `cli`, `container`, `database`, `device`, `isolation`, `server`, `service`) — and an integration implements one. So the chain knows "a database exists"; it never knows Postgres.
|
|
38
|
+
|
|
39
|
+
Every seam is opened lazily rather than imported, and each one is an optional peer loaded by the one module that owns it: `playwright` for `.visit()`, `appium`/`webdriverio` for `.open()`, `@vitest/browser-playwright` + `vitest-browser-react` + `react` for `component.render()`, and — since 16.0 — `better-sqlite3`, `pg`, `redis` and `testcontainers` for the services a record declares. A project that tests no page, no component and no database installs none of them; `src/seams/peer.ts` owns the one message a missing peer produces, which names the facet that asked, the command, and under pnpm the `onlyBuiltDependencies` line a native binding needs.
|
|
40
|
+
|
|
41
|
+
## The five trees
|
|
42
|
+
|
|
43
|
+
The source tree is five trees with declared, one-directional edges. The map is not the linter's to assume — `i1-layer-boundaries` ships inert — so this package declares its own as `FRAMEWORK_LAYERS` in `oxlint.config.ts`, and that declaration is the enforced statement of what follows.
|
|
44
|
+
|
|
45
|
+
| Tree | May import | Holds |
|
|
46
|
+
| --------- | --------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
47
|
+
| `model/` | itself, the docker/process/yaml seams, the msw seam through two named lazy imports, `runner/update`, and each facet's `*.result` module | What every facet is made of: the chain and the orchestrator, the results and their accessors, the element vocabulary, the contracts and `intercept()`, the `{{token}}` engine, the goldens, the clock, the doubles, the ports |
|
|
48
|
+
| `facets/` | `model/`, itself, the runner's project shape, and the one seam each constructor opens | Seven folders, the SAME four files in each: `<facet>.specification.ts` (the constructor), `<facet>.chain.ts`, `<facet>.result.ts`, `<facet>.project.ts` (the vitest helper), plus what is unique to the facet |
|
|
49
|
+
| `seams/` | its OWN external dependency, plus `model/` and the facet vocabulary an adapter projects into | One folder per dependency: `postgres`, `redis`, `sqlite`, `testcontainers`, `docker`, `hono`, `playwright`, `vitest-browser`, `appium`, `msw`, `openai`, `anthropic`, `yaml`, `process` |
|
|
50
|
+
| `runner/` | `vitest`, `vite`, `@vitest/browser-playwright`, `model/`, `facets/` | The CONFIG side of the runner coupling: the preset, update mode, the `literate()` plugin, the shape a node facet's project takes, and the project index |
|
|
51
|
+
| `lint/` | itself, plus a short list of PURE `model/` modules | The tool-facing channel: the oxlint plugin, the conventions checker, the catalogue manifest and generator |
|
|
52
|
+
|
|
53
|
+
**Every facet folder carries the same four files, whether or not it owns all four.** The setups and terminal actions of the six node facets are ONE builder, because a second copy of "declare a contract, seed a table, run the thing" is how two facets drift — so `api.chain.ts` and its five siblings are a named re-export of that builder rather than an implementation, and `jobs.result.ts` re-exports the shape the builder states. The shape is uniform so that a reader who has opened one facet has opened all seven; the re-export says, in the folder itself, which of the four a facet does not own.
|
|
54
|
+
|
|
55
|
+
Three of those edges carry their reason in the declaration itself. `model/` reaches `seams/docker`, `seams/process` and the yaml parser because none of the three has a dependency of its own to leak, and it reaches each facet's `*.result` module because the ONE builder constructs all seven results — a value edge, stated rather than hidden. `lint/` reaches exactly the pure modules the runner also uses — the token list, the ground names, the root walk, the `<case>.spec.yaml` parser — so that the file the lint accepts is the file the runner runs, from ONE parser. And the runner is named in exactly TWO places outside a test, one per side of it: `runner/` for the config side — the preset, the project shape, the plugin — and `seams/vitest-browser/` for the page side, where the locators, the visitor, the golden commands and the page's own `vitest` primitives live. Swapping the runner would be a rewrite of those two folders and of nothing else; `model/` states what a render IS and never which runner performs it, which is why the layer map exempts that seam from the prod-import ban (F2) by name.
|
|
56
|
+
|
|
57
|
+
The table's May-import column is a reading of `FRAMEWORK_LAYERS`, never a second statement of it: the declaration in `oxlint.config.ts` is what the linter enforces and what a change edits.
|
|
58
|
+
|
|
59
|
+
There are TWO composition roots and neither names a layer. `src/index.ts` is node's: it wires the container integrations into the registry seam and re-exports the public surface. `src/browser/index.ts` is the page's, and `src/surface.ts` is what they agree on — see [What the tree publishes](#what-the-tree-publishes).
|
|
60
|
+
|
|
61
|
+
## The seven enforcement channels
|
|
62
|
+
|
|
63
|
+
A convention this package states is held by a **channel**, and every row of the catalogue names exactly one of seven. The guiding aim is that most enforcement is programmatic, not review-borne.
|
|
64
|
+
|
|
65
|
+
- **statique** — the `jterrazz/*` oxlint plugin: one file per rule under `src/lint/rules/model/` or `src/lint/rules/<facet>/` — the tree the rule reaches, which is also the `facet` field of its manifest row — AST analysis, one diagnostic per file.
|
|
66
|
+
- **upstream** — a convention an oxlint vitest-plugin rule already holds, set as an OPTION from the `testing` fragment rather than duplicated as a rule of ours ([ADR-005](decisions/005-the-doubles-ladder-is-closed.md)).
|
|
67
|
+
- **checker** — passes of the `jterrazz-test-check` binary reading what an AST cannot: the `{{token}}` grammar of `_requests/` and `_expected/` fixtures, the `<case>.spec.yaml` document family, cross-file analyses that cross a `*.specification.ts` with its tests, and the member pass that judges a package rather than a tree.
|
|
68
|
+
- **runtime** — the framework refuses incorrect usage as it executes, where static analysis abstains (a non-literal argument) or cannot reach (the network, a container lifecycle).
|
|
69
|
+
- **type** — what the compiler refuses with no rule at all, proven by `src/type-channel.test-d.ts`.
|
|
70
|
+
- **meta** — what the framework's own suite holds about itself: the catalogue's freshness, its completeness, the reach of its rules — [03 — Testing](03-testing.md) owns the channel.
|
|
71
|
+
- **process** — the judgement no single channel settles: asset-driven grouping, golden-file discipline, retro-propagation.
|
|
72
|
+
|
|
73
|
+
The channel a rule sits on is not prose here: it is a field of `src/lint/manifest.ts`, the single source of truth from which the catalogue in [19 — Linting](19-linting.md) is generated. A rule and its normative sentence live together in the code, so the two cannot drift.
|
|
74
|
+
|
|
75
|
+
## What the tree publishes
|
|
76
|
+
|
|
77
|
+
The bundle is built by `tsdown` into `dist/` and its shape follows how each entry is consumed. `index` and `vitest`, plus the `checker`/`catalog` CLIs, are ESM-only — vitest is ESM-only and the CLIs are invoked as `node dist/*.js`. The `oxlint` plugin ships dual, because oxlint loads it from a consumer project that may itself be CommonJS.
|
|
78
|
+
|
|
79
|
+
**There are FOUR entries and no more**: `@jterrazz/test` for everything a spec uses, `@jterrazz/test/vitest` for what `vitest.config.ts` needs, `@jterrazz/test/oxlint` for the lint plugin, `@jterrazz/test/schema` for an editor validating a `<case>.spec.yaml`. That is rule F1 rather than a convention of taste, and the exemption is derived from the manifest's own `exports` map (`src/lint/package-exports.ts`), so a subpath is exempt the moment it is published and stops being exempt the moment it is withdrawn.
|
|
80
|
+
|
|
81
|
+
**The root entry has two runtimes.** A component test renders in a real browser, and the node entry cannot load there: it wires `pg`, `better-sqlite3`, `testcontainers` and `node:fs` at import. So `exports["."]` carries a `browser` condition resolving to `dist/browser/index.js` — the same public surface, built for a page, where every node-only name (the six constructors, the services, the docker accessors, the results that walk a disk) is a stub that throws where it was called, naming the facet and saying it runs under node. The split is the RUNTIME's and never the facets': one specifier, so no spec has to remember which entry it may import.
|
|
82
|
+
|
|
83
|
+
`types` is stated once, and it is the node build's — so the two runtimes are ONE type surface and a name cannot exist on one side only. What both agree on lives in `src/surface.ts`, and the `package-exports` meta-test compares the two builds' exported names on every run.
|
|
84
|
+
|
|
85
|
+
Two committed projections leave the code and land in the corpus: the API reference under `docs/reference/` (typedoc, through `typescript docs`) and the rule catalogue spliced into [19 — Linting](19-linting.md) and `skills/jterrazz-test/references/rules.md`. A third, `schema/spec.schema.json`, is generated from the document grammar's own constants. Both `docs/` and `schema/` ship in the tarball — the reference included, since the corpus links into it — so a consumer reads the manual where the messages send them, inside the install ([04 — Operating](04-operating.md#what-leaves-the-tree)). All three are regenerated by one gesture and sync-checked — see [02 — Developing](02-developing.md).
|
|
86
|
+
|
|
87
|
+
## Pitfalls
|
|
88
|
+
|
|
89
|
+
- **Reaching for a dependency from `model/`.** A new external package belongs in a folder of `seams/` that imports it and nothing else; `model/` importing it directly is the one boundary this package cannot afford to blur, and `i1-layer-boundaries` refuses it.
|
|
90
|
+
- **Adding a seventh constructor.** The six are the closed vocabulary the conventions, the linter and the documentation are all shaped around. A new SUBJECT to specify is a design decision, and it earns a record in [`decisions/`](decisions/) before it earns a constructor — as `integration` did in [ADR-004](decisions/004-a-module-against-real-services-or-a-golden-is-the-integration-facet.md).
|
|
91
|
+
- **Writing a rule's normative sentence into a chapter.** It belongs in `src/lint/manifest.ts` beside the implementation; a chapter that restates it is the second copy the whole design exists to prevent.
|
|
92
|
+
|
|
93
|
+
## Related
|
|
94
|
+
|
|
95
|
+
[02 — Developing](02-developing.md) · [03 — Testing](03-testing.md) · [18 — Conventions](18-conventions.md) · [19 — Linting](19-linting.md)
|