@jterrazz/test 15.1.0 → 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.
@@ -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 options = element.name === void 0 ? { exact: element.exact ?? false } : {
18
- exact: element.exact ?? false,
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: (element.textContent ?? "").replaceAll(/\s+/gu, " ").trim().slice(0, 80)
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
- await locator.waitFor({ state: "visible" });
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 { A as placeholderPatternSource, D as hasPlaceholders, P as textEquals } from "./artifacts.js";
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
- export { type LiterateOptions, type LiteratePlugin, type SpecConfig, defineSpecConfig, literate };
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 { readFileSync } from "node:fs";
3
- import { isAbsolute, resolve } from "node:path";
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
- export { defineSpecConfig, literate };
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 };