@jterrazz/test 9.2.1 → 10.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.
package/dist/index.d.ts CHANGED
@@ -497,15 +497,53 @@ interface InterceptContract {
497
497
  declare function defineContract(contract: InterceptContract): InterceptContract;
498
498
  //#endregion
499
499
  //#region src/core/ports/browser.port.d.ts
500
+ /**
501
+ * The landmark roles — the standard set of page regions, and the only
502
+ * containers a scope can name. Closed on purpose: ARIA defines exactly these,
503
+ * so `within()` never becomes a second selector language.
504
+ */
505
+ type LandmarkKind = 'banner' | 'complementary' | 'contentinfo' | 'form' | 'main' | 'navigation' | 'region' | 'search';
506
+ /** The interactive//textual element kinds — what a visitor actually acts on. */
507
+ type ElementKind = 'button' | 'field' | 'heading' | 'link' | 'testId' | 'text';
500
508
  /**
501
509
  * A user-facing element descriptor — pure data, built by the element
502
510
  * vocabulary (`button()`, `link()`, `field()`, …) and translated into
503
511
  * concrete locators by the browser integration. CSS/XPath selectors are
504
512
  * deliberately not expressible: user-facing elements are the only surface.
513
+ *
514
+ * A descriptor must designate exactly ONE element at action time; see
515
+ * {@link ElementMatch} and CONVENTIONS W3.
505
516
  */
506
517
  interface ElementRef {
507
- kind: 'button' | 'field' | 'heading' | 'link' | 'testId' | 'text';
508
- name: string;
518
+ /**
519
+ * Match the accessible name as a whole string rather than a substring.
520
+ * Default (`false`) mirrors playwright: `link('Articles')` also matches
521
+ * "Read Articles".
522
+ */
523
+ exact?: boolean;
524
+ kind: ElementKind | LandmarkKind;
525
+ /** Landmarks may be anonymous (`main()`, `banner()`); everything else is named. */
526
+ name?: string;
527
+ /**
528
+ * Restrict the search to the elements of another descriptor — built by
529
+ * `within(scope, target)`. Chains: a scope may itself carry a scope.
530
+ */
531
+ scope?: ElementRef;
532
+ }
533
+ /**
534
+ * One candidate captured when a descriptor matched more than one element —
535
+ * the evidence the ambiguity error enumerates so the author can disambiguate
536
+ * without opening a browser.
537
+ */
538
+ interface ElementMatch {
539
+ /** Nearest landmark ancestor (`nav`, `footer`, `main`…), when there is one. */
540
+ context?: string;
541
+ /** The attribute that disambiguates most — `href` for links, `name` for fields. */
542
+ detail?: string;
543
+ /** Tag name, lower-cased. */
544
+ tag: string;
545
+ /** Text content, whitespace-collapsed and truncated. */
546
+ text: string;
509
547
  }
510
548
  /** A `<link>` element captured from the rendered document's head. */
511
549
  interface BrowserLinkElement {
@@ -1468,19 +1506,63 @@ interface ContainerPort {
1468
1506
  * Reads like English (`click(link('Articles'))`), translates to
1469
1507
  * accessibility-first locators in the browser integration. CSS/XPath is
1470
1508
  * deliberately not expressible; `testId()` is the single escape hatch.
1509
+ *
1510
+ * A descriptor must designate exactly ONE element (CONVENTIONS W3). Two knobs
1511
+ * narrow it, in this order of preference:
1512
+ *
1513
+ * 1. `within(scope, target)` — search inside a landmark, the way a person
1514
+ * would say "the Articles link *in the nav*";
1515
+ * 2. `{ exact: true }` — match the accessible name whole rather than as a
1516
+ * substring, when two names genuinely overlap.
1471
1517
  */
1518
+ /** Options accepted by every named descriptor. */
1519
+ interface ElementOptions {
1520
+ /**
1521
+ * Match the accessible name as a whole string. Default is substring —
1522
+ * `link('Articles')` also matches "Read Articles".
1523
+ */
1524
+ exact?: boolean;
1525
+ }
1472
1526
  /** A button (or element with the button role), by accessible name. */
1473
- declare const button: (name: string) => ElementRef;
1527
+ declare const button: (name: string, options?: ElementOptions) => ElementRef;
1474
1528
  /** A form field, by label. */
1475
- declare const field: (label: string) => ElementRef;
1529
+ declare const field: (name: string, options?: ElementOptions) => ElementRef;
1476
1530
  /** A heading, by accessible name. */
1477
- declare const heading: (name: string) => ElementRef;
1531
+ declare const heading: (name: string, options?: ElementOptions) => ElementRef;
1478
1532
  /** A link, by accessible name. */
1479
- declare const link: (name: string) => ElementRef;
1533
+ declare const link: (name: string, options?: ElementOptions) => ElementRef;
1480
1534
  /** An element containing the given text. */
1481
- declare const content: (value: string) => ElementRef;
1535
+ declare const content: (name: string, options?: ElementOptions) => ElementRef;
1482
1536
  /** The escape hatch: an element by `data-testid`. Prefer user-facing elements. */
1483
1537
  declare const testId: (id: string) => ElementRef;
1538
+ /** The `banner` landmark — the page header. */
1539
+ declare const banner: (name?: string, options?: ElementOptions) => ElementRef;
1540
+ /** The `complementary` landmark — an `<aside>`, a sidebar. */
1541
+ declare const complementary: (name?: string, options?: ElementOptions) => ElementRef;
1542
+ /** The `contentinfo` landmark — the page footer. */
1543
+ declare const contentinfo: (name?: string, options?: ElementOptions) => ElementRef;
1544
+ /** The `form` landmark — a form carrying an accessible name. */
1545
+ declare const form: (name?: string, options?: ElementOptions) => ElementRef;
1546
+ /** The `main` landmark — the primary content of the document. */
1547
+ declare const main: (name?: string, options?: ElementOptions) => ElementRef;
1548
+ /** The `navigation` landmark — a `<nav>`. Name it when a page has several. */
1549
+ declare const navigation: (name?: string, options?: ElementOptions) => ElementRef;
1550
+ /** The `region` landmark — a `<section>` carrying an accessible name. */
1551
+ declare const region: (name?: string, options?: ElementOptions) => ElementRef;
1552
+ /** The `search` landmark. */
1553
+ declare const search: (name?: string, options?: ElementOptions) => ElementRef;
1554
+ /**
1555
+ * Restrict a descriptor to the inside of another — the answer to ambiguity,
1556
+ * and the one the framework prefers over a test id.
1557
+ *
1558
+ * click(within(navigation(), link('Articles')))
1559
+ *
1560
+ * Composes outside-in: the scope may itself be scoped, so a deeply nested
1561
+ * target reads `within(main(), within(region('Series'), link('Part 2')))`.
1562
+ * Any descriptor works as a scope, including `testId()` when a container has
1563
+ * no landmark role to stand on.
1564
+ */
1565
+ declare const within: (scope: ElementRef, target: ElementRef) => ElementRef;
1484
1566
  //#endregion
1485
1567
  //#region src/integrations/postgres/postgres.d.ts
1486
1568
  interface PostgresOptions {
@@ -1809,4 +1891,4 @@ declare module 'vitest' {
1809
1891
  }
1810
1892
  }
1811
1893
  //#endregion
1812
- export { type ApiHandle, type ApiSpecification, type ApiSpecificationOptions, BaseResult, type BrowserConsoleMessage, type BrowserLinkElement, type BrowserMetaElement, type BrowserOpenOptions, type BrowserPage, type BrowserPort, type CaptureScope, type CliEnv, type CliHandle, type CliOutput, type CliPort, CliResult, type CliSpecification, type CliSpecificationOptions, ContainerAccessor, type ContainerPort, type DatabaseKeys, type DatabasePort, DirectoryAccessor, type DockerSpecConfig, type ElementRef, type ExecOptions, FetchResult, type FileAccessor, FilesystemAccessor, type HonoApp, HttpResult, type InterceptContract, type InterceptEntry, type InterceptResponder, type InterceptResponse, type InterceptResponseValue, type InterceptTrigger, type IsolationStrategy, type JobHandle, type JobsHandle, type JobsSpecification, type JobsSpecificationOptions, JsonAccessor, type MatchFixtureOptions, type MatchableRequest, Matcher, type MatcherKind, type MockDatePort, type MockPort, Orchestrator, PageResult, type PostgresOptions, type RedisOptions, ResponseAccessor, type ServeOptions, type ServerPort, type ServerResponse, type ServiceHandle, type ServiceRecord, type SpecificationConfig, type SpecificationMode, type SqliteOptions, TableAccessor, TextAccessor, type VisitScenario, type Visitor, type WebsiteHandle, type WebsiteSpecification, type WebsiteSpecificationOptions, anthropic, button, content, defineContract, field, findContainersByLabel, heading, http, inspectContainer, link, match, mockOf, mockOfDate, openai, postgres, redis, removeContainers, specification, sqlite, testId, text };
1894
+ export { type ApiHandle, type ApiSpecification, type ApiSpecificationOptions, BaseResult, type BrowserConsoleMessage, type BrowserLinkElement, type BrowserMetaElement, type BrowserOpenOptions, type BrowserPage, type BrowserPort, type CaptureScope, type CliEnv, type CliHandle, type CliOutput, type CliPort, CliResult, type CliSpecification, type CliSpecificationOptions, ContainerAccessor, type ContainerPort, type DatabaseKeys, type DatabasePort, DirectoryAccessor, type DockerSpecConfig, type ElementKind, type ElementMatch, type ElementOptions, type ElementRef, type ExecOptions, FetchResult, type FileAccessor, FilesystemAccessor, type HonoApp, HttpResult, type InterceptContract, type InterceptEntry, type InterceptResponder, type InterceptResponse, type InterceptResponseValue, type InterceptTrigger, type IsolationStrategy, type JobHandle, type JobsHandle, type JobsSpecification, type JobsSpecificationOptions, JsonAccessor, type LandmarkKind, type MatchFixtureOptions, type MatchableRequest, Matcher, type MatcherKind, type MockDatePort, type MockPort, Orchestrator, PageResult, type PostgresOptions, type RedisOptions, ResponseAccessor, type ServeOptions, type ServerPort, type ServerResponse, type ServiceHandle, type ServiceRecord, type SpecificationConfig, type SpecificationMode, type SqliteOptions, TableAccessor, TextAccessor, type VisitScenario, type Visitor, type WebsiteHandle, type WebsiteSpecification, type WebsiteSpecificationOptions, anthropic, banner, button, complementary, content, contentinfo, defineContract, field, findContainersByLabel, form, heading, http, inspectContainer, link, main, match, mockOf, mockOfDate, navigation, openai, postgres, redis, region, removeContainers, search, specification, sqlite, testId, text, within };
package/dist/index.js CHANGED
@@ -3637,42 +3637,65 @@ const specification = {
3637
3637
  };
3638
3638
  //#endregion
3639
3639
  //#region src/core/specification/website/elements.ts
3640
- /**
3641
- * The element vocabulary — user-facing descriptors for visit scenarios.
3642
- * Reads like English (`click(link('Articles'))`), translates to
3643
- * accessibility-first locators in the browser integration. CSS/XPath is
3644
- * deliberately not expressible; `testId()` is the single escape hatch.
3645
- */
3646
- /** A button (or element with the button role), by accessible name. */
3647
- const button = (name) => ({
3648
- kind: "button",
3649
- name
3640
+ const named = (kind) => (name, options) => ({
3641
+ kind,
3642
+ name,
3643
+ ...options?.exact ? { exact: true } : {}
3650
3644
  });
3645
+ /** A button (or element with the button role), by accessible name. */
3646
+ const button = named("button");
3651
3647
  /** A form field, by label. */
3652
- const field = (label) => ({
3653
- kind: "field",
3654
- name: label
3655
- });
3648
+ const field = named("field");
3656
3649
  /** A heading, by accessible name. */
3657
- const heading = (name) => ({
3658
- kind: "heading",
3659
- name
3660
- });
3650
+ const heading = named("heading");
3661
3651
  /** A link, by accessible name. */
3662
- const link = (name) => ({
3663
- kind: "link",
3664
- name
3665
- });
3652
+ const link = named("link");
3666
3653
  /** An element containing the given text. */
3667
- const content = (value) => ({
3668
- kind: "text",
3669
- name: value
3670
- });
3654
+ const content = named("text");
3671
3655
  /** The escape hatch: an element by `data-testid`. Prefer user-facing elements. */
3672
3656
  const testId = (id) => ({
3673
3657
  kind: "testId",
3674
3658
  name: id
3675
3659
  });
3660
+ const landmark = (kind) => (name, options) => ({
3661
+ kind,
3662
+ ...name === void 0 ? {} : { name },
3663
+ ...options?.exact ? { exact: true } : {}
3664
+ });
3665
+ /** The `banner` landmark — the page header. */
3666
+ const banner = landmark("banner");
3667
+ /** The `complementary` landmark — an `<aside>`, a sidebar. */
3668
+ const complementary = landmark("complementary");
3669
+ /** The `contentinfo` landmark — the page footer. */
3670
+ const contentinfo = landmark("contentinfo");
3671
+ /** The `form` landmark — a form carrying an accessible name. */
3672
+ const form = landmark("form");
3673
+ /** The `main` landmark — the primary content of the document. */
3674
+ const main = landmark("main");
3675
+ /** The `navigation` landmark — a `<nav>`. Name it when a page has several. */
3676
+ const navigation = landmark("navigation");
3677
+ /** The `region` landmark — a `<section>` carrying an accessible name. */
3678
+ const region = landmark("region");
3679
+ /** The `search` landmark. */
3680
+ const search = landmark("search");
3681
+ /**
3682
+ * Restrict a descriptor to the inside of another — the answer to ambiguity,
3683
+ * and the one the framework prefers over a test id.
3684
+ *
3685
+ * click(within(navigation(), link('Articles')))
3686
+ *
3687
+ * Composes outside-in: the scope may itself be scoped, so a deeply nested
3688
+ * target reads `within(main(), within(region('Series'), link('Part 2')))`.
3689
+ * Any descriptor works as a scope, including `testId()` when a container has
3690
+ * no landmark role to stand on.
3691
+ */
3692
+ const within = (scope, target) => ({
3693
+ ...target,
3694
+ scope: target.scope ? {
3695
+ ...target.scope,
3696
+ scope
3697
+ } : scope
3698
+ });
3676
3699
  //#endregion
3677
3700
  //#region src/integrations/sqlite/sqlite.ts
3678
3701
  var SqliteHandle = class {
@@ -4246,4 +4269,4 @@ registerComposeServiceFactory("postgres", (service) => postgres({
4246
4269
  }));
4247
4270
  registerComposeServiceFactory("redis", (service) => redis({ composeService: service.name }));
4248
4271
  //#endregion
4249
- export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ResponseAccessor, TableAccessor, TextAccessor, anthropic, button, content, defineContract, field, findContainersByLabel, heading, http, inspectContainer, link, match, mockOf, mockOfDate, openai, postgres, redis, removeContainers, specification, sqlite, testId, text };
4272
+ export { BaseResult, CliResult, ContainerAccessor, DirectoryAccessor, FetchResult, FilesystemAccessor, HttpResult, JsonAccessor, Matcher, Orchestrator, PageResult, ResponseAccessor, TableAccessor, TextAccessor, anthropic, banner, button, complementary, content, contentinfo, defineContract, field, findContainersByLabel, form, heading, http, inspectContainer, link, main, match, mockOf, mockOfDate, navigation, openai, postgres, redis, region, removeContainers, search, specification, sqlite, testId, text, within };
package/dist/oxlint.cjs CHANGED
@@ -571,6 +571,15 @@ const RUNTIME_RULES = [
571
571
  name: "d14-tomatch-fixture-name",
572
572
  rationale: "L’instinct hérité de vitest (`toMatch(/re/)`) tomberait sinon sur l’erreur d’extension ou coercerait la regex en `\"/re/\"`. L’argument accesseur n’est jamais littéral côté valeur, et une heuristique statique confondrait le `expect(chaîne).toMatch(/re/)` légitime (D3/D8) — seul le canal runtime refuse proprement, sans faux positifs."
573
573
  },
574
+ {
575
+ channel: "runtime",
576
+ convention: "Un descripteur d’élément doit désigner exactement UN élément : si plusieurs correspondent, l’action est refusée avec une erreur qui énumère les candidats et propose les réécritures (`within(...)`, `{ exact: true }`). Le framework n’agit jamais sur « le premier ».",
577
+ facet: "website",
578
+ family: "W",
579
+ id: "W3",
580
+ name: "w3-unambiguous-element",
581
+ rationale: "Agir sur le premier match rend le spec vert alors que le visiteur clique autre chose, et rien ne le signale jamais — une ambiguïté est une faute d’écriture, pas un cas à arbitrer par l’ordre du DOM. Le comptage n’existe qu’à l’exécution : aucune analyse statique ne peut le faire, d’où le canal runtime."
582
+ },
574
583
  {
575
584
  channel: "runtime",
576
585
  convention: "`.intercept()` n’existe que sur `api`/`jobs` et lève immédiatement en mode compose (MSW est in-process).",
package/dist/oxlint.js CHANGED
@@ -567,6 +567,15 @@ const RUNTIME_RULES = [
567
567
  name: "d14-tomatch-fixture-name",
568
568
  rationale: "L’instinct hérité de vitest (`toMatch(/re/)`) tomberait sinon sur l’erreur d’extension ou coercerait la regex en `\"/re/\"`. L’argument accesseur n’est jamais littéral côté valeur, et une heuristique statique confondrait le `expect(chaîne).toMatch(/re/)` légitime (D3/D8) — seul le canal runtime refuse proprement, sans faux positifs."
569
569
  },
570
+ {
571
+ channel: "runtime",
572
+ convention: "Un descripteur d’élément doit désigner exactement UN élément : si plusieurs correspondent, l’action est refusée avec une erreur qui énumère les candidats et propose les réécritures (`within(...)`, `{ exact: true }`). Le framework n’agit jamais sur « le premier ».",
573
+ facet: "website",
574
+ family: "W",
575
+ id: "W3",
576
+ name: "w3-unambiguous-element",
577
+ rationale: "Agir sur le premier match rend le spec vert alors que le visiteur clique autre chose, et rien ne le signale jamais — une ambiguïté est une faute d’écriture, pas un cas à arbitrer par l’ordre du DOM. Le comptage n’existe qu’à l’exécution : aucune analyse statique ne peut le faire, d’où le canal runtime."
578
+ },
570
579
  {
571
580
  channel: "runtime",
572
581
  convention: "`.intercept()` n’existe que sur `api`/`jobs` et lève immédiatement en mode compose (MSW est in-process).",
@@ -1,32 +1,226 @@
1
1
  import { mkdtempSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
+ //#region src/core/specification/website/ambiguity.ts
5
+ /**
6
+ * The ambiguity refusal — CONVENTIONS W3.
7
+ *
8
+ * A visit descriptor must designate exactly one element. Acting on "the
9
+ * first match" is the failure mode this module exists to prevent: the spec
10
+ * keeps passing while the visitor clicks something else, and nothing ever
11
+ * reports it. So when a descriptor matches several elements the framework
12
+ * refuses, and the refusal has to carry everything needed to fix it without
13
+ * opening a browser — the descriptor in the caller's own vocabulary, every
14
+ * candidate, and the concrete rewrites that would resolve it.
15
+ *
16
+ * Pure string building: it takes captured data and returns a message, so the
17
+ * wording is unit-testable and the browser integration stays a thin adapter.
18
+ */
19
+ /** `kind` → the constructor that builds it, so errors echo the caller's source. */
20
+ const CONSTRUCTORS = {
21
+ banner: "banner",
22
+ button: "button",
23
+ complementary: "complementary",
24
+ contentinfo: "contentinfo",
25
+ field: "field",
26
+ form: "form",
27
+ heading: "heading",
28
+ link: "link",
29
+ main: "main",
30
+ navigation: "navigation",
31
+ region: "region",
32
+ search: "search",
33
+ testId: "testId",
34
+ text: "content"
35
+ };
36
+ /** The landmark a context string maps back to, for a copy-pasteable suggestion. */
37
+ const CONTEXT_LANDMARKS = {
38
+ aside: "complementary()",
39
+ banner: "banner()",
40
+ complementary: "complementary()",
41
+ contentinfo: "contentinfo()",
42
+ footer: "contentinfo()",
43
+ form: "form()",
44
+ header: "banner()",
45
+ main: "main()",
46
+ nav: "navigation()",
47
+ navigation: "navigation()",
48
+ search: "search()"
49
+ };
50
+ /**
51
+ * Render a descriptor as the source that would build it — `link('Articles')`,
52
+ * `within(navigation(), link('Articles', { exact: true }))`. The error speaks
53
+ * the vocabulary the author wrote, never playwright's.
54
+ */
55
+ function formatElement(element) {
56
+ const bare = formatBare(element);
57
+ return element.scope ? `within(${formatElement(element.scope)}, ${bare})` : bare;
58
+ }
59
+ function formatBare(element) {
60
+ const args = [];
61
+ if (element.name !== void 0) args.push(JSON.stringify(element.name));
62
+ if (element.exact) args.push("{ exact: true }");
63
+ return `${CONSTRUCTORS[element.kind]}(${args.join(", ")})`;
64
+ }
65
+ /** `1. <a href="/articles">Articles</a> in <nav>` — one evidence line per candidate. */
66
+ function formatMatch(match, index) {
67
+ const attribute = match.detail ? ` ${quoteDetail(match)}` : "";
68
+ const context = match.context ? ` in <${match.context}>` : "";
69
+ return ` ${index + 1}. <${match.tag}${attribute}>${match.text}</${match.tag}>${context}`;
70
+ }
71
+ function quoteDetail(match) {
72
+ return `${match.tag === "a" ? "href" : "name"}=${JSON.stringify(match.detail)}`;
73
+ }
74
+ /**
75
+ * The rewrites worth offering, in the order they should be tried. Scoping is
76
+ * always available; `exact` only when it would actually narrow the set, and
77
+ * the count it would leave is stated so a still-ambiguous suggestion never
78
+ * reads as a fix.
79
+ */
80
+ function formatFixes(element, matches) {
81
+ const fixes = [];
82
+ const landmarks = [...new Set(matches.map((match) => match.context && CONTEXT_LANDMARKS[match.context]).filter((landmark) => Boolean(landmark)))];
83
+ if (landmarks.length > 0 && !element.scope) fixes.push(`scope it within(${landmarks[0]}, ${formatBare(element)})${landmarks.length > 1 ? ` [also here: ${landmarks.slice(1).join(", ")}]` : ""}`);
84
+ else if (!element.scope) fixes.push(`scope it within(main(), ${formatBare(element)})`);
85
+ if (!element.exact && element.name !== void 0) {
86
+ const remaining = matches.filter((match) => match.text === element.name).length;
87
+ if (remaining > 0 && remaining < matches.length) fixes.push(`exact name ${formatBare({
88
+ ...element,
89
+ exact: true
90
+ })} [leaves ${remaining} of ${matches.length}]`);
91
+ }
92
+ fixes.push("other element a heading(), button() or field() may name one thing where this does not");
93
+ return fixes;
94
+ }
95
+ /**
96
+ * The W3 refusal. A distinct class so the visit wrapper can recognize an
97
+ * already-complete message and let it through instead of nesting it inside
98
+ * `visit scenario failed: …`, which would print the whole thing twice.
99
+ */
100
+ var AmbiguousElementError = class extends Error {
101
+ name = "AmbiguousElementError";
102
+ };
103
+ /**
104
+ * Build the refusal thrown when a descriptor matches more than one element.
105
+ *
106
+ * @param options.element The descriptor as the caller wrote it.
107
+ * @param options.matches Every candidate, in DOM order (already truncated).
108
+ * @param options.url The page the visitor was on when it happened.
109
+ */
110
+ function describeAmbiguity(options) {
111
+ const { element, matches, url } = options;
112
+ const fixes = formatFixes(element, matches).map((fix) => ` • ${fix}`);
113
+ return [
114
+ `Ambiguous element: ${formatElement(element)} matched ${matches.length} elements on ${url}.`,
115
+ "",
116
+ "A spec must designate exactly one element. Acting on the first match would let",
117
+ "this test keep passing while the visitor interacts with something else.",
118
+ "",
119
+ "Matched:",
120
+ ...matches.map((match, index) => formatMatch(match, index)),
121
+ "",
122
+ "Disambiguate with one of:",
123
+ ...fixes,
124
+ "",
125
+ "Docs: docs/11-website.md#designating-exactly-one-element (CONVENTIONS W3)"
126
+ ].join("\n");
127
+ }
128
+ //#endregion
4
129
  //#region src/integrations/playwright/playwright.adapter.ts
5
- /** Translate a user-facing element descriptor into a playwright locator. */
6
- function locate(page, element) {
130
+ /** How many candidates the ambiguity error enumerates before truncating. */
131
+ const MAX_REPORTED_MATCHES = 10;
132
+ /**
133
+ * Translate a user-facing descriptor into a playwright locator, resolving
134
+ * `scope` outside-in so `within(navigation(), link('X'))` searches the nav
135
+ * subtree. No `.first()` anywhere: playwright's strict mode is the mechanism
136
+ * behind CONVENTIONS W3, and swallowing it would reintroduce the silent
137
+ * wrong-element bug the rule exists to prevent.
138
+ */
139
+ function locate(root, element) {
140
+ const scope = element.scope ? locate(root, element.scope) : root;
141
+ const options = {
142
+ exact: element.exact,
143
+ name: element.name
144
+ };
7
145
  switch (element.kind) {
8
- case "button": return page.getByRole("button", { name: element.name });
9
- case "field": return page.getByLabel(element.name);
10
- case "heading": return page.getByRole("heading", { name: element.name });
11
- case "link": return page.getByRole("link", { name: element.name });
12
- case "testId": return page.getByTestId(element.name);
13
- case "text": return page.getByText(element.name);
146
+ case "banner":
147
+ case "complementary":
148
+ case "contentinfo":
149
+ case "form":
150
+ case "main":
151
+ case "navigation":
152
+ case "region":
153
+ case "search": return scope.getByRole(element.kind, options);
154
+ case "button":
155
+ case "heading":
156
+ case "link": return scope.getByRole(element.kind, options);
157
+ case "field": return scope.getByLabel(element.name ?? "", { exact: element.exact });
158
+ case "testId": return scope.getByTestId(element.name ?? "");
159
+ case "text": return scope.getByText(element.name ?? "", { exact: element.exact });
14
160
  }
15
161
  }
162
+ /** Playwright signals "more than one match" through this error text. */
163
+ function isStrictViolation(error) {
164
+ return error instanceof Error && error.message.includes("strict mode violation");
165
+ }
166
+ /** Capture the candidates in-page — the evidence the refusal enumerates. */
167
+ async function captureMatches(locator) {
168
+ return locator.evaluateAll((nodes, limit) => {
169
+ const LANDMARKS = "nav, header, footer, main, aside, section, form, [role]";
170
+ return nodes.slice(0, limit).map((node) => {
171
+ const element = node;
172
+ const landmark = element.parentElement?.closest(LANDMARKS);
173
+ const context = landmark ? landmark.getAttribute("role") ?? landmark.tagName.toLowerCase() : null;
174
+ const detail = element.getAttribute("href") ?? element.getAttribute("name");
175
+ return {
176
+ context: context ?? void 0,
177
+ detail: detail ?? void 0,
178
+ tag: element.tagName.toLowerCase(),
179
+ text: (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 80)
180
+ };
181
+ });
182
+ }, MAX_REPORTED_MATCHES);
183
+ }
184
+ /**
185
+ * Run one visitor action, converting a strict-mode violation into the W3
186
+ * refusal. The ambiguous level is identified before reporting: a scope that
187
+ * matches several landmarks is the real fault, and naming the target instead
188
+ * would send the author to fix the wrong descriptor.
189
+ */
190
+ async function act(page, element, action) {
191
+ try {
192
+ return await action(locate(page, element));
193
+ } catch (error) {
194
+ if (!isStrictViolation(error)) throw error;
195
+ const culprit = await findAmbiguousLevel(page, element);
196
+ throw new AmbiguousElementError(describeAmbiguity({
197
+ element: culprit,
198
+ matches: await captureMatches(locate(page, culprit)),
199
+ url: page.url()
200
+ }));
201
+ }
202
+ }
203
+ /** Walk the scope chain outside-in; the outermost ambiguous level is the fault. */
204
+ async function findAmbiguousLevel(page, element) {
205
+ const chain = [];
206
+ for (let current = element; current; current = current.scope) chain.unshift(current);
207
+ for (const level of chain.slice(0, -1)) if (await locate(page, level).count() > 1) return level;
208
+ return element;
209
+ }
16
210
  /** The visitor implementation — every action auto-waits via playwright actionability. */
17
211
  function createVisitor(page, baseUrl) {
18
212
  return {
19
- check: (element) => locate(page, element).first().check(),
20
- click: (element) => locate(page, element).first().click(),
21
- fill: (element, value) => locate(page, element).first().fill(value),
213
+ check: (element) => act(page, element, (locator) => locator.check()),
214
+ click: (element) => act(page, element, (locator) => locator.click()),
215
+ fill: (element, value) => act(page, element, (locator) => locator.fill(value)),
22
216
  goto: async (path) => {
23
217
  await page.goto(`${baseUrl}${path}`, { waitUntil: "load" });
24
218
  },
25
- hover: (element) => locate(page, element).first().hover(),
219
+ hover: (element) => act(page, element, (locator) => locator.hover()),
26
220
  press: (key) => page.keyboard.press(key),
27
- see: (element) => locate(page, element).first().waitFor({ state: "visible" }),
221
+ see: (element) => act(page, element, (locator) => locator.waitFor({ state: "visible" })),
28
222
  select: async (element, option) => {
29
- await locate(page, element).first().selectOption(option);
223
+ await act(page, element, (locator) => locator.selectOption(option));
30
224
  }
31
225
  };
32
226
  }
@@ -71,7 +265,12 @@ var PlaywrightAdapter = class {
71
265
  await options.scenario(createVisitor(page, options.baseUrl));
72
266
  } catch (error) {
73
267
  const evidence = await this.captureEvidence(page);
74
- throw new Error(`visit scenario failed: ${error instanceof Error ? error.message : String(error)}${evidence ? `\nEvidence: ${evidence}` : ""}`, { cause: error });
268
+ const suffix = evidence ? `\nEvidence: ${evidence}` : "";
269
+ if (error instanceof AmbiguousElementError) {
270
+ error.message += suffix;
271
+ throw error;
272
+ }
273
+ throw new Error(`visit scenario failed: ${error instanceof Error ? error.message : String(error)}${suffix}`, { cause: error });
75
274
  }
76
275
  const extraction = await page.evaluate(() => {
77
276
  const links = [...document.querySelectorAll("link")].map((link) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jterrazz/test",
3
- "version": "9.2.1",
3
+ "version": "10.0.0",
4
4
  "description": "Declarative testing framework for HTTP APIs, CLIs, and background jobs — one entry point, real infrastructure, golden-file assertions, plus an oxlint convention plugin.",
5
5
  "keywords": [
6
6
  "api-testing",
@@ -49,8 +49,7 @@
49
49
  "docs": "npm run build && node dist/catalog.js && typescript docs",
50
50
  "lint": "typescript check && node dist/checker.js specs",
51
51
  "lint:fix": "typescript fix",
52
- "test": "vitest --run",
53
- "pretest": "playwright install chromium"
52
+ "test": "vitest --run"
54
53
  },
55
54
  "dependencies": {
56
55
  "better-sqlite3": "^12.11.1",