@jterrazz/test 10.0.0 → 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 +345 -7
- package/dist/index.js +602 -20
- package/dist/oxlint.cjs +104 -61
- package/dist/oxlint.d.cts +1 -1
- package/dist/oxlint.d.ts +1 -1
- package/dist/oxlint.js +104 -61
- package/dist/playwright.adapter.js +3 -127
- package/package.json +13 -4
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."
|
|
@@ -573,13 +573,22 @@ const RUNTIME_RULES = [
|
|
|
573
573
|
},
|
|
574
574
|
{
|
|
575
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: "
|
|
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
578
|
family: "W",
|
|
579
579
|
id: "W3",
|
|
580
580
|
name: "w3-unambiguous-element",
|
|
581
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
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
|
+
},
|
|
583
592
|
{
|
|
584
593
|
channel: "runtime",
|
|
585
594
|
convention: "`.intercept()` n’existe que sur `api`/`jobs` et lève immédiatement en mode compose (MSW est in-process).",
|
|
@@ -765,18 +774,19 @@ const a10DuplicateBinding = {
|
|
|
765
774
|
};
|
|
766
775
|
//#endregion
|
|
767
776
|
//#region src/lint/rules/a2-known-constructors.ts
|
|
768
|
-
/** The
|
|
777
|
+
/** The five constructors, and only five (A2 — see docs/10-linting.md). */
|
|
769
778
|
const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
|
|
770
779
|
"api",
|
|
771
780
|
"cli",
|
|
772
781
|
"jobs",
|
|
782
|
+
"mobile",
|
|
773
783
|
"website"
|
|
774
784
|
]);
|
|
775
785
|
/**
|
|
776
786
|
* CONVENTIONS A2 — `specification.api()`, `specification.jobs()`,
|
|
777
|
-
* `specification.cli()
|
|
778
|
-
*
|
|
779
|
-
* 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.
|
|
780
790
|
*/
|
|
781
791
|
const a2KnownConstructors = {
|
|
782
792
|
create(context) {
|
|
@@ -793,7 +803,7 @@ const a2KnownConstructors = {
|
|
|
793
803
|
},
|
|
794
804
|
meta: {
|
|
795
805
|
docs: RULE_DOCS["a2-known-constructors"],
|
|
796
|
-
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)." },
|
|
797
807
|
type: "problem"
|
|
798
808
|
}
|
|
799
809
|
};
|
|
@@ -2430,6 +2440,7 @@ const f5FixturesOnlyFromTests = {
|
|
|
2430
2440
|
*/
|
|
2431
2441
|
const INTEGRATION_DEPS = {
|
|
2432
2442
|
anthropic: ["@anthropic-ai/sdk"],
|
|
2443
|
+
appium: ["webdriverio"],
|
|
2433
2444
|
compose: ["yaml"],
|
|
2434
2445
|
docker: [],
|
|
2435
2446
|
hono: ["hono", "@hono/node-server"],
|
|
@@ -2507,6 +2518,7 @@ const i1LayerBoundaries = {
|
|
|
2507
2518
|
if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
|
|
2508
2519
|
if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
|
|
2509
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;
|
|
2510
2522
|
return "crossLayer";
|
|
2511
2523
|
}
|
|
2512
2524
|
if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
|
|
@@ -2832,6 +2844,82 @@ const TITLED = /* @__PURE__ */ new Set([
|
|
|
2832
2844
|
* `HTTP`, `DI`. Such a word names a symbol verbatim — lowercasing it would
|
|
2833
2845
|
* misspell it — so it is exempt. A prose word (`Rejects`, `Builds`) is not. */
|
|
2834
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"]);
|
|
2835
2923
|
//#endregion
|
|
2836
2924
|
//#region src/lint/plugin.ts
|
|
2837
2925
|
/**
|
|
@@ -2891,59 +2979,14 @@ const plugin = {
|
|
|
2891
2979
|
"j2-no-sleep-in-specs": j2NoSleepInSpecs,
|
|
2892
2980
|
"j3-no-expectless-test": j3NoExpectlessTest,
|
|
2893
2981
|
"j4-unique-test-names": j4UniqueTestNames,
|
|
2894
|
-
"j5-lowercase-title":
|
|
2895
|
-
|
|
2896
|
-
return { CallExpression(node) {
|
|
2897
|
-
const callee = node.callee;
|
|
2898
|
-
const root = chainRootName(callee);
|
|
2899
|
-
if (root === void 0 || !TITLED.has(root)) return;
|
|
2900
|
-
const title = stringValue(node.arguments?.[0]);
|
|
2901
|
-
if (title === void 0) return;
|
|
2902
|
-
const first = [...title][0];
|
|
2903
|
-
if (first === void 0 || !/\p{L}/u.test(first)) return;
|
|
2904
|
-
const firstWord = title.split(/\s+/u)[0];
|
|
2905
|
-
if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
|
|
2906
|
-
if (first !== first.toLocaleLowerCase()) context.report({
|
|
2907
|
-
data: { runner: root },
|
|
2908
|
-
messageId: "uppercase",
|
|
2909
|
-
node
|
|
2910
|
-
});
|
|
2911
|
-
} };
|
|
2912
|
-
},
|
|
2913
|
-
meta: {
|
|
2914
|
-
docs: RULE_DOCS["j5-lowercase-title"],
|
|
2915
|
-
messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
|
|
2916
|
-
type: "problem"
|
|
2917
|
-
}
|
|
2918
|
-
},
|
|
2919
|
-
"w1-scenario-pure": {
|
|
2920
|
-
create(context) {
|
|
2921
|
-
return { CallExpression(node) {
|
|
2922
|
-
const callee = node.callee;
|
|
2923
|
-
if (!callee || memberPropertyName(callee) !== "visit") return;
|
|
2924
|
-
const scenario = node.arguments?.[1];
|
|
2925
|
-
if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
|
|
2926
|
-
walk(scenario, (inner) => {
|
|
2927
|
-
if (inner.type !== "CallExpression") return;
|
|
2928
|
-
const innerCallee = inner.callee;
|
|
2929
|
-
if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
|
|
2930
|
-
messageId: "expectInScenario",
|
|
2931
|
-
node: inner
|
|
2932
|
-
});
|
|
2933
|
-
});
|
|
2934
|
-
} };
|
|
2935
|
-
},
|
|
2936
|
-
meta: {
|
|
2937
|
-
docs: RULE_DOCS["w1-scenario-pure"],
|
|
2938
|
-
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)." },
|
|
2939
|
-
type: "problem"
|
|
2940
|
-
}
|
|
2941
|
-
},
|
|
2982
|
+
"j5-lowercase-title": j5LowercaseTitle,
|
|
2983
|
+
"w1-scenario-pure": w1ScenarioPure,
|
|
2942
2984
|
"w2w-user-facing-elements": {
|
|
2943
2985
|
create(context) {
|
|
2944
2986
|
return { CallExpression(node) {
|
|
2945
2987
|
const callee = node.callee;
|
|
2946
|
-
|
|
2988
|
+
const member = callee ? memberPropertyName(callee) : void 0;
|
|
2989
|
+
if (member === void 0 || !SCENARIO_ACTIONS.has(member)) return;
|
|
2947
2990
|
const scenario = node.arguments?.[1];
|
|
2948
2991
|
if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
|
|
2949
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."
|
|
@@ -569,13 +569,22 @@ const RUNTIME_RULES = [
|
|
|
569
569
|
},
|
|
570
570
|
{
|
|
571
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: "
|
|
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
574
|
family: "W",
|
|
575
575
|
id: "W3",
|
|
576
576
|
name: "w3-unambiguous-element",
|
|
577
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
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
|
+
},
|
|
579
588
|
{
|
|
580
589
|
channel: "runtime",
|
|
581
590
|
convention: "`.intercept()` n’existe que sur `api`/`jobs` et lève immédiatement en mode compose (MSW est in-process).",
|
|
@@ -761,18 +770,19 @@ const a10DuplicateBinding = {
|
|
|
761
770
|
};
|
|
762
771
|
//#endregion
|
|
763
772
|
//#region src/lint/rules/a2-known-constructors.ts
|
|
764
|
-
/** The
|
|
773
|
+
/** The five constructors, and only five (A2 — see docs/10-linting.md). */
|
|
765
774
|
const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
|
|
766
775
|
"api",
|
|
767
776
|
"cli",
|
|
768
777
|
"jobs",
|
|
778
|
+
"mobile",
|
|
769
779
|
"website"
|
|
770
780
|
]);
|
|
771
781
|
/**
|
|
772
782
|
* CONVENTIONS A2 — `specification.api()`, `specification.jobs()`,
|
|
773
|
-
* `specification.cli()
|
|
774
|
-
*
|
|
775
|
-
* 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.
|
|
776
786
|
*/
|
|
777
787
|
const a2KnownConstructors = {
|
|
778
788
|
create(context) {
|
|
@@ -789,7 +799,7 @@ const a2KnownConstructors = {
|
|
|
789
799
|
},
|
|
790
800
|
meta: {
|
|
791
801
|
docs: RULE_DOCS["a2-known-constructors"],
|
|
792
|
-
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)." },
|
|
793
803
|
type: "problem"
|
|
794
804
|
}
|
|
795
805
|
};
|
|
@@ -2426,6 +2436,7 @@ const f5FixturesOnlyFromTests = {
|
|
|
2426
2436
|
*/
|
|
2427
2437
|
const INTEGRATION_DEPS = {
|
|
2428
2438
|
anthropic: ["@anthropic-ai/sdk"],
|
|
2439
|
+
appium: ["webdriverio"],
|
|
2429
2440
|
compose: ["yaml"],
|
|
2430
2441
|
docker: [],
|
|
2431
2442
|
hono: ["hono", "@hono/node-server"],
|
|
@@ -2503,6 +2514,7 @@ const i1LayerBoundaries = {
|
|
|
2503
2514
|
if (target.startsWith("core/") || target.startsWith("integrations/docker/") || target.startsWith("integrations/hono/") || withoutExtension(target) === "vitest/matchers") return null;
|
|
2504
2515
|
if (target.startsWith("integrations/msw/") && inside === "core/specification/shared/builder.ts") return null;
|
|
2505
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;
|
|
2506
2518
|
return "crossLayer";
|
|
2507
2519
|
}
|
|
2508
2520
|
if (layer === "integrations") return target.startsWith("core/") || target.startsWith(`integrations/${integrationFolder}/`) ? null : "crossLayer";
|
|
@@ -2828,6 +2840,82 @@ const TITLED = /* @__PURE__ */ new Set([
|
|
|
2828
2840
|
* `HTTP`, `DI`. Such a word names a symbol verbatim — lowercasing it would
|
|
2829
2841
|
* misspell it — so it is exempt. A prose word (`Rejects`, `Builds`) is not. */
|
|
2830
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"]);
|
|
2831
2919
|
//#endregion
|
|
2832
2920
|
//#region src/lint/plugin.ts
|
|
2833
2921
|
/**
|
|
@@ -2887,59 +2975,14 @@ const plugin = {
|
|
|
2887
2975
|
"j2-no-sleep-in-specs": j2NoSleepInSpecs,
|
|
2888
2976
|
"j3-no-expectless-test": j3NoExpectlessTest,
|
|
2889
2977
|
"j4-unique-test-names": j4UniqueTestNames,
|
|
2890
|
-
"j5-lowercase-title":
|
|
2891
|
-
|
|
2892
|
-
return { CallExpression(node) {
|
|
2893
|
-
const callee = node.callee;
|
|
2894
|
-
const root = chainRootName(callee);
|
|
2895
|
-
if (root === void 0 || !TITLED.has(root)) return;
|
|
2896
|
-
const title = stringValue(node.arguments?.[0]);
|
|
2897
|
-
if (title === void 0) return;
|
|
2898
|
-
const first = [...title][0];
|
|
2899
|
-
if (first === void 0 || !/\p{L}/u.test(first)) return;
|
|
2900
|
-
const firstWord = title.split(/\s+/u)[0];
|
|
2901
|
-
if (ALL_CAPS_IDENTIFIER.test(firstWord)) return;
|
|
2902
|
-
if (first !== first.toLocaleLowerCase()) context.report({
|
|
2903
|
-
data: { runner: root },
|
|
2904
|
-
messageId: "uppercase",
|
|
2905
|
-
node
|
|
2906
|
-
});
|
|
2907
|
-
} };
|
|
2908
|
-
},
|
|
2909
|
-
meta: {
|
|
2910
|
-
docs: RULE_DOCS["j5-lowercase-title"],
|
|
2911
|
-
messages: { uppercase: "A {{runner}}() title must start lowercase — the test name is a prose fragment, not a sentence (J5 — see docs/10-linting.md)." },
|
|
2912
|
-
type: "problem"
|
|
2913
|
-
}
|
|
2914
|
-
},
|
|
2915
|
-
"w1-scenario-pure": {
|
|
2916
|
-
create(context) {
|
|
2917
|
-
return { CallExpression(node) {
|
|
2918
|
-
const callee = node.callee;
|
|
2919
|
-
if (!callee || memberPropertyName(callee) !== "visit") return;
|
|
2920
|
-
const scenario = node.arguments?.[1];
|
|
2921
|
-
if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
|
|
2922
|
-
walk(scenario, (inner) => {
|
|
2923
|
-
if (inner.type !== "CallExpression") return;
|
|
2924
|
-
const innerCallee = inner.callee;
|
|
2925
|
-
if (innerCallee?.type === "Identifier" && innerCallee.name === "expect") context.report({
|
|
2926
|
-
messageId: "expectInScenario",
|
|
2927
|
-
node: inner
|
|
2928
|
-
});
|
|
2929
|
-
});
|
|
2930
|
-
} };
|
|
2931
|
-
},
|
|
2932
|
-
meta: {
|
|
2933
|
-
docs: RULE_DOCS["w1-scenario-pure"],
|
|
2934
|
-
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)." },
|
|
2935
|
-
type: "problem"
|
|
2936
|
-
}
|
|
2937
|
-
},
|
|
2978
|
+
"j5-lowercase-title": j5LowercaseTitle,
|
|
2979
|
+
"w1-scenario-pure": w1ScenarioPure,
|
|
2938
2980
|
"w2w-user-facing-elements": {
|
|
2939
2981
|
create(context) {
|
|
2940
2982
|
return { CallExpression(node) {
|
|
2941
2983
|
const callee = node.callee;
|
|
2942
|
-
|
|
2984
|
+
const member = callee ? memberPropertyName(callee) : void 0;
|
|
2985
|
+
if (member === void 0 || !SCENARIO_ACTIONS.has(member)) return;
|
|
2943
2986
|
const scenario = node.arguments?.[1];
|
|
2944
2987
|
if (scenario?.type !== "ArrowFunctionExpression" && scenario?.type !== "FunctionExpression") return;
|
|
2945
2988
|
walk(scenario, (inner) => {
|
|
@@ -1,131 +1,7 @@
|
|
|
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
|
-
//#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
|
|
129
5
|
//#region src/integrations/playwright/playwright.adapter.ts
|
|
130
6
|
/** How many candidates the ambiguity error enumerates before truncating. */
|
|
131
7
|
const MAX_REPORTED_MATCHES = 10;
|
|
@@ -245,9 +121,9 @@ var PlaywrightAdapter = class {
|
|
|
245
121
|
async open(url, options) {
|
|
246
122
|
const context = await (await this.launch()).newContext({ extraHTTPHeaders: options.headers });
|
|
247
123
|
if (options.external === "block") {
|
|
248
|
-
const
|
|
124
|
+
const allowed = /* @__PURE__ */ new Set([new URL(options.baseUrl).origin, ...options.allowedOrigins ?? []]);
|
|
249
125
|
await context.route("**/*", (route) => {
|
|
250
|
-
if (new URL(route.request().url()).origin
|
|
126
|
+
if (allowed.has(new URL(route.request().url()).origin)) route.continue();
|
|
251
127
|
else route.abort();
|
|
252
128
|
});
|
|
253
129
|
}
|