@jterrazz/test 9.2.1 → 10.1.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/README.md +72 -20
- package/dist/ambiguity.js +126 -0
- package/dist/appium.adapter.js +386 -0
- package/dist/index.d.ts +434 -14
- package/dist/index.js +651 -46
- package/dist/oxlint.cjs +111 -59
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +111 -59
- package/dist/playwright.adapter.js +92 -17
- package/package.json +14 -6
package/dist/oxlint.cjs
CHANGED
|
@@ -166,7 +166,7 @@ const RULE_DOCS = {
|
|
|
166
166
|
},
|
|
167
167
|
"a2-known-constructors": {
|
|
168
168
|
channel: "statique",
|
|
169
|
-
convention: "
|
|
169
|
+
convention: "Cinq constructeurs et seulement cinq : `specification.api()`, `specification.jobs()`, `specification.cli(bin)`, `specification.website()`, `specification.mobile()` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
|
|
170
170
|
family: "A",
|
|
171
171
|
id: "A2",
|
|
172
172
|
rationale: "Une surface fermée empêche l’invention de constructeurs parallèles et garde l’API mémorisable."
|
|
@@ -439,16 +439,16 @@ const RULE_DOCS = {
|
|
|
439
439
|
},
|
|
440
440
|
"w1-scenario-pure": {
|
|
441
441
|
channel: "statique",
|
|
442
|
-
convention: "Un scénario
|
|
443
|
-
facet: "
|
|
442
|
+
convention: "Un scénario (`.visit()` website, `.open()` mobile) est le When : le visiteur agit, la capture reflète l’état final ; aucun `expect()` dans le callback — les assertions vivent dans le Then, sur le résultat retourné.",
|
|
443
|
+
facet: "shared",
|
|
444
444
|
family: "W",
|
|
445
445
|
id: "W1",
|
|
446
446
|
rationale: "Séparer l’interaction de l’assertion garde la grammaire setup → action → résultat intacte et les scénarios rejouables."
|
|
447
447
|
},
|
|
448
448
|
"w2w-user-facing-elements": {
|
|
449
449
|
channel: "statique",
|
|
450
|
-
convention: "Les éléments d’un scénario sont user-facing (`button`, `link`, `field`, `heading`, `content`) ; `testId()` est l’unique échappatoire et déclenche un avertissement.",
|
|
451
|
-
facet: "
|
|
450
|
+
convention: "Les éléments d’un scénario sont user-facing (`button`, `link`, `field`, `heading`, `content` ; côté mobile `button`, `field`, `content`) ; `testId()` est l’unique échappatoire et déclenche un avertissement.",
|
|
451
|
+
facet: "shared",
|
|
452
452
|
family: "W",
|
|
453
453
|
id: "W2",
|
|
454
454
|
rationale: "Tester ce que l’utilisateur voit (rôles, labels) rend les specs robustes aux refontes DOM ; un test-id contourne cette garantie."
|
|
@@ -571,6 +571,24 @@ 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 quand un verbe AGIT dessus (`click`/`tap`/`fill`) : 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 ». Vaut pour les deux facettes à scénario : website et mobile ; sur mobile, `see()` — qui n’agit sur rien — est satisfait par n’importe quel match visible (l’arbre XCUITest duplique légitimement un label entre conteneur et enfant).",
|
|
577
|
+
facet: "shared",
|
|
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
|
+
},
|
|
583
|
+
{
|
|
584
|
+
channel: "runtime",
|
|
585
|
+
convention: "Le vocabulaire d’éléments est UNIQUE entre les facettes website et mobile ; un landmark ARIA (`main()`, `navigation()`…) passé à un verbe mobile est refusé à l’exécution — un écran iOS n’a pas de régions ARIA ; scoper avec `within(testId(…), …)`.",
|
|
586
|
+
facet: "mobile",
|
|
587
|
+
family: "W",
|
|
588
|
+
id: "W4",
|
|
589
|
+
name: "w4-no-landmarks-on-mobile",
|
|
590
|
+
rationale: "Un seul vocabulaire garde l’API mémorisable ; refuser explicitement la partie sans équivalent iOS évite une approximation silencieuse."
|
|
591
|
+
},
|
|
574
592
|
{
|
|
575
593
|
channel: "runtime",
|
|
576
594
|
convention: "`.intercept()` n’existe que sur `api`/`jobs` et lève immédiatement en mode compose (MSW est in-process).",
|
|
@@ -756,18 +774,19 @@ const a10DuplicateBinding = {
|
|
|
756
774
|
};
|
|
757
775
|
//#endregion
|
|
758
776
|
//#region src/lint/rules/a2-known-constructors.ts
|
|
759
|
-
/** The
|
|
777
|
+
/** The five constructors, and only five (A2 — see docs/10-linting.md). */
|
|
760
778
|
const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
|
|
761
779
|
"api",
|
|
762
780
|
"cli",
|
|
763
781
|
"jobs",
|
|
782
|
+
"mobile",
|
|
764
783
|
"website"
|
|
765
784
|
]);
|
|
766
785
|
/**
|
|
767
786
|
* CONVENTIONS A2 — `specification.api()`, `specification.jobs()`,
|
|
768
|
-
* `specification.cli()
|
|
769
|
-
*
|
|
770
|
-
* the member site.
|
|
787
|
+
* `specification.cli()`, `specification.website()` and
|
|
788
|
+
* `specification.mobile()` are the only members. Any other access
|
|
789
|
+
* (`specification.app`, `.http`, `.stack`, …) is flagged at the member site.
|
|
771
790
|
*/
|
|
772
791
|
const a2KnownConstructors = {
|
|
773
792
|
create(context) {
|
|
@@ -784,7 +803,7 @@ const a2KnownConstructors = {
|
|
|
784
803
|
},
|
|
785
804
|
meta: {
|
|
786
805
|
docs: RULE_DOCS["a2-known-constructors"],
|
|
787
|
-
messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs(), specification.cli() and specification.
|
|
806
|
+
messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs(), specification.cli(), specification.website() and specification.mobile() (A2 — see docs/10-linting.md)." },
|
|
788
807
|
type: "problem"
|
|
789
808
|
}
|
|
790
809
|
};
|
|
@@ -2421,6 +2440,7 @@ const f5FixturesOnlyFromTests = {
|
|
|
2421
2440
|
*/
|
|
2422
2441
|
const INTEGRATION_DEPS = {
|
|
2423
2442
|
anthropic: ["@anthropic-ai/sdk"],
|
|
2443
|
+
appium: ["webdriverio"],
|
|
2424
2444
|
compose: ["yaml"],
|
|
2425
2445
|
docker: [],
|
|
2426
2446
|
hono: ["hono", "@hono/node-server"],
|
|
@@ -2498,6 +2518,7 @@ const i1LayerBoundaries = {
|
|
|
2498
2518
|
if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
|
|
2499
2519
|
if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
|
|
2500
2520
|
if (target.startsWith("integrations/playwright/") && inside === "core/specification/website/start-website.ts") return null;
|
|
2521
|
+
if (target.startsWith("integrations/appium/") && inside === "core/specification/mobile/start-mobile.ts") return null;
|
|
2501
2522
|
return "crossLayer";
|
|
2502
2523
|
}
|
|
2503
2524
|
if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
|
|
@@ -2823,6 +2844,82 @@ const TITLED = /* @__PURE__ */ new Set([
|
|
|
2823
2844
|
* `HTTP`, `DI`. Such a word names a symbol verbatim — lowercasing it would
|
|
2824
2845
|
* misspell it — so it is exempt. A prose word (`Rejects`, `Builds`) is not. */
|
|
2825
2846
|
const ALL_CAPS_IDENTIFIER = /^[A-Z][A-Z0-9_]*$/u;
|
|
2847
|
+
/**
|
|
2848
|
+
* CONVENTIONS J5 — the first character of a `test('…')` / `describe('…')` title
|
|
2849
|
+
* literal must be lowercase.
|
|
2850
|
+
*
|
|
2851
|
+
* The test name is the sole description of behaviour (B3): a sentence fragment,
|
|
2852
|
+
* lowercase-led like prose (`'rejects an unknown job'`). Exempt are titles
|
|
2853
|
+
* whose first WORD is an identifier-shaped all-caps/underscored token
|
|
2854
|
+
* (`'VALID_CATEGORIES is rejected'`, `'HTTP is upgraded'`, `'DI wires …'`) —
|
|
2855
|
+
* lowercasing a named symbol would misspell it — and titles opening on a
|
|
2856
|
+
* non-letter (a `$`, a `{`, a `%s` placeholder). Only lowercase-able prose
|
|
2857
|
+
* first words are enforced. Covers the modifier and parametrized forms
|
|
2858
|
+
* (`test.only`, `describe.each(...)(...)`) via the chain root.
|
|
2859
|
+
*/
|
|
2860
|
+
const j5LowercaseTitle = {
|
|
2861
|
+
create(context) {
|
|
2862
|
+
return { CallExpression(node) {
|
|
2863
|
+
const callee = node.callee;
|
|
2864
|
+
const root = chainRootName(callee);
|
|
2865
|
+
if (root === void 0 || !TITLED.has(root)) return;
|
|
2866
|
+
const title = stringValue(node.arguments?.[0]);
|
|
2867
|
+
if (title === void 0) return;
|
|
2868
|
+
const first = [...title][0];
|
|
2869
|
+
if (first === void 0 || !/\p{L}/u.test(first)) return;
|
|
2870
|
+
const firstWord = title.split(/\s+/u)[0];
|
|
2871
|
+
if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
|
|
2872
|
+
if (first !== first.toLocaleLowerCase()) context.report({
|
|
2873
|
+
data: { runner: root },
|
|
2874
|
+
messageId: "uppercase",
|
|
2875
|
+
node
|
|
2876
|
+
});
|
|
2877
|
+
} };
|
|
2878
|
+
},
|
|
2879
|
+
meta: {
|
|
2880
|
+
docs: RULE_DOCS["j5-lowercase-title"],
|
|
2881
|
+
messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
|
|
2882
|
+
type: "problem"
|
|
2883
|
+
}
|
|
2884
|
+
};
|
|
2885
|
+
//#endregion
|
|
2886
|
+
//#region src/lint/rules/w1-scenario-pure.ts
|
|
2887
|
+
/** The scenario-carrying terminal actions: `.visit()` (website) and `.open()` (mobile). */
|
|
2888
|
+
const SCENARIO_ACTIONS$1 = /* @__PURE__ */ new Set(["open", "visit"]);
|
|
2889
|
+
/**
|
|
2890
|
+
* CONVENTIONS W1 — a visit (or mobile open) scenario is the When: the
|
|
2891
|
+
* visitor interacts, the capture reflects the final state, and assertions
|
|
2892
|
+
* live in the Then on the returned result. An `expect()` inside the scenario
|
|
2893
|
+
* callback is flagged.
|
|
2894
|
+
*/
|
|
2895
|
+
const w1ScenarioPure = {
|
|
2896
|
+
create(context) {
|
|
2897
|
+
return { CallExpression(node) {
|
|
2898
|
+
const callee = node.callee;
|
|
2899
|
+
const member = callee ? memberPropertyName(callee) : void 0;
|
|
2900
|
+
if (member === void 0 || !SCENARIO_ACTIONS$1.has(member)) return;
|
|
2901
|
+
const scenario = node.arguments?.[1];
|
|
2902
|
+
if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
|
|
2903
|
+
walk(scenario, (inner) => {
|
|
2904
|
+
if (inner.type !== "CallExpression") return;
|
|
2905
|
+
const innerCallee = inner.callee;
|
|
2906
|
+
if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
|
|
2907
|
+
messageId: "expectInScenario",
|
|
2908
|
+
node: inner
|
|
2909
|
+
});
|
|
2910
|
+
});
|
|
2911
|
+
} };
|
|
2912
|
+
},
|
|
2913
|
+
meta: {
|
|
2914
|
+
docs: RULE_DOCS["w1-scenario-pure"],
|
|
2915
|
+
messages: { expectInScenario: "No expect() inside a scenario — the scenario is the When; assert the final state on the returned result in the Then (W1 — see docs/10-linting.md)." },
|
|
2916
|
+
type: "problem"
|
|
2917
|
+
}
|
|
2918
|
+
};
|
|
2919
|
+
//#endregion
|
|
2920
|
+
//#region src/lint/rules/w2w-user-facing-elements.ts
|
|
2921
|
+
/** The scenario-carrying terminal actions: `.visit()` (website) and `.open()` (mobile). */
|
|
2922
|
+
const SCENARIO_ACTIONS = /* @__PURE__ */ new Set(["open", "visit"]);
|
|
2826
2923
|
//#endregion
|
|
2827
2924
|
//#region src/lint/plugin.ts
|
|
2828
2925
|
/**
|
|
@@ -2882,59 +2979,14 @@ const plugin = {
|
|
|
2882
2979
|
"j2-no-sleep-in-specs": j2NoSleepInSpecs,
|
|
2883
2980
|
"j3-no-expectless-test": j3NoExpectlessTest,
|
|
2884
2981
|
"j4-unique-test-names": j4UniqueTestNames,
|
|
2885
|
-
"j5-lowercase-title":
|
|
2886
|
-
|
|
2887
|
-
return { CallExpression(node) {
|
|
2888
|
-
const callee = node.callee;
|
|
2889
|
-
const root = chainRootName(callee);
|
|
2890
|
-
if (root === void 0 || !TITLED.has(root)) return;
|
|
2891
|
-
const title = stringValue(node.arguments?.[0]);
|
|
2892
|
-
if (title === void 0) return;
|
|
2893
|
-
const first = [...title][0];
|
|
2894
|
-
if (first === void 0 || !/\p{L}/u.test(first)) return;
|
|
2895
|
-
const firstWord = title.split(/\s+/u)[0];
|
|
2896
|
-
if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
|
|
2897
|
-
if (first !== first.toLocaleLowerCase()) context.report({
|
|
2898
|
-
data: { runner: root },
|
|
2899
|
-
messageId: "uppercase",
|
|
2900
|
-
node
|
|
2901
|
-
});
|
|
2902
|
-
} };
|
|
2903
|
-
},
|
|
2904
|
-
meta: {
|
|
2905
|
-
docs: RULE_DOCS["j5-lowercase-title"],
|
|
2906
|
-
messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
|
|
2907
|
-
type: "problem"
|
|
2908
|
-
}
|
|
2909
|
-
},
|
|
2910
|
-
"w1-scenario-pure": {
|
|
2911
|
-
create(context) {
|
|
2912
|
-
return { CallExpression(node) {
|
|
2913
|
-
const callee = node.callee;
|
|
2914
|
-
if (!callee || memberPropertyName(callee) !== "visit") return;
|
|
2915
|
-
const scenario = node.arguments?.[1];
|
|
2916
|
-
if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
|
|
2917
|
-
walk(scenario, (inner) => {
|
|
2918
|
-
if (inner.type !== "CallExpression") return;
|
|
2919
|
-
const innerCallee = inner.callee;
|
|
2920
|
-
if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
|
|
2921
|
-
messageId: "expectInScenario",
|
|
2922
|
-
node: inner
|
|
2923
|
-
});
|
|
2924
|
-
});
|
|
2925
|
-
} };
|
|
2926
|
-
},
|
|
2927
|
-
meta: {
|
|
2928
|
-
docs: RULE_DOCS["w1-scenario-pure"],
|
|
2929
|
-
messages: { expectInScenario: "No expect() inside a visit scenario — the scenario is the When; assert the final state on the returned result in the Then (W1 — see docs/10-linting.md)." },
|
|
2930
|
-
type: "problem"
|
|
2931
|
-
}
|
|
2932
|
-
},
|
|
2982
|
+
"j5-lowercase-title": j5LowercaseTitle,
|
|
2983
|
+
"w1-scenario-pure": w1ScenarioPure,
|
|
2933
2984
|
"w2w-user-facing-elements": {
|
|
2934
2985
|
create(context) {
|
|
2935
2986
|
return { CallExpression(node) {
|
|
2936
2987
|
const callee = node.callee;
|
|
2937
|
-
|
|
2988
|
+
const member = callee ? memberPropertyName(callee) : void 0;
|
|
2989
|
+
if (member === void 0 || !SCENARIO_ACTIONS.has(member)) return;
|
|
2938
2990
|
const scenario = node.arguments?.[1];
|
|
2939
2991
|
if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
|
|
2940
2992
|
walk(scenario, (inner) => {
|
package/dist/oxlint.d.cts
CHANGED
|
@@ -65,7 +65,7 @@ type RuleDoc = {
|
|
|
65
65
|
* The specification facet the rule guards — segments the catalogue like
|
|
66
66
|
* the constructors segment the API. `'shared'` for cross-facet rules.
|
|
67
67
|
*/
|
|
68
|
-
facet?: 'api' | 'cli' | 'jobs' | 'shared' | 'website';
|
|
68
|
+
facet?: 'api' | 'cli' | 'jobs' | 'mobile' | 'shared' | 'website';
|
|
69
69
|
/** Convention family letter, e.g. `'A'`. */
|
|
70
70
|
family: string;
|
|
71
71
|
/** Convention code, e.g. `'A1'`. */
|
package/dist/oxlint.d.ts
CHANGED
|
@@ -65,7 +65,7 @@ type RuleDoc = {
|
|
|
65
65
|
* The specification facet the rule guards — segments the catalogue like
|
|
66
66
|
* the constructors segment the API. `'shared'` for cross-facet rules.
|
|
67
67
|
*/
|
|
68
|
-
facet?: 'api' | 'cli' | 'jobs' | 'shared' | 'website';
|
|
68
|
+
facet?: 'api' | 'cli' | 'jobs' | 'mobile' | 'shared' | 'website';
|
|
69
69
|
/** Convention family letter, e.g. `'A'`. */
|
|
70
70
|
family: string;
|
|
71
71
|
/** Convention code, e.g. `'A1'`. */
|
package/dist/oxlint.js
CHANGED
|
@@ -162,7 +162,7 @@ const RULE_DOCS = {
|
|
|
162
162
|
},
|
|
163
163
|
"a2-known-constructors": {
|
|
164
164
|
channel: "statique",
|
|
165
|
-
convention: "
|
|
165
|
+
convention: "Cinq constructeurs et seulement cinq : `specification.api()`, `specification.jobs()`, `specification.cli(bin)`, `specification.website()`, `specification.mobile()` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
|
|
166
166
|
family: "A",
|
|
167
167
|
id: "A2",
|
|
168
168
|
rationale: "Une surface fermée empêche l’invention de constructeurs parallèles et garde l’API mémorisable."
|
|
@@ -435,16 +435,16 @@ const RULE_DOCS = {
|
|
|
435
435
|
},
|
|
436
436
|
"w1-scenario-pure": {
|
|
437
437
|
channel: "statique",
|
|
438
|
-
convention: "Un scénario
|
|
439
|
-
facet: "
|
|
438
|
+
convention: "Un scénario (`.visit()` website, `.open()` mobile) est le When : le visiteur agit, la capture reflète l’état final ; aucun `expect()` dans le callback — les assertions vivent dans le Then, sur le résultat retourné.",
|
|
439
|
+
facet: "shared",
|
|
440
440
|
family: "W",
|
|
441
441
|
id: "W1",
|
|
442
442
|
rationale: "Séparer l’interaction de l’assertion garde la grammaire setup → action → résultat intacte et les scénarios rejouables."
|
|
443
443
|
},
|
|
444
444
|
"w2w-user-facing-elements": {
|
|
445
445
|
channel: "statique",
|
|
446
|
-
convention: "Les éléments d’un scénario sont user-facing (`button`, `link`, `field`, `heading`, `content`) ; `testId()` est l’unique échappatoire et déclenche un avertissement.",
|
|
447
|
-
facet: "
|
|
446
|
+
convention: "Les éléments d’un scénario sont user-facing (`button`, `link`, `field`, `heading`, `content` ; côté mobile `button`, `field`, `content`) ; `testId()` est l’unique échappatoire et déclenche un avertissement.",
|
|
447
|
+
facet: "shared",
|
|
448
448
|
family: "W",
|
|
449
449
|
id: "W2",
|
|
450
450
|
rationale: "Tester ce que l’utilisateur voit (rôles, labels) rend les specs robustes aux refontes DOM ; un test-id contourne cette garantie."
|
|
@@ -567,6 +567,24 @@ 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 quand un verbe AGIT dessus (`click`/`tap`/`fill`) : 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 ». Vaut pour les deux facettes à scénario : website et mobile ; sur mobile, `see()` — qui n’agit sur rien — est satisfait par n’importe quel match visible (l’arbre XCUITest duplique légitimement un label entre conteneur et enfant).",
|
|
573
|
+
facet: "shared",
|
|
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
|
+
},
|
|
579
|
+
{
|
|
580
|
+
channel: "runtime",
|
|
581
|
+
convention: "Le vocabulaire d’éléments est UNIQUE entre les facettes website et mobile ; un landmark ARIA (`main()`, `navigation()`…) passé à un verbe mobile est refusé à l’exécution — un écran iOS n’a pas de régions ARIA ; scoper avec `within(testId(…), …)`.",
|
|
582
|
+
facet: "mobile",
|
|
583
|
+
family: "W",
|
|
584
|
+
id: "W4",
|
|
585
|
+
name: "w4-no-landmarks-on-mobile",
|
|
586
|
+
rationale: "Un seul vocabulaire garde l’API mémorisable ; refuser explicitement la partie sans équivalent iOS évite une approximation silencieuse."
|
|
587
|
+
},
|
|
570
588
|
{
|
|
571
589
|
channel: "runtime",
|
|
572
590
|
convention: "`.intercept()` n’existe que sur `api`/`jobs` et lève immédiatement en mode compose (MSW est in-process).",
|
|
@@ -752,18 +770,19 @@ const a10DuplicateBinding = {
|
|
|
752
770
|
};
|
|
753
771
|
//#endregion
|
|
754
772
|
//#region src/lint/rules/a2-known-constructors.ts
|
|
755
|
-
/** The
|
|
773
|
+
/** The five constructors, and only five (A2 — see docs/10-linting.md). */
|
|
756
774
|
const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
|
|
757
775
|
"api",
|
|
758
776
|
"cli",
|
|
759
777
|
"jobs",
|
|
778
|
+
"mobile",
|
|
760
779
|
"website"
|
|
761
780
|
]);
|
|
762
781
|
/**
|
|
763
782
|
* CONVENTIONS A2 — `specification.api()`, `specification.jobs()`,
|
|
764
|
-
* `specification.cli()
|
|
765
|
-
*
|
|
766
|
-
* the member site.
|
|
783
|
+
* `specification.cli()`, `specification.website()` and
|
|
784
|
+
* `specification.mobile()` are the only members. Any other access
|
|
785
|
+
* (`specification.app`, `.http`, `.stack`, …) is flagged at the member site.
|
|
767
786
|
*/
|
|
768
787
|
const a2KnownConstructors = {
|
|
769
788
|
create(context) {
|
|
@@ -780,7 +799,7 @@ const a2KnownConstructors = {
|
|
|
780
799
|
},
|
|
781
800
|
meta: {
|
|
782
801
|
docs: RULE_DOCS["a2-known-constructors"],
|
|
783
|
-
messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs(), specification.cli() and specification.
|
|
802
|
+
messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs(), specification.cli(), specification.website() and specification.mobile() (A2 — see docs/10-linting.md)." },
|
|
784
803
|
type: "problem"
|
|
785
804
|
}
|
|
786
805
|
};
|
|
@@ -2417,6 +2436,7 @@ const f5FixturesOnlyFromTests = {
|
|
|
2417
2436
|
*/
|
|
2418
2437
|
const INTEGRATION_DEPS = {
|
|
2419
2438
|
anthropic: ["@anthropic-ai/sdk"],
|
|
2439
|
+
appium: ["webdriverio"],
|
|
2420
2440
|
compose: ["yaml"],
|
|
2421
2441
|
docker: [],
|
|
2422
2442
|
hono: ["hono", "@hono/node-server"],
|
|
@@ -2494,6 +2514,7 @@ const i1LayerBoundaries = {
|
|
|
2494
2514
|
if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
|
|
2495
2515
|
if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
|
|
2496
2516
|
if (target.startsWith("integrations/playwright/") && inside === "core/specification/website/start-website.ts") return null;
|
|
2517
|
+
if (target.startsWith("integrations/appium/") && inside === "core/specification/mobile/start-mobile.ts") return null;
|
|
2497
2518
|
return "crossLayer";
|
|
2498
2519
|
}
|
|
2499
2520
|
if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
|
|
@@ -2819,6 +2840,82 @@ const TITLED = /* @__PURE__ */ new Set([
|
|
|
2819
2840
|
* `HTTP`, `DI`. Such a word names a symbol verbatim — lowercasing it would
|
|
2820
2841
|
* misspell it — so it is exempt. A prose word (`Rejects`, `Builds`) is not. */
|
|
2821
2842
|
const ALL_CAPS_IDENTIFIER = /^[A-Z][A-Z0-9_]*$/u;
|
|
2843
|
+
/**
|
|
2844
|
+
* CONVENTIONS J5 — the first character of a `test('…')` / `describe('…')` title
|
|
2845
|
+
* literal must be lowercase.
|
|
2846
|
+
*
|
|
2847
|
+
* The test name is the sole description of behaviour (B3): a sentence fragment,
|
|
2848
|
+
* lowercase-led like prose (`'rejects an unknown job'`). Exempt are titles
|
|
2849
|
+
* whose first WORD is an identifier-shaped all-caps/underscored token
|
|
2850
|
+
* (`'VALID_CATEGORIES is rejected'`, `'HTTP is upgraded'`, `'DI wires …'`) —
|
|
2851
|
+
* lowercasing a named symbol would misspell it — and titles opening on a
|
|
2852
|
+
* non-letter (a `$`, a `{`, a `%s` placeholder). Only lowercase-able prose
|
|
2853
|
+
* first words are enforced. Covers the modifier and parametrized forms
|
|
2854
|
+
* (`test.only`, `describe.each(...)(...)`) via the chain root.
|
|
2855
|
+
*/
|
|
2856
|
+
const j5LowercaseTitle = {
|
|
2857
|
+
create(context) {
|
|
2858
|
+
return { CallExpression(node) {
|
|
2859
|
+
const callee = node.callee;
|
|
2860
|
+
const root = chainRootName(callee);
|
|
2861
|
+
if (root === void 0 || !TITLED.has(root)) return;
|
|
2862
|
+
const title = stringValue(node.arguments?.[0]);
|
|
2863
|
+
if (title === void 0) return;
|
|
2864
|
+
const first = [...title][0];
|
|
2865
|
+
if (first === void 0 || !/\p{L}/u.test(first)) return;
|
|
2866
|
+
const firstWord = title.split(/\s+/u)[0];
|
|
2867
|
+
if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
|
|
2868
|
+
if (first !== first.toLocaleLowerCase()) context.report({
|
|
2869
|
+
data: { runner: root },
|
|
2870
|
+
messageId: "uppercase",
|
|
2871
|
+
node
|
|
2872
|
+
});
|
|
2873
|
+
} };
|
|
2874
|
+
},
|
|
2875
|
+
meta: {
|
|
2876
|
+
docs: RULE_DOCS["j5-lowercase-title"],
|
|
2877
|
+
messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
|
|
2878
|
+
type: "problem"
|
|
2879
|
+
}
|
|
2880
|
+
};
|
|
2881
|
+
//#endregion
|
|
2882
|
+
//#region src/lint/rules/w1-scenario-pure.ts
|
|
2883
|
+
/** The scenario-carrying terminal actions: `.visit()` (website) and `.open()` (mobile). */
|
|
2884
|
+
const SCENARIO_ACTIONS$1 = /* @__PURE__ */ new Set(["open", "visit"]);
|
|
2885
|
+
/**
|
|
2886
|
+
* CONVENTIONS W1 — a visit (or mobile open) scenario is the When: the
|
|
2887
|
+
* visitor interacts, the capture reflects the final state, and assertions
|
|
2888
|
+
* live in the Then on the returned result. An `expect()` inside the scenario
|
|
2889
|
+
* callback is flagged.
|
|
2890
|
+
*/
|
|
2891
|
+
const w1ScenarioPure = {
|
|
2892
|
+
create(context) {
|
|
2893
|
+
return { CallExpression(node) {
|
|
2894
|
+
const callee = node.callee;
|
|
2895
|
+
const member = callee ? memberPropertyName(callee) : void 0;
|
|
2896
|
+
if (member === void 0 || !SCENARIO_ACTIONS$1.has(member)) return;
|
|
2897
|
+
const scenario = node.arguments?.[1];
|
|
2898
|
+
if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
|
|
2899
|
+
walk(scenario, (inner) => {
|
|
2900
|
+
if (inner.type !== "CallExpression") return;
|
|
2901
|
+
const innerCallee = inner.callee;
|
|
2902
|
+
if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
|
|
2903
|
+
messageId: "expectInScenario",
|
|
2904
|
+
node: inner
|
|
2905
|
+
});
|
|
2906
|
+
});
|
|
2907
|
+
} };
|
|
2908
|
+
},
|
|
2909
|
+
meta: {
|
|
2910
|
+
docs: RULE_DOCS["w1-scenario-pure"],
|
|
2911
|
+
messages: { expectInScenario: "No expect() inside a scenario — the scenario is the When; assert the final state on the returned result in the Then (W1 — see docs/10-linting.md)." },
|
|
2912
|
+
type: "problem"
|
|
2913
|
+
}
|
|
2914
|
+
};
|
|
2915
|
+
//#endregion
|
|
2916
|
+
//#region src/lint/rules/w2w-user-facing-elements.ts
|
|
2917
|
+
/** The scenario-carrying terminal actions: `.visit()` (website) and `.open()` (mobile). */
|
|
2918
|
+
const SCENARIO_ACTIONS = /* @__PURE__ */ new Set(["open", "visit"]);
|
|
2822
2919
|
//#endregion
|
|
2823
2920
|
//#region src/lint/plugin.ts
|
|
2824
2921
|
/**
|
|
@@ -2878,59 +2975,14 @@ const plugin = {
|
|
|
2878
2975
|
"j2-no-sleep-in-specs": j2NoSleepInSpecs,
|
|
2879
2976
|
"j3-no-expectless-test": j3NoExpectlessTest,
|
|
2880
2977
|
"j4-unique-test-names": j4UniqueTestNames,
|
|
2881
|
-
"j5-lowercase-title":
|
|
2882
|
-
|
|
2883
|
-
return { CallExpression(node) {
|
|
2884
|
-
const callee = node.callee;
|
|
2885
|
-
const root = chainRootName(callee);
|
|
2886
|
-
if (root === void 0 || !TITLED.has(root)) return;
|
|
2887
|
-
const title = stringValue(node.arguments?.[0]);
|
|
2888
|
-
if (title === void 0) return;
|
|
2889
|
-
const first = [...title][0];
|
|
2890
|
-
if (first === void 0 || !/\p{L}/u.test(first)) return;
|
|
2891
|
-
const firstWord = title.split(/\s+/u)[0];
|
|
2892
|
-
if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
|
|
2893
|
-
if (first !== first.toLocaleLowerCase()) context.report({
|
|
2894
|
-
data: { runner: root },
|
|
2895
|
-
messageId: "uppercase",
|
|
2896
|
-
node
|
|
2897
|
-
});
|
|
2898
|
-
} };
|
|
2899
|
-
},
|
|
2900
|
-
meta: {
|
|
2901
|
-
docs: RULE_DOCS["j5-lowercase-title"],
|
|
2902
|
-
messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
|
|
2903
|
-
type: "problem"
|
|
2904
|
-
}
|
|
2905
|
-
},
|
|
2906
|
-
"w1-scenario-pure": {
|
|
2907
|
-
create(context) {
|
|
2908
|
-
return { CallExpression(node) {
|
|
2909
|
-
const callee = node.callee;
|
|
2910
|
-
if (!callee || memberPropertyName(callee) !== "visit") return;
|
|
2911
|
-
const scenario = node.arguments?.[1];
|
|
2912
|
-
if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
|
|
2913
|
-
walk(scenario, (inner) => {
|
|
2914
|
-
if (inner.type !== "CallExpression") return;
|
|
2915
|
-
const innerCallee = inner.callee;
|
|
2916
|
-
if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
|
|
2917
|
-
messageId: "expectInScenario",
|
|
2918
|
-
node: inner
|
|
2919
|
-
});
|
|
2920
|
-
});
|
|
2921
|
-
} };
|
|
2922
|
-
},
|
|
2923
|
-
meta: {
|
|
2924
|
-
docs: RULE_DOCS["w1-scenario-pure"],
|
|
2925
|
-
messages: { expectInScenario: "No expect() inside a visit scenario — the scenario is the When; assert the final state on the returned result in the Then (W1 — see docs/10-linting.md)." },
|
|
2926
|
-
type: "problem"
|
|
2927
|
-
}
|
|
2928
|
-
},
|
|
2978
|
+
"j5-lowercase-title": j5LowercaseTitle,
|
|
2979
|
+
"w1-scenario-pure": w1ScenarioPure,
|
|
2929
2980
|
"w2w-user-facing-elements": {
|
|
2930
2981
|
create(context) {
|
|
2931
2982
|
return { CallExpression(node) {
|
|
2932
2983
|
const callee = node.callee;
|
|
2933
|
-
|
|
2984
|
+
const member = callee ? memberPropertyName(callee) : void 0;
|
|
2985
|
+
if (member === void 0 || !SCENARIO_ACTIONS.has(member)) return;
|
|
2934
2986
|
const scenario = node.arguments?.[1];
|
|
2935
2987
|
if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
|
|
2936
2988
|
walk(scenario, (inner) => {
|
|
@@ -1,32 +1,102 @@
|
|
|
1
|
+
import { n as describeAmbiguity, t as AmbiguousElementError } from "./ambiguity.js";
|
|
1
2
|
import { mkdtempSync } from "node:fs";
|
|
2
3
|
import { resolve } from "node:path";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
//#region src/integrations/playwright/playwright.adapter.ts
|
|
5
|
-
/**
|
|
6
|
-
|
|
6
|
+
/** How many candidates the ambiguity error enumerates before truncating. */
|
|
7
|
+
const MAX_REPORTED_MATCHES = 10;
|
|
8
|
+
/**
|
|
9
|
+
* Translate a user-facing descriptor into a playwright locator, resolving
|
|
10
|
+
* `scope` outside-in so `within(navigation(), link('X'))` searches the nav
|
|
11
|
+
* subtree. No `.first()` anywhere: playwright's strict mode is the mechanism
|
|
12
|
+
* behind CONVENTIONS W3, and swallowing it would reintroduce the silent
|
|
13
|
+
* wrong-element bug the rule exists to prevent.
|
|
14
|
+
*/
|
|
15
|
+
function locate(root, element) {
|
|
16
|
+
const scope = element.scope ? locate(root, element.scope) : root;
|
|
17
|
+
const options = {
|
|
18
|
+
exact: element.exact,
|
|
19
|
+
name: element.name
|
|
20
|
+
};
|
|
7
21
|
switch (element.kind) {
|
|
8
|
-
case "
|
|
9
|
-
case "
|
|
10
|
-
case "
|
|
11
|
-
case "
|
|
12
|
-
case "
|
|
13
|
-
case "
|
|
22
|
+
case "banner":
|
|
23
|
+
case "complementary":
|
|
24
|
+
case "contentinfo":
|
|
25
|
+
case "form":
|
|
26
|
+
case "main":
|
|
27
|
+
case "navigation":
|
|
28
|
+
case "region":
|
|
29
|
+
case "search": return scope.getByRole(element.kind, options);
|
|
30
|
+
case "button":
|
|
31
|
+
case "heading":
|
|
32
|
+
case "link": return scope.getByRole(element.kind, options);
|
|
33
|
+
case "field": return scope.getByLabel(element.name ?? "", { exact: element.exact });
|
|
34
|
+
case "testId": return scope.getByTestId(element.name ?? "");
|
|
35
|
+
case "text": return scope.getByText(element.name ?? "", { exact: element.exact });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Playwright signals "more than one match" through this error text. */
|
|
39
|
+
function isStrictViolation(error) {
|
|
40
|
+
return error instanceof Error && error.message.includes("strict mode violation");
|
|
41
|
+
}
|
|
42
|
+
/** Capture the candidates in-page — the evidence the refusal enumerates. */
|
|
43
|
+
async function captureMatches(locator) {
|
|
44
|
+
return locator.evaluateAll((nodes, limit) => {
|
|
45
|
+
const LANDMARKS = "nav, header, footer, main, aside, section, form, [role]";
|
|
46
|
+
return nodes.slice(0, limit).map((node) => {
|
|
47
|
+
const element = node;
|
|
48
|
+
const landmark = element.parentElement?.closest(LANDMARKS);
|
|
49
|
+
const context = landmark ? landmark.getAttribute("role") ?? landmark.tagName.toLowerCase() : null;
|
|
50
|
+
const detail = element.getAttribute("href") ?? element.getAttribute("name");
|
|
51
|
+
return {
|
|
52
|
+
context: context ?? void 0,
|
|
53
|
+
detail: detail ?? void 0,
|
|
54
|
+
tag: element.tagName.toLowerCase(),
|
|
55
|
+
text: (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 80)
|
|
56
|
+
};
|
|
57
|
+
});
|
|
58
|
+
}, MAX_REPORTED_MATCHES);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Run one visitor action, converting a strict-mode violation into the W3
|
|
62
|
+
* refusal. The ambiguous level is identified before reporting: a scope that
|
|
63
|
+
* matches several landmarks is the real fault, and naming the target instead
|
|
64
|
+
* would send the author to fix the wrong descriptor.
|
|
65
|
+
*/
|
|
66
|
+
async function act(page, element, action) {
|
|
67
|
+
try {
|
|
68
|
+
return await action(locate(page, element));
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (!isStrictViolation(error)) throw error;
|
|
71
|
+
const culprit = await findAmbiguousLevel(page, element);
|
|
72
|
+
throw new AmbiguousElementError(describeAmbiguity({
|
|
73
|
+
element: culprit,
|
|
74
|
+
matches: await captureMatches(locate(page, culprit)),
|
|
75
|
+
url: page.url()
|
|
76
|
+
}));
|
|
14
77
|
}
|
|
15
78
|
}
|
|
79
|
+
/** Walk the scope chain outside-in; the outermost ambiguous level is the fault. */
|
|
80
|
+
async function findAmbiguousLevel(page, element) {
|
|
81
|
+
const chain = [];
|
|
82
|
+
for (let current = element; current; current = current.scope) chain.unshift(current);
|
|
83
|
+
for (const level of chain.slice(0, -1)) if (await locate(page, level).count() > 1) return level;
|
|
84
|
+
return element;
|
|
85
|
+
}
|
|
16
86
|
/** The visitor implementation — every action auto-waits via playwright actionability. */
|
|
17
87
|
function createVisitor(page, baseUrl) {
|
|
18
88
|
return {
|
|
19
|
-
check: (element) =>
|
|
20
|
-
click: (element) =>
|
|
21
|
-
fill: (element, value) =>
|
|
89
|
+
check: (element) => act(page, element, (locator) => locator.check()),
|
|
90
|
+
click: (element) => act(page, element, (locator) => locator.click()),
|
|
91
|
+
fill: (element, value) => act(page, element, (locator) => locator.fill(value)),
|
|
22
92
|
goto: async (path) => {
|
|
23
93
|
await page.goto(`${baseUrl}${path}`, { waitUntil: "load" });
|
|
24
94
|
},
|
|
25
|
-
hover: (element) =>
|
|
95
|
+
hover: (element) => act(page, element, (locator) => locator.hover()),
|
|
26
96
|
press: (key) => page.keyboard.press(key),
|
|
27
|
-
see: (element) =>
|
|
97
|
+
see: (element) => act(page, element, (locator) => locator.waitFor({ state: "visible" })),
|
|
28
98
|
select: async (element, option) => {
|
|
29
|
-
await
|
|
99
|
+
await act(page, element, (locator) => locator.selectOption(option));
|
|
30
100
|
}
|
|
31
101
|
};
|
|
32
102
|
}
|
|
@@ -51,9 +121,9 @@ var PlaywrightAdapter = class {
|
|
|
51
121
|
async open(url, options) {
|
|
52
122
|
const context = await (await this.launch()).newContext({ extraHTTPHeaders: options.headers });
|
|
53
123
|
if (options.external === "block") {
|
|
54
|
-
const
|
|
124
|
+
const allowed = /* @__PURE__ */ new Set([new URL(options.baseUrl).origin, ...options.allowedOrigins ?? []]);
|
|
55
125
|
await context.route("**/*", (route) => {
|
|
56
|
-
if (new URL(route.request().url()).origin
|
|
126
|
+
if (allowed.has(new URL(route.request().url()).origin)) route.continue();
|
|
57
127
|
else route.abort();
|
|
58
128
|
});
|
|
59
129
|
}
|
|
@@ -71,7 +141,12 @@ var PlaywrightAdapter = class {
|
|
|
71
141
|
await options.scenario(createVisitor(page, options.baseUrl));
|
|
72
142
|
} catch (error) {
|
|
73
143
|
const evidence = await this.captureEvidence(page);
|
|
74
|
-
|
|
144
|
+
const suffix = evidence ? `\nEvidence: ${evidence}` : "";
|
|
145
|
+
if (error instanceof AmbiguousElementError) {
|
|
146
|
+
error.message += suffix;
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
throw new Error(`visit scenario failed: ${error instanceof Error ? error.message : String(error)}${suffix}`, { cause: error });
|
|
75
150
|
}
|
|
76
151
|
const extraction = await page.evaluate(() => {
|
|
77
152
|
const links = [...document.querySelectorAll("link")].map((link) => ({
|