@jterrazz/test 15.3.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 = [];
@@ -279,7 +372,8 @@ var PlaywrightAdapter = class {
279
372
  /** Screenshot the failing state into a temp file; never masks the original error. */
280
373
  async captureEvidence(page) {
281
374
  try {
282
- 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");
283
377
  await page.screenshot({
284
378
  fullPage: true,
285
379
  path
@@ -1,5 +1,5 @@
1
1
  import { a as isStreamBody, n as ContractQueue, t as toReadableStream } from "./stream.js";
2
- //#region src/integrations/msw/handlers.ts
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
@@ -115,7 +115,7 @@ async function observe(request) {
115
115
  };
116
116
  }
117
117
  //#endregion
118
- //#region src/integrations/msw/intercept.ts
118
+ //#region src/seams/msw/server.ts
119
119
  let mswModule = null;
120
120
  let mswHttp = null;
121
121
  async function loadMsw() {
package/dist/stream.js CHANGED
@@ -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,7 @@ function contractsOf(input) {
86
86
  return flatten([input]);
87
87
  }
88
88
  //#endregion
89
- //#region src/specification/contracts/types.ts
89
+ //#region src/model/contracts/types.ts
90
90
  /**
91
91
  * The marker that tells a STREAMED body from a value that happens to be an
92
92
  * object. A body reaches three different engines (msw's server, msw's worker,
@@ -100,7 +100,7 @@ function isStreamBody(body) {
100
100
  return typeof body === "object" && body !== null && "kind" in body && body.kind === "@jterrazz/test:stream";
101
101
  }
102
102
  //#endregion
103
- //#region src/specification/timing/pause.ts
103
+ //#region src/model/timing/pause.ts
104
104
  /**
105
105
  * One answer to "wait" — the only `setTimeout` the model owns.
106
106
  *
@@ -115,7 +115,7 @@ async function pause(ms) {
115
115
  });
116
116
  }
117
117
  //#endregion
118
- //#region src/specification/contracts/queue.ts
118
+ //#region src/model/contracts/queue.ts
119
119
  /**
120
120
  * The ONE contract queue — pure, engine-agnostic (no MSW, no node:http).
121
121
  * Both engines consume it: the MSW integration on api/jobs chains, and the
@@ -288,7 +288,7 @@ var ContractQueue = class {
288
288
  }
289
289
  };
290
290
  //#endregion
291
- //#region src/specification/contracts/stream.ts
291
+ //#region src/model/contracts/stream.ts
292
292
  /**
293
293
  * A declared stream, made real — ONE serialisation for all three engines.
294
294
  *
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,17 +58,17 @@ 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
74
  type FacetProjectOptions = {
@@ -91,16 +91,31 @@ type FacetProjectOptions = {
91
91
  /** Raise (or lower) the 30s budget for this project alone. */
92
92
  timeout?: number;
93
93
  };
94
- /** `unit()` — module tests, beside the modules they cover (CONVENTIONS I2). */
95
- type UnitProjectOptions = FacetProjectOptions & {
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 & {
96
102
  /**
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.
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.
99
107
  */
100
- roots?: string[];
108
+ literate?: false | Partial<LiterateOptions>;
101
109
  };
102
- /** `website()` — the assembled product, met through a served page. */
103
- type WebsiteProjectOptions = FacetProjectOptions;
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
104
119
  /** `component()` — rendered units, beside the components they cover. */
105
120
  type ComponentProjectOptions = {
106
121
  /** Freeze the page's `Date` for every render of the project. */
@@ -133,20 +148,6 @@ type ComponentProjectOptions = {
133
148
  */
134
149
  wrap?: string;
135
150
  } & 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
  /**
151
152
  * A component test renders in a real Chromium — the browser the website facet
152
153
  * already drives through the same `playwright` peer.
@@ -156,33 +157,51 @@ declare function website(options?: WebsiteProjectOptions): TestProjectInlineConf
156
157
  * your own `include` and the exclusion is yours to state too.
157
158
  */
158
159
  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;
160
+ //#endregion
161
+ //#region src/facets/integration/integration.project.d.ts
163
162
  /** `integration()` — a module against real services, or against a golden. */
164
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
165
170
  /**
166
171
  * `mobile()` — the app on a simulator. A simulator is the one thing that
167
172
  * cannot share a machine with itself, so it runs last and alone (group 3).
168
173
  */
169
174
  declare function mobile(options?: FacetProjectOptions): TestProjectInlineConfiguration;
170
- /** `cli()` — a binary, and the documents that specify it. */
171
- type CliProjectOptions = FacetProjectOptions & {
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;
179
+ /**
180
+ * A website spec meets the assembled product through a served page, in the
181
+ * Chromium playwright drives. It opens a browser, so it is scheduled before the
182
+ * component project and never beside it: two Chromiums must not share a slot on
183
+ * a 2-vCPU runner.
184
+ */
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 & {
172
190
  /**
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.
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.
177
193
  */
178
- literate?: false | Partial<LiterateOptions>;
194
+ roots?: string[];
179
195
  };
180
196
  /**
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.
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.
185
204
  */
186
- declare function cli(options?: CliProjectOptions): TestProjectInlineConfiguration;
205
+ declare function unit(options?: UnitProjectOptions): TestProjectInlineConfiguration;
187
206
  //#endregion
188
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 };