@jterrazz/test 15.2.0 → 16.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,12 +3,76 @@ import { mkdtempSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { tmpdir } from "node:os";
5
5
  import { setTimeout } from "node:timers/promises";
6
- //#region src/integrations/playwright/playwright.adapter.ts
6
+ //#region src/model/elements/substring-warning.ts
7
+ /**
8
+ * The transitional warning for a name that matches only as a SUBSTRING.
9
+ *
10
+ * Until 16.0 a descriptor's name matched any element whose accessible name
11
+ * CONTAINED it, so `link('Articles')` also designated "Read Articles" — and a
12
+ * test could pass for years against the wrong element without anyone noticing,
13
+ * because the wrong element was usually next to the right one. Exact is the
14
+ * default now.
15
+ *
16
+ * A lint rule cannot carry this: whether a name matched whole or in part is a
17
+ * fact about a RUN, and a verdict fed by a previous run's artefact is not
18
+ * deterministic. So it is a runtime warning, printed by the adapter that did
19
+ * the looking, at the moment it has both answers in hand — nothing found under
20
+ * exact, something found under substring.
21
+ *
22
+ * It is printed ONCE per descriptor per process: a scenario that clicks the
23
+ * same link in ten tests has one thing to fix, not ten lines to read.
24
+ */
25
+ /** How long the two-release window lasts, stated in the message itself. */
26
+ const WINDOW = "16.0 and 16.1";
27
+ /** Descriptors already reported in this process. */
28
+ const reported = /* @__PURE__ */ new Set();
29
+ /** The identity a descriptor is deduplicated by — kind, name, and its scope chain. */
30
+ function identityOf(element) {
31
+ const own = `${element.kind}:${element.name ?? ""}`;
32
+ return element.scope === void 0 ? own : `${identityOf(element.scope)}>${own}`;
33
+ }
34
+ /**
35
+ * Report that `element` designated nothing as a whole name, but would have
36
+ * designated something as a substring. Returns whether a line was printed,
37
+ * which is what the adapters' own tests assert on.
38
+ */
39
+ function warnSubstringOnly(element, where) {
40
+ const identity = identityOf(element);
41
+ if (reported.has(identity)) return false;
42
+ reported.add(identity);
43
+ console.warn(`@jterrazz/test: ${element.kind}('${element.name ?? ""}') matched nothing as a whole accessible name, and matched only as a SUBSTRING of a longer one (${where}). Names are exact from 16.0: write the name in full, scope it with \`within(…)\`, or state \`{ exact: false }\` to keep the old behaviour. This warning ships for ${WINDOW}; after that the substring match is gone and this is simply not found.`);
44
+ return true;
45
+ }
46
+ //#endregion
47
+ //#region src/seams/playwright/playwright.adapter.ts
7
48
  /** How many candidates the ambiguity error enumerates before truncating. */
8
49
  const MAX_REPORTED_MATCHES = 10;
9
50
  const VALUE_TIMEOUT_MS = 3e4;
10
51
  const VALUE_POLL_MS = 100;
11
52
  /**
53
+ * The roles a FIELD can carry — what `field('Name')` designates.
54
+ *
55
+ * A field is named by its ACCESSIBLE name, not by the text of the label
56
+ * element that happens to wrap it: `<label><span>Channel</span><select>…` has
57
+ * a label whose text carries every option of the select, while the control's
58
+ * accessible name is `Channel` — which is what the ARIA tree shows and what a
59
+ * screen reader reads. Resolving by label text alone made the ordinary
60
+ * wrapping-label form unreachable under the 16.0 whole-name default.
61
+ *
62
+ * The label-text locator stays in the union for the controls ARIA gives no
63
+ * role at all (a date or colour input), which nothing else would find.
64
+ */
65
+ const FIELD_ROLES = [
66
+ "checkbox",
67
+ "combobox",
68
+ "radio",
69
+ "searchbox",
70
+ "slider",
71
+ "spinbutton",
72
+ "switch",
73
+ "textbox"
74
+ ];
75
+ /**
12
76
  * Translate a user-facing descriptor into a playwright locator, resolving
13
77
  * `scope` outside-in so `within(navigation(), link('X'))` searches the nav
14
78
  * subtree. No `.first()` anywhere: playwright's strict mode is the mechanism
@@ -17,9 +81,16 @@ const VALUE_POLL_MS = 100;
17
81
  */
18
82
  function locate(root, element) {
19
83
  const scope = element.scope ? locate(root, element.scope) : root;
20
- const exact = element.exact ?? false;
84
+ const exact = element.exact ?? true;
21
85
  const name = element.name ?? "";
22
- if (element.kind === "field") return scope.getByLabel(name, { exact });
86
+ if (element.kind === "field") {
87
+ let field = scope.getByLabel(name, { exact });
88
+ for (const role of FIELD_ROLES) field = field.or(scope.getByRole(role, {
89
+ exact,
90
+ name
91
+ }));
92
+ return field;
93
+ }
23
94
  if (element.kind === "testId") return scope.getByTestId(name);
24
95
  if (element.kind === "text") return scope.getByText(name, { exact });
25
96
  return scope.getByRole(element.kind, element.name === void 0 ? { exact } : {
@@ -62,15 +133,37 @@ async function act(page, element, action) {
62
133
  try {
63
134
  return await action(locate(page, element));
64
135
  } catch (error) {
65
- if (!isStrictViolation(error)) throw error;
136
+ if (!isStrictViolation(error)) {
137
+ await reportSubstringOnly(page, element);
138
+ throw error;
139
+ }
66
140
  const culprit = await findAmbiguousLevel(page, element);
141
+ const matches = await captureMatches(locate(page, culprit));
67
142
  throw new AmbiguousElementError(describeAmbiguity({
68
143
  element: culprit,
69
- matches: await captureMatches(locate(page, culprit)),
144
+ matches,
70
145
  url: page.url()
71
146
  }));
72
147
  }
73
148
  }
149
+ /**
150
+ * Say so when a descriptor found NOTHING as a whole name but would have found
151
+ * something as a substring — the one shape the whole-name default changes.
152
+ *
153
+ * Asked only on the failure path, and only for a descriptor that stated no
154
+ * `exact` of its own: an author who wrote `{ exact: false }` chose the
155
+ * substring, and one who wrote `{ exact: true }` was already exact.
156
+ */
157
+ async function reportSubstringOnly(page, element) {
158
+ if (element.exact !== void 0 || element.name === void 0) return;
159
+ try {
160
+ if (await locate(page, element).count() > 0) return;
161
+ if (await locate(page, {
162
+ ...element,
163
+ exact: false
164
+ }).count() > 0) warnSubstringOnly(element, page.url());
165
+ } catch {}
166
+ }
74
167
  /** Walk the scope chain outside-in; the outermost ambiguous level is the fault. */
75
168
  async function findAmbiguousLevel(page, element) {
76
169
  const chain = [];
@@ -222,6 +315,7 @@ var PlaywrightAdapter = class {
222
315
  }
223
316
  try {
224
317
  const page = await context.newPage();
318
+ if (options.clock !== void 0) await page.clock.setFixedTime(new Date(options.clock));
225
319
  const consoleMessages = [];
226
320
  page.on("console", (message) => {
227
321
  consoleMessages.push({
@@ -278,7 +372,8 @@ var PlaywrightAdapter = class {
278
372
  /** Screenshot the failing state into a temp file; never masks the original error. */
279
373
  async captureEvidence(page) {
280
374
  try {
281
- const path = resolve(mkdtempSync(resolve(tmpdir(), "spec-website-")), "failure.png");
375
+ const dir = mkdtempSync(resolve(tmpdir(), "spec-website-"));
376
+ const path = resolve(dir, "failure.png");
282
377
  await page.screenshot({
283
378
  fullPage: true,
284
379
  path
@@ -1,5 +1,5 @@
1
- import { t as ContractQueue } from "./queue.js";
2
- //#region src/integrations/msw/handlers.ts
1
+ import { a as isStreamBody, n as ContractQueue, t as toReadableStream } from "./stream.js";
2
+ //#region src/seams/msw/handlers.ts
3
3
  /**
4
4
  * What a chain with zero contracts gets where the facet's scope is KNOWN but
5
5
  * not total — the node engine, which shares a process with everything else the
@@ -31,6 +31,14 @@ function toMswResponse(msw, response) {
31
31
  },
32
32
  status
33
33
  });
34
+ if (isStreamBody(body)) return new msw.HttpResponse(toReadableStream(body), {
35
+ headers: {
36
+ ...NEVER_CACHED,
37
+ "content-type": body.contentType,
38
+ ...headers
39
+ },
40
+ status
41
+ });
34
42
  if (typeof body === "string") return new msw.HttpResponse(body, {
35
43
  headers: {
36
44
  ...NEVER_CACHED,
@@ -107,7 +115,7 @@ async function observe(request) {
107
115
  };
108
116
  }
109
117
  //#endregion
110
- //#region src/integrations/msw/intercept.ts
118
+ //#region src/seams/msw/server.ts
111
119
  let mswModule = null;
112
120
  let mswHttp = null;
113
121
  async function loadMsw() {
@@ -1,6 +1,6 @@
1
1
  import { t as CaptureScope } from "./match.js";
2
- import { I as textEquals, M as placeholderPatternSource, k as hasPlaceholders } from "./artifacts.js";
3
- //#region src/specification/contracts/contract.ts
2
+ import { A as placeholderPatternSource, D as hasPlaceholders, P as textEquals } from "./literate.js";
3
+ //#region src/model/contracts/contract.ts
4
4
  /**
5
5
  * Declare one contract. Identity function — its value is the enforced shape
6
6
  * and the naming convention:
@@ -86,7 +86,36 @@ function contractsOf(input) {
86
86
  return flatten([input]);
87
87
  }
88
88
  //#endregion
89
- //#region src/specification/contracts/queue.ts
89
+ //#region src/model/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/model/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
118
+ //#region src/model/contracts/queue.ts
90
119
  /**
91
120
  * The ONE contract queue — pure, engine-agnostic (no MSW, no node:http).
92
121
  * Both engines consume it: the MSW integration on api/jobs chains, and the
@@ -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/model/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,6 +1,6 @@
1
1
  import { TestProjectInlineConfiguration, ViteUserConfig } from "vitest/config";
2
2
  import { UserConfig, UserConfigFn } from "vite";
3
- //#region src/vitest/literate-plugin.d.ts
3
+ //#region src/runner/literate-plugin.d.ts
4
4
  type LiterateOptions = {
5
5
  /**
6
6
  * Globs added to the project's test include. Default: every `.spec.yaml`
@@ -40,7 +40,7 @@ type LiteratePlugin = {
40
40
  };
41
41
  declare function literate(options: LiterateOptions): LiteratePlugin;
42
42
  //#endregion
43
- //#region src/vitest/preset.d.ts
43
+ //#region src/runner/preset.d.ts
44
44
  /**
45
45
  * Options are a vite/vitest config plus one key of the framework's own.
46
46
  * Everything stated here wins over the preset's defaults.
@@ -58,24 +58,26 @@ type SpecConfig = {
58
58
  } & ViteUserConfig;
59
59
  declare function defineSpecConfig(config?: SpecConfig): ViteUserConfig;
60
60
  //#endregion
61
- //#region src/vitest/projects.d.ts
61
+ //#region src/runner/facet-project.d.ts
62
62
  /**
63
- * The project helpers — one per kind of test, each the canonical project that
64
- * kind runs in.
63
+ * The node facets' canonical projects.
65
64
  *
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.
65
+ * Each one collects the facet's own tree — `specs/<facet>/**\/*.spec.ts`, the
66
+ * suffix that says "the assembled product" — carries the preset's budgets and
67
+ * artefact directory, and runs in group 0 — the browser facets follow, so two
68
+ * Chromiums never share a slot on a two-vCPU runner. `{ include, exclude,
69
+ * timeout, serial }` is what a repository states when its tree, its budget or
70
+ * its parallelism differs;
71
+ * everything else is the framework's.
72
72
  */
73
73
  /** What every helper accepts on top of its canonical project. */
74
- type CommonOptions = {
74
+ type FacetProjectOptions = {
75
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.
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.
79
81
  */
80
82
  exclude?: string[];
81
83
  /** Replace the canonical `include` globs. */
@@ -89,16 +91,31 @@ type CommonOptions = {
89
91
  /** Raise (or lower) the 30s budget for this project alone. */
90
92
  timeout?: number;
91
93
  };
92
- /** `unit()` — module tests, beside the modules they cover (CONVENTIONS I2). */
93
- type UnitProjectOptions = CommonOptions & {
94
+ //#endregion
95
+ //#region src/facets/api/api.project.d.ts
96
+ /** `api()` — the app met through HTTP. */
97
+ declare function api(options?: FacetProjectOptions): TestProjectInlineConfiguration;
98
+ //#endregion
99
+ //#region src/facets/cli/cli.project.d.ts
100
+ /** `cli()` — a binary, and the documents that specify it. */
101
+ type CliProjectOptions = FacetProjectOptions & {
94
102
  /**
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.
103
+ * The literate door, wired by default: every `specs/cli/**\/*.spec.yaml`
104
+ * becomes a test file bound to `specs/cli/cli.specification.ts`. State
105
+ * `specification` to name another runner, `include` to narrow the glob, or
106
+ * `false` to collect no documents at all.
97
107
  */
98
- roots?: string[];
108
+ literate?: false | Partial<LiterateOptions>;
99
109
  };
100
- /** `website()` — the assembled product, met through a served page. */
101
- type WebsiteProjectOptions = CommonOptions;
110
+ /**
111
+ * A cli project collects the facet's `.test.ts` files AND its documents: a
112
+ * `<case>.spec.yaml` is a test file of its own (docs/07 § the literate door),
113
+ * so the plugin's glob has to join the include of the project that collects
114
+ * them — which is this one, never the root config.
115
+ */
116
+ declare function cli(options?: CliProjectOptions): TestProjectInlineConfiguration;
117
+ //#endregion
118
+ //#region src/facets/component/component.project.d.ts
102
119
  /** `component()` — rendered units, beside the components they cover. */
103
120
  type ComponentProjectOptions = {
104
121
  /** Freeze the page's `Date` for every render of the project. */
@@ -130,14 +147,35 @@ type ComponentProjectOptions = {
130
147
  * provider). A router belongs on the chain instead: it is a test's Given.
131
148
  */
132
149
  wrap?: string;
133
- } & CommonOptions;
150
+ } & FacetProjectOptions;
134
151
  /**
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.
152
+ * A component test renders in a real Chromium — the browser the website facet
153
+ * already drives through the same `playwright` peer.
154
+ *
155
+ * The default `include` reaches the whole project, so the default `exclude`
156
+ * keeps it out of `specs/`, where the assembled product is specified. State
157
+ * your own `include` and the exclusion is yours to state too.
139
158
  */
140
- declare function unit(options?: UnitProjectOptions): TestProjectInlineConfiguration;
159
+ declare function component(options?: ComponentProjectOptions): Promise<TestProjectInlineConfiguration>;
160
+ //#endregion
161
+ //#region src/facets/integration/integration.project.d.ts
162
+ /** `integration()` — a module against real services, or against a golden. */
163
+ declare function integration(options?: FacetProjectOptions): TestProjectInlineConfiguration;
164
+ //#endregion
165
+ //#region src/facets/jobs/jobs.project.d.ts
166
+ /** `jobs()` — what a name triggers in-process. */
167
+ declare function jobs(options?: FacetProjectOptions): TestProjectInlineConfiguration;
168
+ //#endregion
169
+ //#region src/facets/mobile/mobile.project.d.ts
170
+ /**
171
+ * `mobile()` — the app on a simulator. A simulator is the one thing that
172
+ * cannot share a machine with itself, so it runs last and alone (group 3).
173
+ */
174
+ declare function mobile(options?: FacetProjectOptions): TestProjectInlineConfiguration;
175
+ //#endregion
176
+ //#region src/facets/website/website.project.d.ts
177
+ /** `website()` — the assembled product, met through a served page. */
178
+ type WebsiteProjectOptions = FacetProjectOptions;
141
179
  /**
142
180
  * A website spec meets the assembled product through a served page, in the
143
181
  * Chromium playwright drives. It opens a browser, so it is scheduled before the
@@ -145,14 +183,25 @@ declare function unit(options?: UnitProjectOptions): TestProjectInlineConfigurat
145
183
  * a 2-vCPU runner.
146
184
  */
147
185
  declare function website(options?: WebsiteProjectOptions): TestProjectInlineConfiguration;
186
+ //#endregion
187
+ //#region src/runner/projects.d.ts
188
+ /** `unit()` — module tests, beside the modules they cover (CONVENTIONS I2). */
189
+ type UnitProjectOptions = FacetProjectOptions & {
190
+ /**
191
+ * The trees module tests live in, when they do not live in `src/`. Each
192
+ * root becomes `<root>/**\/*.test.ts`; `include` states the globs outright.
193
+ */
194
+ roots?: string[];
195
+ };
148
196
  /**
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.
197
+ * A module test runs beside the module it covers: no DOM, no browser, no
198
+ * pipeline. Its suffix is `.test.ts` and its place is OUTSIDE `specs/`, where
199
+ * the assembled product is specified in `.spec.ts` — the two exclusions say
200
+ * exactly that. `.test.tsx` is the third exclusion because a rendered thing is
201
+ * a component test: a project's dependency scan reads every file its globs
202
+ * match, and a `.tsx` collected here would be optimised for a page that never
203
+ * opens.
155
204
  */
156
- declare function component(options?: ComponentProjectOptions): Promise<TestProjectInlineConfiguration>;
205
+ declare function unit(options?: UnitProjectOptions): TestProjectInlineConfiguration;
157
206
  //#endregion
158
- export { type ComponentProjectOptions, type LiterateOptions, type LiteratePlugin, type SpecConfig, type UnitProjectOptions, type WebsiteProjectOptions, component, defineSpecConfig, literate, unit, website };
207
+ 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 };