@jterrazz/test 15.1.1 → 15.3.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) => {
@@ -151,6 +222,7 @@ var PlaywrightAdapter = class {
151
222
  }
152
223
  try {
153
224
  const page = await context.newPage();
225
+ if (options.clock !== void 0) await page.clock.setFixedTime(new Date(options.clock));
154
226
  const consoleMessages = [];
155
227
  page.on("console", (message) => {
156
228
  consoleMessages.push({
@@ -192,9 +264,11 @@ var PlaywrightAdapter = class {
192
264
  title: document.title
193
265
  };
194
266
  });
267
+ const tree = await page.locator("body").ariaSnapshot();
195
268
  return {
196
269
  consoleMessages,
197
270
  status: response?.status() ?? 0,
271
+ tree,
198
272
  url: page.url(),
199
273
  ...extraction
200
274
  };
@@ -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
@@ -86,6 +86,35 @@ function contractsOf(input) {
86
86
  return flatten([input]);
87
87
  }
88
88
  //#endregion
89
+ //#region src/specification/contracts/types.ts
90
+ /**
91
+ * The marker that tells a STREAMED body from a value that happens to be an
92
+ * object. A body reaches three different engines (msw's server, msw's worker,
93
+ * the `node:http` stub) and each of them serialises "an object" as JSON, so a
94
+ * stream declared as a plain value arrives as `{}`. The tag is what each
95
+ * engine recognises before it reaches for `JSON.stringify`.
96
+ */
97
+ const STREAM_BODY = "@jterrazz/test:stream";
98
+ /** Is this body a declared stream rather than a value to serialise? */
99
+ function isStreamBody(body) {
100
+ return typeof body === "object" && body !== null && "kind" in body && body.kind === "@jterrazz/test:stream";
101
+ }
102
+ //#endregion
103
+ //#region src/specification/timing/pause.ts
104
+ /**
105
+ * One answer to "wait" — the only `setTimeout` the model owns.
106
+ *
107
+ * Two things in the framework genuinely have to wait: the spacing between the
108
+ * pieces of a declared stream, and the poll behind `waitUntil`. Everything
109
+ * else that waits is a spec doing it by hand, which is what J2 refuses. Stated
110
+ * once so there is one place to look when time stops behaving.
111
+ */
112
+ async function pause(ms) {
113
+ await new Promise((resolve) => {
114
+ setTimeout(resolve, ms);
115
+ });
116
+ }
117
+ //#endregion
89
118
  //#region src/specification/contracts/queue.ts
90
119
  /**
91
120
  * The ONE contract queue — pure, engine-agnostic (no MSW, no node:http).
@@ -259,4 +288,34 @@ var ContractQueue = class {
259
288
  }
260
289
  };
261
290
  //#endregion
262
- export { isContract as a, defineContracts as i, contractsOf as n, isContracts as o, defineContract as r, ContractQueue as t };
291
+ //#region src/specification/contracts/stream.ts
292
+ /**
293
+ * A declared stream, made real — ONE serialisation for all three engines.
294
+ *
295
+ * msw's server and msw's worker take a `ReadableStream` body verbatim
296
+ * (https://mswjs.io/docs/http/mocking-responses/streaming) and the `node:http`
297
+ * stub pipes one into its response, so what "arriving in pieces" MEANS —
298
+ * the order, the spacing, the close after the last chunk — is stated here and
299
+ * nowhere else. A second copy of it would be a second answer to "what does the
300
+ * subject read", and the facets would drift.
301
+ */
302
+ /**
303
+ * The pieces of `body`, in order, `delayBetweenChunks` milliseconds apart,
304
+ * closing after the last one.
305
+ */
306
+ function toReadableStream(body) {
307
+ const encoder = new TextEncoder();
308
+ let index = 0;
309
+ return new ReadableStream({ async pull(controller) {
310
+ const chunk = body.chunks[index];
311
+ if (chunk === void 0) {
312
+ controller.close();
313
+ return;
314
+ }
315
+ if (body.delayBetweenChunks > 0 && index > 0) await pause(body.delayBetweenChunks);
316
+ index += 1;
317
+ controller.enqueue(encoder.encode(chunk));
318
+ } });
319
+ }
320
+ //#endregion
321
+ export { isStreamBody as a, defineContracts as c, STREAM_BODY as i, isContract as l, ContractQueue as n, contractsOf as o, pause as r, defineContract as s, toReadableStream as t, isContracts as u };
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,131 @@ 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 FacetProjectOptions = {
75
+ /**
76
+ * Globs this project stays out of. On a facet helper they ADD to the
77
+ * preset's (`_fixtures/` and vitest's own), because vite concatenates. On
78
+ * `unit()` and `component()`, whose canonical `exclude` exists only to
79
+ * guard their canonical `include`, stating one replaces it — so state
80
+ * both or neither there.
81
+ */
82
+ exclude?: string[];
83
+ /** Replace the canonical `include` globs. */
84
+ include?: string[];
85
+ /**
86
+ * Run this project's files ONE AT A TIME (`fileParallelism: false`) — for
87
+ * the facets whose files share something a second worker would collide
88
+ * with: one served app, one database file, one browser profile.
89
+ */
90
+ serial?: boolean;
91
+ /** Raise (or lower) the 30s budget for this project alone. */
92
+ timeout?: number;
93
+ };
94
+ /** `unit()` — module tests, beside the modules they cover (CONVENTIONS I2). */
95
+ type UnitProjectOptions = FacetProjectOptions & {
96
+ /**
97
+ * The trees module tests live in, when they do not live in `src/`. Each
98
+ * root becomes `<root>/**\/*.test.ts`; `include` states the globs outright.
99
+ */
100
+ roots?: string[];
101
+ };
102
+ /** `website()` — the assembled product, met through a served page. */
103
+ type WebsiteProjectOptions = FacetProjectOptions;
104
+ /** `component()` — rendered units, beside the components they cover. */
105
+ type ComponentProjectOptions = {
106
+ /** Freeze the page's `Date` for every render of the project. */
107
+ clock?: string;
108
+ /** `Accept-Language` and `Intl` locale of the page. Default `'en-US'`. */
109
+ locale?: string;
110
+ /**
111
+ * The directory `vite` and `wrap` are resolved against. Default: the
112
+ * directory of the file that CALLED the helper — see
113
+ * {@link callerDirectory}. State it when the call is not written in the
114
+ * config it configures (a shared config factory).
115
+ */
116
+ root?: string;
117
+ /** IANA zone the page's `Date` reports in. Default `'UTC'`. */
118
+ timezone?: string;
119
+ /**
120
+ * The app's own Vite pipeline: a config path, the object, or the function
121
+ * a framework exports. Only the PIPELINE is adopted (see {@link reroot}).
122
+ */
123
+ vite?: Promise<UserConfig> | string | UserConfig | UserConfigFn;
124
+ /** The page size every render gets. Default 1280×720. */
125
+ viewport?: {
126
+ height: number;
127
+ width: number;
128
+ };
129
+ /**
130
+ * A module whose default export is `(ui) => ReactNode` — the providers
131
+ * every render of this project is dressed in (a theme, a store, an intl
132
+ * provider). A router belongs on the chain instead: it is a test's Given.
133
+ */
134
+ wrap?: string;
135
+ } & FacetProjectOptions;
136
+ /**
137
+ * A module test runs beside the module it covers: no DOM, no browser, no
138
+ * pipeline. `.test.tsx` is excluded because a rendered thing is a component
139
+ * test — a project's dependency scan reads every file its globs match, and a
140
+ * `.tsx` collected here would be optimised for a page that never opens.
141
+ */
142
+ declare function unit(options?: UnitProjectOptions): TestProjectInlineConfiguration;
143
+ /**
144
+ * A website spec meets the assembled product through a served page, in the
145
+ * Chromium playwright drives. It opens a browser, so it is scheduled before the
146
+ * component project and never beside it: two Chromiums must not share a slot on
147
+ * a 2-vCPU runner.
148
+ */
149
+ declare function website(options?: WebsiteProjectOptions): TestProjectInlineConfiguration;
150
+ /**
151
+ * A component test renders in a real Chromium — the browser the website facet
152
+ * already drives through the same `playwright` peer.
153
+ *
154
+ * The default `include` reaches the whole project, so the default `exclude`
155
+ * keeps it out of `specs/`, where the assembled product is specified. State
156
+ * your own `include` and the exclusion is yours to state too.
157
+ */
158
+ declare function component(options?: ComponentProjectOptions): Promise<TestProjectInlineConfiguration>;
159
+ /** `api()` — the app met through HTTP. */
160
+ declare function api(options?: FacetProjectOptions): TestProjectInlineConfiguration;
161
+ /** `jobs()` — what a name triggers in-process. */
162
+ declare function jobs(options?: FacetProjectOptions): TestProjectInlineConfiguration;
163
+ /** `integration()` — a module against real services, or against a golden. */
164
+ declare function integration(options?: FacetProjectOptions): TestProjectInlineConfiguration;
165
+ /**
166
+ * `mobile()` — the app on a simulator. A simulator is the one thing that
167
+ * cannot share a machine with itself, so it runs last and alone (group 3).
168
+ */
169
+ declare function mobile(options?: FacetProjectOptions): TestProjectInlineConfiguration;
170
+ /** `cli()` — a binary, and the documents that specify it. */
171
+ type CliProjectOptions = FacetProjectOptions & {
172
+ /**
173
+ * The literate door, wired by default: every `specs/cli/**\/*.spec.yaml`
174
+ * becomes a test file bound to `specs/cli/cli.specification.ts`. State
175
+ * `specification` to name another runner, `include` to narrow the glob, or
176
+ * `false` to collect no documents at all.
177
+ */
178
+ literate?: false | Partial<LiterateOptions>;
179
+ };
180
+ /**
181
+ * A cli project collects the facet's `.test.ts` files AND its documents: a
182
+ * `<case>.spec.yaml` is a test file of its own (docs/07 § the literate door),
183
+ * so the plugin's glob has to join the include of the project that collects
184
+ * them — which is this one, never the root config.
185
+ */
186
+ declare function cli(options?: CliProjectOptions): TestProjectInlineConfiguration;
187
+ //#endregion
188
+ export { type CliProjectOptions, type ComponentProjectOptions, type FacetProjectOptions, type LiterateOptions, type LiteratePlugin, type SpecConfig, type UnitProjectOptions, type WebsiteProjectOptions, api, cli, component, defineSpecConfig, integration, jobs, literate, mobile, unit, website };