@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/dist/oxlint.cjs CHANGED
@@ -166,7 +166,7 @@ const RULE_DOCS = {
166
166
  },
167
167
  "a2-known-constructors": {
168
168
  channel: "statique",
169
- convention: "Quatre constructeurs et seulement quatre : `specification.api()`, `specification.jobs()`, `specification.cli(bin)`, `specification.website()` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
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 de visite 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: "website",
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: "website",
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 four constructors, and only four (A2 — see docs/10-linting.md). */
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()` and `specification.website()` are the only members.
769
- * Any other access (`specification.app`, `.http`, `.stack`, …) is flagged at
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.website() (A2 — see docs/10-linting.md)." },
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
- create(context) {
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
- if (!callee || memberPropertyName(callee) !== "visit") return;
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: "Quatre constructeurs et seulement quatre : `specification.api()`, `specification.jobs()`, `specification.cli(bin)`, `specification.website()` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur.",
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 de visite 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: "website",
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: "website",
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 four constructors, and only four (A2 — see docs/10-linting.md). */
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()` and `specification.website()` are the only members.
765
- * Any other access (`specification.app`, `.http`, `.stack`, …) is flagged at
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.website() (A2 — see docs/10-linting.md)." },
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
- create(context) {
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
- if (!callee || memberPropertyName(callee) !== "visit") return;
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
- /** Translate a user-facing element descriptor into a playwright locator. */
6
- function locate(page, element) {
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 "button": return page.getByRole("button", { name: element.name });
9
- case "field": return page.getByLabel(element.name);
10
- case "heading": return page.getByRole("heading", { name: element.name });
11
- case "link": return page.getByRole("link", { name: element.name });
12
- case "testId": return page.getByTestId(element.name);
13
- case "text": return page.getByText(element.name);
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) => locate(page, element).first().check(),
20
- click: (element) => locate(page, element).first().click(),
21
- fill: (element, value) => locate(page, element).first().fill(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) => locate(page, element).first().hover(),
95
+ hover: (element) => act(page, element, (locator) => locator.hover()),
26
96
  press: (key) => page.keyboard.press(key),
27
- see: (element) => locate(page, element).first().waitFor({ state: "visible" }),
97
+ see: (element) => act(page, element, (locator) => locator.waitFor({ state: "visible" })),
28
98
  select: async (element, option) => {
29
- await locate(page, element).first().selectOption(option);
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 origin = new URL(options.baseUrl).origin;
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 === origin) route.continue();
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
- throw new Error(`visit scenario failed: ${error instanceof Error ? error.message : String(error)}${evidence ? `\nEvidence: ${evidence}` : ""}`, { cause: error });
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) => ({