@jterrazz/test 9.2.0 → 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 +90 -8
- package/dist/index.js +51 -28
- package/dist/oxlint.cjs +9 -0
- package/dist/oxlint.js +9 -0
- package/dist/playwright.adapter.js +214 -15
- package/package.json +2 -3
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
|
-
|
|
508
|
-
|
|
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: (
|
|
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: (
|
|
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
|
@@ -1096,7 +1096,7 @@ function getCallerDir() {
|
|
|
1096
1096
|
if ((filePath.includes("/src/core/") || filePath.includes("/src/integrations/") || filePath.includes("/src/vitest/") || resolve(filePath, "..") === FRAMEWORK_DIR) && !/\.test\.[cm]?[jt]s$/.test(filePath)) continue;
|
|
1097
1097
|
return resolve(filePath, "..");
|
|
1098
1098
|
}
|
|
1099
|
-
|
|
1099
|
+
return process.cwd();
|
|
1100
1100
|
}
|
|
1101
1101
|
//#endregion
|
|
1102
1102
|
//#region src/core/specification/shared/result/grep.ts
|
|
@@ -3637,42 +3637,65 @@ const specification = {
|
|
|
3637
3637
|
};
|
|
3638
3638
|
//#endregion
|
|
3639
3639
|
//#region src/core/specification/website/elements.ts
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
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 = (
|
|
3653
|
-
kind: "field",
|
|
3654
|
-
name: label
|
|
3655
|
-
});
|
|
3648
|
+
const field = named("field");
|
|
3656
3649
|
/** A heading, by accessible name. */
|
|
3657
|
-
const heading = (
|
|
3658
|
-
kind: "heading",
|
|
3659
|
-
name
|
|
3660
|
-
});
|
|
3650
|
+
const heading = named("heading");
|
|
3661
3651
|
/** A link, by accessible name. */
|
|
3662
|
-
const link = (
|
|
3663
|
-
kind: "link",
|
|
3664
|
-
name
|
|
3665
|
-
});
|
|
3652
|
+
const link = named("link");
|
|
3666
3653
|
/** An element containing the given text. */
|
|
3667
|
-
const content = (
|
|
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
|
-
/**
|
|
6
|
-
|
|
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 "
|
|
9
|
-
case "
|
|
10
|
-
case "
|
|
11
|
-
case "
|
|
12
|
-
case "
|
|
13
|
-
case "
|
|
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) =>
|
|
20
|
-
click: (element) =>
|
|
21
|
-
fill: (element, 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) =>
|
|
219
|
+
hover: (element) => act(page, element, (locator) => locator.hover()),
|
|
26
220
|
press: (key) => page.keyboard.press(key),
|
|
27
|
-
see: (element) =>
|
|
221
|
+
see: (element) => act(page, element, (locator) => locator.waitFor({ state: "visible" })),
|
|
28
222
|
select: async (element, option) => {
|
|
29
|
-
await
|
|
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
|
-
|
|
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": "
|
|
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",
|