@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/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."
@@ -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: "website",
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 four constructors, and only four (A2 — see docs/10-linting.md). */
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()` and `specification.website()` are the only members.
778
- * Any other access (`specification.app`, `.http`, `.stack`, …) is flagged at
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.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)." },
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
- create(context) {
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
- if (!callee || memberPropertyName(callee) !== "visit") return;
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: "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."
@@ -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: "website",
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 four constructors, and only four (A2 — see docs/10-linting.md). */
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()` and `specification.website()` are the only members.
774
- * Any other access (`specification.app`, `.http`, `.stack`, …) is flagged at
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.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)." },
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
- create(context) {
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
- if (!callee || memberPropertyName(callee) !== "visit") return;
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 origin = new URL(options.baseUrl).origin;
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 === origin) route.continue();
126
+ if (allowed.has(new URL(route.request().url()).origin)) route.continue();
251
127
  else route.abort();
252
128
  });
253
129
  }