@jterrazz/test 15.1.1 → 15.2.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/dist/ambiguity.js +23 -4
- package/dist/artifacts.js +68 -160
- package/dist/browser/index.js +2477 -0
- package/dist/checker.js +9 -3
- package/dist/index.d.ts +807 -497
- package/dist/index.js +3034 -2770
- package/dist/intercept.js +108 -58
- package/dist/oxlint.cjs +434 -21
- package/dist/oxlint.d.cts +13 -1
- package/dist/oxlint.d.ts +13 -1
- package/dist/oxlint.js +434 -21
- package/dist/playwright.adapter.js +95 -22
- package/dist/queue.js +1 -1
- package/dist/vitest.d.ts +100 -2
- package/dist/vitest.js +375 -3
- package/package.json +37 -5
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import { n as describeAmbiguity, t as AmbiguousElementError } from "./ambiguity.js";
|
|
1
|
+
import { n as describeAmbiguity, r as formatElement, t as AmbiguousElementError } from "./ambiguity.js";
|
|
2
2
|
import { mkdtempSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
+
import { setTimeout } from "node:timers/promises";
|
|
5
6
|
//#region src/integrations/playwright/playwright.adapter.ts
|
|
6
7
|
/** How many candidates the ambiguity error enumerates before truncating. */
|
|
7
8
|
const MAX_REPORTED_MATCHES = 10;
|
|
9
|
+
const VALUE_TIMEOUT_MS = 3e4;
|
|
10
|
+
const VALUE_POLL_MS = 100;
|
|
8
11
|
/**
|
|
9
12
|
* Translate a user-facing descriptor into a playwright locator, resolving
|
|
10
13
|
* `scope` outside-in so `within(navigation(), link('X'))` searches the nav
|
|
@@ -14,26 +17,15 @@ const MAX_REPORTED_MATCHES = 10;
|
|
|
14
17
|
*/
|
|
15
18
|
function locate(root, element) {
|
|
16
19
|
const scope = element.scope ? locate(root, element.scope) : root;
|
|
17
|
-
const
|
|
18
|
-
|
|
20
|
+
const exact = element.exact ?? false;
|
|
21
|
+
const name = element.name ?? "";
|
|
22
|
+
if (element.kind === "field") return scope.getByLabel(name, { exact });
|
|
23
|
+
if (element.kind === "testId") return scope.getByTestId(name);
|
|
24
|
+
if (element.kind === "text") return scope.getByText(name, { exact });
|
|
25
|
+
return scope.getByRole(element.kind, element.name === void 0 ? { exact } : {
|
|
26
|
+
exact,
|
|
19
27
|
name: element.name
|
|
20
|
-
};
|
|
21
|
-
switch (element.kind) {
|
|
22
|
-
case "banner":
|
|
23
|
-
case "complementary":
|
|
24
|
-
case "contentinfo":
|
|
25
|
-
case "form":
|
|
26
|
-
case "main":
|
|
27
|
-
case "navigation":
|
|
28
|
-
case "region":
|
|
29
|
-
case "search": return scope.getByRole(element.kind, options);
|
|
30
|
-
case "button":
|
|
31
|
-
case "heading":
|
|
32
|
-
case "link": return scope.getByRole(element.kind, options);
|
|
33
|
-
case "field": return scope.getByLabel(element.name ?? "", { exact: element.exact ?? false });
|
|
34
|
-
case "testId": return scope.getByTestId(element.name ?? "");
|
|
35
|
-
case "text": return scope.getByText(element.name ?? "", { exact: element.exact ?? false });
|
|
36
|
-
}
|
|
28
|
+
});
|
|
37
29
|
}
|
|
38
30
|
/** Playwright signals "more than one match" through this error text. */
|
|
39
31
|
function isStrictViolation(error) {
|
|
@@ -48,11 +40,14 @@ async function captureMatches(locator) {
|
|
|
48
40
|
const landmark = element.parentElement?.closest(LANDMARKS);
|
|
49
41
|
const context = landmark ? landmark.getAttribute("role") ?? landmark.tagName.toLowerCase() : null;
|
|
50
42
|
const detail = element.getAttribute("href") ?? element.getAttribute("name");
|
|
43
|
+
const text = (element.textContent ?? "").replaceAll(/\s+/gu, " ").trim().slice(0, 80);
|
|
44
|
+
const label = element.getAttribute("aria-label")?.trim();
|
|
51
45
|
return {
|
|
46
|
+
accessibleName: label && label !== text ? label : void 0,
|
|
52
47
|
context: context ?? void 0,
|
|
53
48
|
detail: detail ?? void 0,
|
|
54
49
|
tag: element.tagName.toLowerCase(),
|
|
55
|
-
text
|
|
50
|
+
text
|
|
56
51
|
};
|
|
57
52
|
});
|
|
58
53
|
}, MAX_REPORTED_MATCHES);
|
|
@@ -83,6 +78,59 @@ async function findAmbiguousLevel(page, element) {
|
|
|
83
78
|
for (const level of chain.slice(0, -1)) if (await locate(page, level).count() > 1) return level;
|
|
84
79
|
return element;
|
|
85
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* The same element, narrowed by whatever STATE the descriptor carries — focus,
|
|
83
|
+
* or accepting input — or `null` when it carries none.
|
|
84
|
+
*
|
|
85
|
+
* A second condition on the locator rather than a one-shot probe: the locator
|
|
86
|
+
* keeps playwright's own retry, so `see(focused(x))` waits for the keyboard to
|
|
87
|
+
* arrive instead of asking once and failing on a frame of animation. It also
|
|
88
|
+
* needs nothing but `playwright` — `expect(...).toBeFocused()` lives in
|
|
89
|
+
* `@playwright/test`, which this package deliberately does not carry.
|
|
90
|
+
*/
|
|
91
|
+
function stateOnly(page, locator, element) {
|
|
92
|
+
if (element.focused === true) return locator.and(page.locator(":focus"));
|
|
93
|
+
if (element.disabled !== void 0) return locator.and(page.locator(element.disabled ? ":disabled" : ":enabled"));
|
|
94
|
+
if (element.selected !== void 0) return locator.and(page.locator(element.selected ? ":checked" : ":not(:checked)"));
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Is this descriptor answered by PRESENCE rather than by visibility? The
|
|
99
|
+
* options of a collapsed `<select>` are in the document and in the tree, and
|
|
100
|
+
* none of them has a box on the screen until a visitor opens it.
|
|
101
|
+
*/
|
|
102
|
+
function byPresence(element) {
|
|
103
|
+
return element.kind === "option";
|
|
104
|
+
}
|
|
105
|
+
/** The field's live value, or `null` when nothing (unambiguous) is there yet. */
|
|
106
|
+
async function currentValue(locator) {
|
|
107
|
+
try {
|
|
108
|
+
return await locator.inputValue({ timeout: VALUE_POLL_MS });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (isStrictViolation(error)) throw error;
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Wait until the field holds the value the descriptor carries — or, for
|
|
116
|
+
* `gone()`, until it no longer does.
|
|
117
|
+
*
|
|
118
|
+
* A poll rather than a narrowed locator: a value is a PROPERTY of the node,
|
|
119
|
+
* and the CSS a locator narrows with reads ATTRIBUTES — a controlled input
|
|
120
|
+
* whose `value` attribute never moves would answer for a value it no longer
|
|
121
|
+
* holds. The locator is re-read every round, so a re-rendered field is read
|
|
122
|
+
* again rather than held as a stale handle.
|
|
123
|
+
*/
|
|
124
|
+
async function waitForValue(locator, element, wanted) {
|
|
125
|
+
const value = element.value ?? "";
|
|
126
|
+
const deadline = Date.now() + VALUE_TIMEOUT_MS;
|
|
127
|
+
for (;;) {
|
|
128
|
+
const actual = await currentValue(locator);
|
|
129
|
+
if (actual === null ? !wanted : actual === value === wanted) return;
|
|
130
|
+
if (Date.now() >= deadline) throw new Error(`${formatElement(element)} ${wanted ? "never held" : "still holds"} ${JSON.stringify(value)}${actual === null ? " (it is not on the page)" : `, it holds ${JSON.stringify(actual)}`}`);
|
|
131
|
+
await setTimeout(VALUE_POLL_MS);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
86
134
|
/** The visitor implementation — every action auto-waits via playwright actionability. */
|
|
87
135
|
function createVisitor(page, baseUrl) {
|
|
88
136
|
return {
|
|
@@ -112,9 +160,32 @@ function createVisitor(page, baseUrl) {
|
|
|
112
160
|
press: async (key) => {
|
|
113
161
|
await page.keyboard.press(key);
|
|
114
162
|
},
|
|
163
|
+
gone: async (element) => {
|
|
164
|
+
await act(page, element, async (locator) => {
|
|
165
|
+
if (element.value !== void 0) {
|
|
166
|
+
await waitForValue(locator, element, false);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
const narrowed = stateOnly(page, locator, element);
|
|
170
|
+
if (narrowed !== null) {
|
|
171
|
+
await narrowed.waitFor({ state: "detached" });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
await locator.waitFor({ state: byPresence(element) ? "detached" : "hidden" });
|
|
175
|
+
});
|
|
176
|
+
},
|
|
115
177
|
see: async (element) => {
|
|
116
178
|
await act(page, element, async (locator) => {
|
|
117
|
-
|
|
179
|
+
if (element.value !== void 0) {
|
|
180
|
+
await waitForValue(locator, element, true);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const narrowed = stateOnly(page, locator, element);
|
|
184
|
+
if (narrowed !== null) {
|
|
185
|
+
await narrowed.waitFor({ state: "attached" });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
await locator.waitFor({ state: byPresence(element) ? "attached" : "visible" });
|
|
118
189
|
});
|
|
119
190
|
},
|
|
120
191
|
select: async (element, option) => {
|
|
@@ -192,9 +263,11 @@ var PlaywrightAdapter = class {
|
|
|
192
263
|
title: document.title
|
|
193
264
|
};
|
|
194
265
|
});
|
|
266
|
+
const tree = await page.locator("body").ariaSnapshot();
|
|
195
267
|
return {
|
|
196
268
|
consoleMessages,
|
|
197
269
|
status: response?.status() ?? 0,
|
|
270
|
+
tree,
|
|
198
271
|
url: page.url(),
|
|
199
272
|
...extraction
|
|
200
273
|
};
|
package/dist/queue.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { t as CaptureScope } from "./match.js";
|
|
2
|
-
import {
|
|
2
|
+
import { I as textEquals, M as placeholderPatternSource, k as hasPlaceholders } from "./artifacts.js";
|
|
3
3
|
//#region src/specification/contracts/contract.ts
|
|
4
4
|
/**
|
|
5
5
|
* Declare one contract. Identity function — its value is the enforced shape
|
package/dist/vitest.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { ViteUserConfig } from "vitest/config";
|
|
1
|
+
import { TestProjectInlineConfiguration, ViteUserConfig } from "vitest/config";
|
|
2
|
+
import { UserConfig, UserConfigFn } from "vite";
|
|
2
3
|
//#region src/vitest/literate-plugin.d.ts
|
|
3
4
|
type LiterateOptions = {
|
|
4
5
|
/**
|
|
@@ -57,4 +58,101 @@ type SpecConfig = {
|
|
|
57
58
|
} & ViteUserConfig;
|
|
58
59
|
declare function defineSpecConfig(config?: SpecConfig): ViteUserConfig;
|
|
59
60
|
//#endregion
|
|
60
|
-
|
|
61
|
+
//#region src/vitest/projects.d.ts
|
|
62
|
+
/**
|
|
63
|
+
* The project helpers — one per kind of test, each the canonical project that
|
|
64
|
+
* kind runs in.
|
|
65
|
+
*
|
|
66
|
+
* A project is not a matter of taste: `--project component` has to mean the
|
|
67
|
+
* same thing in every repository of the ecosystem, and what a browser project
|
|
68
|
+
* needs (a provider pinned to the runner, a service worker, a Vite pipeline,
|
|
69
|
+
* an artefact directory that is not the repository root) is the framework's to
|
|
70
|
+
* know, not the consumer's to rediscover. So the consumer names the kind and
|
|
71
|
+
* states only what is theirs.
|
|
72
|
+
*/
|
|
73
|
+
/** What every helper accepts on top of its canonical project. */
|
|
74
|
+
type CommonOptions = {
|
|
75
|
+
/**
|
|
76
|
+
* Replace the canonical `exclude` globs. A helper whose canonical
|
|
77
|
+
* `exclude` only guards its canonical `include` drops it when you state
|
|
78
|
+
* `include` alone, so state both or neither.
|
|
79
|
+
*/
|
|
80
|
+
exclude?: string[];
|
|
81
|
+
/** Replace the canonical `include` globs. */
|
|
82
|
+
include?: string[];
|
|
83
|
+
/**
|
|
84
|
+
* Run this project's files ONE AT A TIME (`fileParallelism: false`) — for
|
|
85
|
+
* the facets whose files share something a second worker would collide
|
|
86
|
+
* with: one served app, one database file, one browser profile.
|
|
87
|
+
*/
|
|
88
|
+
serial?: boolean;
|
|
89
|
+
/** Raise (or lower) the 30s budget for this project alone. */
|
|
90
|
+
timeout?: number;
|
|
91
|
+
};
|
|
92
|
+
/** `unit()` — module tests, beside the modules they cover (CONVENTIONS I2). */
|
|
93
|
+
type UnitProjectOptions = CommonOptions & {
|
|
94
|
+
/**
|
|
95
|
+
* The trees module tests live in, when they do not live in `src/`. Each
|
|
96
|
+
* root becomes `<root>/**\/*.test.ts`; `include` states the globs outright.
|
|
97
|
+
*/
|
|
98
|
+
roots?: string[];
|
|
99
|
+
};
|
|
100
|
+
/** `website()` — the assembled product, met through a served page. */
|
|
101
|
+
type WebsiteProjectOptions = CommonOptions;
|
|
102
|
+
/** `component()` — rendered units, beside the components they cover. */
|
|
103
|
+
type ComponentProjectOptions = {
|
|
104
|
+
/** Freeze the page's `Date` for every render of the project. */
|
|
105
|
+
clock?: string;
|
|
106
|
+
/** `Accept-Language` and `Intl` locale of the page. Default `'en-US'`. */
|
|
107
|
+
locale?: string;
|
|
108
|
+
/**
|
|
109
|
+
* The directory `vite` and `wrap` are resolved against. Default: the
|
|
110
|
+
* directory of the file that CALLED the helper — see
|
|
111
|
+
* {@link callerDirectory}. State it when the call is not written in the
|
|
112
|
+
* config it configures (a shared config factory).
|
|
113
|
+
*/
|
|
114
|
+
root?: string;
|
|
115
|
+
/** IANA zone the page's `Date` reports in. Default `'UTC'`. */
|
|
116
|
+
timezone?: string;
|
|
117
|
+
/**
|
|
118
|
+
* The app's own Vite pipeline: a config path, the object, or the function
|
|
119
|
+
* a framework exports. Only the PIPELINE is adopted (see {@link reroot}).
|
|
120
|
+
*/
|
|
121
|
+
vite?: Promise<UserConfig> | string | UserConfig | UserConfigFn;
|
|
122
|
+
/** The page size every render gets. Default 1280×720. */
|
|
123
|
+
viewport?: {
|
|
124
|
+
height: number;
|
|
125
|
+
width: number;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* A module whose default export is `(ui) => ReactNode` — the providers
|
|
129
|
+
* every render of this project is dressed in (a theme, a store, an intl
|
|
130
|
+
* provider). A router belongs on the chain instead: it is a test's Given.
|
|
131
|
+
*/
|
|
132
|
+
wrap?: string;
|
|
133
|
+
} & CommonOptions;
|
|
134
|
+
/**
|
|
135
|
+
* A module test runs beside the module it covers: no DOM, no browser, no
|
|
136
|
+
* pipeline. `.test.tsx` is excluded because a rendered thing is a component
|
|
137
|
+
* test — a project's dependency scan reads every file its globs match, and a
|
|
138
|
+
* `.tsx` collected here would be optimised for a page that never opens.
|
|
139
|
+
*/
|
|
140
|
+
declare function unit(options?: UnitProjectOptions): TestProjectInlineConfiguration;
|
|
141
|
+
/**
|
|
142
|
+
* A website spec meets the assembled product through a served page, in the
|
|
143
|
+
* Chromium playwright drives. It opens a browser, so it is scheduled before the
|
|
144
|
+
* component project and never beside it: two Chromiums must not share a slot on
|
|
145
|
+
* a 2-vCPU runner.
|
|
146
|
+
*/
|
|
147
|
+
declare function website(options?: WebsiteProjectOptions): TestProjectInlineConfiguration;
|
|
148
|
+
/**
|
|
149
|
+
* A component test renders in a real Chromium — the browser the website facet
|
|
150
|
+
* already drives through the same `playwright` peer.
|
|
151
|
+
*
|
|
152
|
+
* The default `include` reaches the whole project, so the default `exclude`
|
|
153
|
+
* keeps it out of `specs/`, where the assembled product is specified. State
|
|
154
|
+
* your own `include` and the exclusion is yours to state too.
|
|
155
|
+
*/
|
|
156
|
+
declare function component(options?: ComponentProjectOptions): Promise<TestProjectInlineConfiguration>;
|
|
157
|
+
//#endregion
|
|
158
|
+
export { type ComponentProjectOptions, type LiterateOptions, type LiteratePlugin, type SpecConfig, type UnitProjectOptions, type WebsiteProjectOptions, component, defineSpecConfig, literate, unit, website };
|
package/dist/vitest.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
import { c as GROUND_EXPECTED } from "./match.js";
|
|
1
2
|
import { a as specDescription, r as VITEST_ARTIFACTS_DIR, t as COVERAGE_DIR } from "./artifacts.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { basename, dirname, isAbsolute, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
4
7
|
import { configDefaults, mergeConfig } from "vitest/config";
|
|
5
8
|
//#region src/vitest/literate-plugin.ts
|
|
6
9
|
/**
|
|
@@ -116,6 +119,12 @@ const EXCLUDE = [...configDefaults.exclude, "**/_fixtures/**"];
|
|
|
116
119
|
* Defaults a vitest PROJECT accepts. `coverage` and `reporters` are root-only
|
|
117
120
|
* options (vitest omits them from a project's config), so they are absent here
|
|
118
121
|
* on purpose — setting them would be silently dropped.
|
|
122
|
+
*
|
|
123
|
+
* Exported for the project helpers, which build their canonical project ON
|
|
124
|
+
* these rather than beside them: a helper's project must carry the same
|
|
125
|
+
* budgets and the same artefact directory as a hand-written one.
|
|
126
|
+
*
|
|
127
|
+
* @internal
|
|
119
128
|
*/
|
|
120
129
|
function projectDefaults() {
|
|
121
130
|
return {
|
|
@@ -164,4 +173,367 @@ function defineSpecConfig(config = {}) {
|
|
|
164
173
|
};
|
|
165
174
|
}
|
|
166
175
|
//#endregion
|
|
167
|
-
|
|
176
|
+
//#region src/integrations/vitest-browser/commands.ts
|
|
177
|
+
/** Where a golden sits: `_expected/<name>` beside the test that asked for it. */
|
|
178
|
+
function goldenPath(testPath, name) {
|
|
179
|
+
if (testPath === void 0) throw new Error("golden: the command carries no test path — a golden is read from a test file.");
|
|
180
|
+
return resolve(dirname(testPath), GROUND_EXPECTED, name);
|
|
181
|
+
}
|
|
182
|
+
/** The ARIA snapshot of the rendered `<body>`, as YAML. */
|
|
183
|
+
const ariaTree = async (context) => {
|
|
184
|
+
return await (await context.frame()).locator("body").ariaSnapshot();
|
|
185
|
+
};
|
|
186
|
+
/** The golden's current content, or `null` when it does not exist yet. */
|
|
187
|
+
const goldenRead = (context, name) => {
|
|
188
|
+
const path = goldenPath(context.testPath, name);
|
|
189
|
+
return existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
190
|
+
};
|
|
191
|
+
/** Write the golden — update mode's half, and the path it wrote, for the message. */
|
|
192
|
+
const goldenWrite = (context, name, content) => {
|
|
193
|
+
const path = goldenPath(context.testPath, name);
|
|
194
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
195
|
+
writeFileSync(path, content, "utf8");
|
|
196
|
+
return path;
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* The commands, as `component()` registers them — one list, so the names the
|
|
200
|
+
* page calls and the names the project registers cannot drift.
|
|
201
|
+
*/
|
|
202
|
+
const COMPONENT_COMMANDS = {
|
|
203
|
+
ariaTree,
|
|
204
|
+
goldenRead,
|
|
205
|
+
goldenWrite
|
|
206
|
+
};
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/vitest/projects.ts
|
|
209
|
+
/** The pipeline keys a consumer's Vite config contributes, and nothing else. */
|
|
210
|
+
const PIPELINE_KEYS = [
|
|
211
|
+
"assetsInclude",
|
|
212
|
+
"css",
|
|
213
|
+
"define",
|
|
214
|
+
"envPrefix",
|
|
215
|
+
"esbuild",
|
|
216
|
+
"json",
|
|
217
|
+
"oxc",
|
|
218
|
+
"plugins",
|
|
219
|
+
"resolve"
|
|
220
|
+
];
|
|
221
|
+
/**
|
|
222
|
+
* The seam's own runtime, pre-bundled so a cold cache never reloads mid-run.
|
|
223
|
+
*
|
|
224
|
+
* `msw` and `msw/browser` are NOT here: `@vitest/browser` already puts both in
|
|
225
|
+
* `optimizeDeps.exclude`, and Vite's esbuild optimizer (6 and 7) treats an
|
|
226
|
+
* entry that is both included and excluded as fatal — dependency optimisation
|
|
227
|
+
* throws before a single test runs.
|
|
228
|
+
*/
|
|
229
|
+
const SEAM_DEPENDENCIES = [
|
|
230
|
+
"react",
|
|
231
|
+
"react-dom/client",
|
|
232
|
+
"react/jsx-dev-runtime",
|
|
233
|
+
"react/jsx-runtime",
|
|
234
|
+
"vitest-browser-react"
|
|
235
|
+
];
|
|
236
|
+
const requireFrom = createRequire(import.meta.url);
|
|
237
|
+
/** This module's own file — the frames the caller search walks past. */
|
|
238
|
+
const HELPERS_FILE = import.meta.filename;
|
|
239
|
+
/** A stack frame's file, in either of the two shapes V8 prints it in. */
|
|
240
|
+
const STACK_FRAME = /(?:\((?<wrapped>[^()]+):\d+:\d+\)|at (?<bare>[^()\s]+):\d+:\d+)$/u;
|
|
241
|
+
/** A config vite bundled before importing it — `<its name>.timestamp-<hash>.mjs`. */
|
|
242
|
+
const BUNDLED_CONFIG = /^(?<name>.+)\.timestamp-[^.]+\.mjs$/u;
|
|
243
|
+
/** The file of the first frame below this module — who called the helper. */
|
|
244
|
+
function callerFile() {
|
|
245
|
+
const { stack } = /* @__PURE__ */ new Error("@jterrazz/test: reading the call site of a project helper");
|
|
246
|
+
for (const frame of stack?.split("\n").slice(1) ?? []) {
|
|
247
|
+
const groups = STACK_FRAME.exec(frame.trim())?.groups;
|
|
248
|
+
const location = groups?.wrapped ?? groups?.bare;
|
|
249
|
+
if (location === void 0 || location.startsWith("node:")) continue;
|
|
250
|
+
const file = location.startsWith("file:") ? fileURLToPath(location) : location;
|
|
251
|
+
if (file !== HELPERS_FILE) return file;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** The nearest directory at or above the cwd holding a file of this name. */
|
|
255
|
+
function nearestHolding(name) {
|
|
256
|
+
let directory = process.cwd();
|
|
257
|
+
for (;;) {
|
|
258
|
+
if (existsSync(resolve(directory, name))) return directory;
|
|
259
|
+
const parent = dirname(directory);
|
|
260
|
+
if (parent === directory) return;
|
|
261
|
+
directory = parent;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* The directory of the CONFIG that called a helper — what the relative paths
|
|
266
|
+
* it was handed mean.
|
|
267
|
+
*
|
|
268
|
+
* Not `process.cwd()`: a `vitest.config.ts` is read by more tools than the
|
|
269
|
+
* runner, and knip and `typescript check` load every config of a repository
|
|
270
|
+
* from the repository ROOT — `component({ vite: './web/vite.config.ts' })`
|
|
271
|
+
* written beside an app resolved against the wrong tree and threw before a
|
|
272
|
+
* single test ran. `import.meta` cannot answer either: it describes THIS
|
|
273
|
+
* module. And vitest hands a project neither its root nor its config file — a
|
|
274
|
+
* project function is called with `{ command, mode, isPreview, isSsrBuild }`
|
|
275
|
+
* and nothing else.
|
|
276
|
+
*
|
|
277
|
+
* So the call site is read off the stack, where a loader that keeps a config's
|
|
278
|
+
* identity (node, tsx, jiti — what those tools use) names the file itself.
|
|
279
|
+
* Vite's own loader does not: it BUNDLES the config into
|
|
280
|
+
* `node_modules/.vite-temp/` and only the config's NAME survives the move. The
|
|
281
|
+
* name is what is then looked for upward from the cwd, because that loader is
|
|
282
|
+
* the runner's and a runner is started in the tree it tests. A config loaded
|
|
283
|
+
* by the runner from another tree entirely states `root` itself.
|
|
284
|
+
*/
|
|
285
|
+
function callerDirectory() {
|
|
286
|
+
const file = callerFile();
|
|
287
|
+
if (file === void 0) return process.cwd();
|
|
288
|
+
const bundled = BUNDLED_CONFIG.exec(basename(file))?.groups?.name;
|
|
289
|
+
if (bundled === void 0 || basename(dirname(file)) !== ".vite-temp") return dirname(file);
|
|
290
|
+
return nearestHolding(bundled) ?? process.cwd();
|
|
291
|
+
}
|
|
292
|
+
/** Is this specifier installed here? An absent optional peer is simply not pre-bundled. */
|
|
293
|
+
function resolves(specifier) {
|
|
294
|
+
try {
|
|
295
|
+
requireFrom.resolve(specifier);
|
|
296
|
+
return true;
|
|
297
|
+
} catch {
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Serve msw's worker script from the package's OWN install, at the path the
|
|
303
|
+
* worker asks for, with the scope header a worker registered at `/` needs.
|
|
304
|
+
*
|
|
305
|
+
* `@vitest/browser` resolves `/mockServiceWorker.js` too, but from ITS own
|
|
306
|
+
* directory — and it does not depend on `msw`, so that resolution only lands
|
|
307
|
+
* where the installer hoists. The package that OWNS the dependency is the one
|
|
308
|
+
* that can always find it, and it is this one. Without either, a consumer runs
|
|
309
|
+
* `msw init` and declares `msw`, which F8 forbids.
|
|
310
|
+
*/
|
|
311
|
+
function mswWorkerPlugin() {
|
|
312
|
+
return {
|
|
313
|
+
configureServer(server) {
|
|
314
|
+
const script = requireFrom.resolve("msw/mockServiceWorker.js");
|
|
315
|
+
server.middlewares.use("/mockServiceWorker.js", (_request, response) => {
|
|
316
|
+
response.setHeader("content-type", "text/javascript");
|
|
317
|
+
response.setHeader("service-worker-allowed", "/");
|
|
318
|
+
response.end(readFileSync(script, "utf8"));
|
|
319
|
+
});
|
|
320
|
+
},
|
|
321
|
+
name: "@jterrazz/test:msw-worker"
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Keep an app's Vite PIPELINE and drop everything that says where the app
|
|
326
|
+
* lives.
|
|
327
|
+
*
|
|
328
|
+
* An allow-list, not a drop-list: a consumer's config carries keys the
|
|
329
|
+
* framework has never heard of — `environments`, `ssr`, `appType`, `publicDir`
|
|
330
|
+
* — and one of them re-roots the run and the project collects nothing. Naming
|
|
331
|
+
* what the pipeline IS keeps an unknown key from leaking in. The consumer's
|
|
332
|
+
* `plugins` are CONCATENATED after the seam's, never assigned over them.
|
|
333
|
+
*/
|
|
334
|
+
async function reroot(source, root) {
|
|
335
|
+
const environment = {
|
|
336
|
+
command: "serve",
|
|
337
|
+
isSsrBuild: false,
|
|
338
|
+
mode: "test"
|
|
339
|
+
};
|
|
340
|
+
let loaded;
|
|
341
|
+
let from = "the value it was given";
|
|
342
|
+
if (typeof source === "string") {
|
|
343
|
+
const { loadConfigFromFile } = await import("vite");
|
|
344
|
+
from = isAbsolute(source) ? source : resolve(root, source);
|
|
345
|
+
loaded = (await loadConfigFromFile(environment, from))?.config;
|
|
346
|
+
} else if (typeof source === "function") loaded = await source(environment);
|
|
347
|
+
else loaded = await source;
|
|
348
|
+
if (loaded === void 0) throw new Error(`component({ vite }): no config at ${from}`);
|
|
349
|
+
const inactive = transformKey() === "oxc" ? "esbuild" : "oxc";
|
|
350
|
+
const kept = {};
|
|
351
|
+
const config = { ...loaded };
|
|
352
|
+
for (const key of PIPELINE_KEYS) if (config[key] !== void 0 && key !== inactive) kept[key] = config[key];
|
|
353
|
+
return kept;
|
|
354
|
+
}
|
|
355
|
+
/** What a missing optional peer costs, said once rather than as a resolver stack. */
|
|
356
|
+
const PROVIDER_MISSING = "component(): @vitest/browser-playwright is an optional peer — install it beside the runner it pins (npm i -D @vitest/browser-playwright@<the installed vitest version> playwright).";
|
|
357
|
+
/** The installed version of a package, read from its own manifest. */
|
|
358
|
+
function versionOf(specifier) {
|
|
359
|
+
const manifest = JSON.parse(readFileSync(requireFrom.resolve(`${specifier}/package.json`), "utf8"));
|
|
360
|
+
if (typeof manifest === "object" && manifest !== null && "version" in manifest) return String(manifest.version);
|
|
361
|
+
throw new Error(`component(): ${specifier} is installed without a version.`);
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Which of the two transformer keys the INSTALLED Vite actually reads.
|
|
365
|
+
*
|
|
366
|
+
* Vite 8 transforms with oxc and prints a warning for every `esbuild` option it
|
|
367
|
+
* is handed; Vite 6 and 7 transform with esbuild and have no `oxc` key at all.
|
|
368
|
+
* The inactive one is dropped rather than carried as a second, ignored
|
|
369
|
+
* statement — a config that says the same thing twice is a config that will one
|
|
370
|
+
* day say two different things.
|
|
371
|
+
*/
|
|
372
|
+
function transformKey() {
|
|
373
|
+
return Number.parseInt(versionOf("vite").split(".")[0] ?? "", 10) >= 8 ? "oxc" : "esbuild";
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* The JSX default, on the key the running Vite reads, yielding to the
|
|
377
|
+
* consumer's own: a `jsxImportSource` or a classic runtime is the app's
|
|
378
|
+
* statement, not the seam's to overwrite.
|
|
379
|
+
*/
|
|
380
|
+
function jsxPipeline(pipeline) {
|
|
381
|
+
const key = transformKey();
|
|
382
|
+
const stated = pipeline[key];
|
|
383
|
+
if (key === "oxc") return { oxc: stated ?? { jsx: { runtime: "automatic" } } };
|
|
384
|
+
return { esbuild: stated ?? { jsx: "automatic" } };
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* `@vitest/browser-playwright` peers vitest on an EXACT version: 4.1.10 wants
|
|
388
|
+
* 4.1.10, not `^4.1`. A mismatch fails deep inside the tester with a message
|
|
389
|
+
* about a missing runner, so the pair is checked where the project is built
|
|
390
|
+
* and both versions are named.
|
|
391
|
+
*/
|
|
392
|
+
function assertProviderPin() {
|
|
393
|
+
const runner = versionOf("vitest");
|
|
394
|
+
let provider;
|
|
395
|
+
try {
|
|
396
|
+
provider = versionOf("@vitest/browser-playwright");
|
|
397
|
+
} catch {
|
|
398
|
+
throw new Error(PROVIDER_MISSING);
|
|
399
|
+
}
|
|
400
|
+
if (runner !== provider) throw new Error(`component(): @vitest/browser-playwright@${provider} peers vitest on an exact version, and vitest is ${runner}. Bump both to the same version in one change (npm i -D vitest@${provider} @vitest/browser-playwright@${provider}).`);
|
|
401
|
+
}
|
|
402
|
+
/** The framework's own update flag, read where `process` exists. */
|
|
403
|
+
function updating() {
|
|
404
|
+
return process.env.TEST_UPDATE === "1" || process.argv.includes("-u") || process.argv.includes("--update");
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Hand the project's `wrap` to the page as a STATIC import.
|
|
408
|
+
*
|
|
409
|
+
* The page could fetch the module by URL when the first render needs it, and
|
|
410
|
+
* that is what breaks: a dependency Vite never scanned is discovered mid-run,
|
|
411
|
+
* the optimizer rewrites its cache, and every module the page is holding —
|
|
412
|
+
* the test's own included — 404s behind a stale `?v=` hash. A setup file is in
|
|
413
|
+
* the graph before the first test runs, so the wrap and everything it imports
|
|
414
|
+
* are scanned, pre-bundled, and never re-optimised under the run.
|
|
415
|
+
*
|
|
416
|
+
* It is written into `.artifacts/` rather than served from memory because
|
|
417
|
+
* vitest resolves `setupFiles` as paths on disk; it is generated, so it belongs
|
|
418
|
+
* with everything else a tool generates, and never on the row.
|
|
419
|
+
*
|
|
420
|
+
* The wrapper lands on `globalThis` under a shared symbol rather than an
|
|
421
|
+
* export: the setup module and the package's browser build are separate graphs
|
|
422
|
+
* in the page and share no module instance.
|
|
423
|
+
*/
|
|
424
|
+
function writeSetupFile(wrap, root) {
|
|
425
|
+
const absolute = resolve(root, wrap);
|
|
426
|
+
const path = resolve(root, VITEST_ARTIFACTS_DIR, "component-setup.mjs");
|
|
427
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
428
|
+
writeFileSync(path, [
|
|
429
|
+
"// Generated by `component({ wrap })` — @jterrazz/test. Do not edit.",
|
|
430
|
+
`import wrap from ${JSON.stringify(absolute)};`,
|
|
431
|
+
"globalThis[Symbol.for('@jterrazz/test:component-wrap')] = wrap;",
|
|
432
|
+
""
|
|
433
|
+
].join("\n"), "utf8");
|
|
434
|
+
return path;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* What the page cannot read for itself: update mode is a `process.env`/`argv`
|
|
438
|
+
* read, and the project's clock is stated where `process` exists.
|
|
439
|
+
*/
|
|
440
|
+
function providedToPage(options) {
|
|
441
|
+
return {
|
|
442
|
+
...options.clock === void 0 ? {} : { componentClock: options.clock },
|
|
443
|
+
componentViewport: viewportOf(options),
|
|
444
|
+
update: updating()
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/** The page size every render of the project starts at. */
|
|
448
|
+
function viewportOf(options) {
|
|
449
|
+
return options.viewport ?? {
|
|
450
|
+
height: 720,
|
|
451
|
+
width: 1280
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
/** The globs a component project collects, and the ones it stays out of. */
|
|
455
|
+
function componentGlobs(options) {
|
|
456
|
+
return {
|
|
457
|
+
exclude: options.exclude ?? (options.include === void 0 ? ["specs/**"] : []),
|
|
458
|
+
include: options.include ?? ["**/*.test.tsx"]
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* A module test runs beside the module it covers: no DOM, no browser, no
|
|
463
|
+
* pipeline. `.test.tsx` is excluded because a rendered thing is a component
|
|
464
|
+
* test — a project's dependency scan reads every file its globs match, and a
|
|
465
|
+
* `.tsx` collected here would be optimised for a page that never opens.
|
|
466
|
+
*/
|
|
467
|
+
function unit(options = {}) {
|
|
468
|
+
const fromRoots = options.roots?.map((root) => `${root.replace(/\/+$/u, "")}/**/*.test.ts`);
|
|
469
|
+
return mergeConfig(projectDefaults(), { test: {
|
|
470
|
+
exclude: options.exclude ?? ["specs/**", "**/*.test.tsx"],
|
|
471
|
+
include: options.include ?? fromRoots ?? ["**/*.test.ts"],
|
|
472
|
+
...options.serial === true ? { fileParallelism: false } : {},
|
|
473
|
+
name: "unit",
|
|
474
|
+
sequence: { groupOrder: 0 },
|
|
475
|
+
...options.timeout === void 0 ? {} : { testTimeout: options.timeout }
|
|
476
|
+
} });
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* A website spec meets the assembled product through a served page, in the
|
|
480
|
+
* Chromium playwright drives. It opens a browser, so it is scheduled before the
|
|
481
|
+
* component project and never beside it: two Chromiums must not share a slot on
|
|
482
|
+
* a 2-vCPU runner.
|
|
483
|
+
*/
|
|
484
|
+
function website(options = {}) {
|
|
485
|
+
return mergeConfig(projectDefaults(), { test: {
|
|
486
|
+
...options.exclude === void 0 ? {} : { exclude: options.exclude },
|
|
487
|
+
...options.serial === true ? { fileParallelism: false } : {},
|
|
488
|
+
include: options.include ?? ["specs/website/**/*.test.ts"],
|
|
489
|
+
name: "website",
|
|
490
|
+
sequence: { groupOrder: 1 },
|
|
491
|
+
...options.timeout === void 0 ? {} : { testTimeout: options.timeout }
|
|
492
|
+
} });
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* A component test renders in a real Chromium — the browser the website facet
|
|
496
|
+
* already drives through the same `playwright` peer.
|
|
497
|
+
*
|
|
498
|
+
* The default `include` reaches the whole project, so the default `exclude`
|
|
499
|
+
* keeps it out of `specs/`, where the assembled product is specified. State
|
|
500
|
+
* your own `include` and the exclusion is yours to state too.
|
|
501
|
+
*/
|
|
502
|
+
async function component(options = {}) {
|
|
503
|
+
const root = options.root ?? callerDirectory();
|
|
504
|
+
assertProviderPin();
|
|
505
|
+
const { playwright } = await import("@vitest/browser-playwright");
|
|
506
|
+
const pipeline = options.vite === void 0 ? {} : await reroot(options.vite, root);
|
|
507
|
+
const consumerPlugins = Array.isArray(pipeline.plugins) ? pipeline.plugins : [];
|
|
508
|
+
const project = {
|
|
509
|
+
...pipeline,
|
|
510
|
+
...jsxPipeline(pipeline),
|
|
511
|
+
optimizeDeps: { include: SEAM_DEPENDENCIES.filter(resolves) },
|
|
512
|
+
plugins: [mswWorkerPlugin(), ...consumerPlugins],
|
|
513
|
+
test: {
|
|
514
|
+
attachmentsDir: `${VITEST_ARTIFACTS_DIR}/attachments`,
|
|
515
|
+
browser: {
|
|
516
|
+
commands: COMPONENT_COMMANDS,
|
|
517
|
+
enabled: true,
|
|
518
|
+
headless: true,
|
|
519
|
+
instances: [{ browser: "chromium" }],
|
|
520
|
+
provider: playwright({ contextOptions: {
|
|
521
|
+
locale: options.locale ?? "en-US",
|
|
522
|
+
timezoneId: options.timezone ?? "UTC"
|
|
523
|
+
} }),
|
|
524
|
+
screenshotDirectory: `${VITEST_ARTIFACTS_DIR}/screenshots`,
|
|
525
|
+
viewport: viewportOf(options)
|
|
526
|
+
},
|
|
527
|
+
...componentGlobs(options),
|
|
528
|
+
...options.serial === true ? { fileParallelism: false } : {},
|
|
529
|
+
name: "component",
|
|
530
|
+
provide: providedToPage(options),
|
|
531
|
+
...options.wrap === void 0 ? {} : { setupFiles: [writeSetupFile(options.wrap, root)] },
|
|
532
|
+
sequence: { groupOrder: 2 },
|
|
533
|
+
...options.timeout === void 0 ? {} : { testTimeout: options.timeout }
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
return mergeConfig(projectDefaults(), project);
|
|
537
|
+
}
|
|
538
|
+
//#endregion
|
|
539
|
+
export { component, defineSpecConfig, literate, unit, website };
|