@jterrazz/test 15.1.1 → 15.3.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 +38 -29
- package/dist/ambiguity.js +23 -4
- package/dist/artifacts.js +108 -163
- package/dist/browser/index.js +2796 -0
- package/dist/checker.js +470 -29
- package/dist/index.d.ts +1434 -609
- package/dist/index.js +3696 -2745
- package/dist/intercept.js +117 -59
- package/dist/oxlint.cjs +481 -35
- package/dist/oxlint.d.cts +13 -1
- package/dist/oxlint.d.ts +13 -1
- package/dist/oxlint.js +481 -35
- package/dist/playwright.adapter.js +96 -22
- package/dist/{queue.js → stream.js} +61 -2
- package/dist/vitest.d.ts +130 -2
- package/dist/vitest.js +433 -3
- package/package.json +37 -5
package/dist/oxlint.cjs
CHANGED
|
@@ -173,9 +173,6 @@ function walk(root, visit) {
|
|
|
173
173
|
} else if (isNode(value)) walk(value, visit);
|
|
174
174
|
}
|
|
175
175
|
}
|
|
176
|
-
function isNode(value) {
|
|
177
|
-
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
178
|
-
}
|
|
179
176
|
/** The source start offset of a node or comment (oxlint `start`, else `range[0]`). */
|
|
180
177
|
function nodeStart(node) {
|
|
181
178
|
if (node === void 0) return -1;
|
|
@@ -222,6 +219,27 @@ function importSourceVisitor(onSource) {
|
|
|
222
219
|
ImportExpression: fromSourceField
|
|
223
220
|
};
|
|
224
221
|
}
|
|
222
|
+
/** Is this value an AST node — an object carrying a `type` string? */
|
|
223
|
+
function isNode(value) {
|
|
224
|
+
return typeof value === "object" && value !== null && "type" in value && typeof value.type === "string";
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* A node's child by key, or `undefined` when the key holds something else.
|
|
228
|
+
*
|
|
229
|
+
* Every rule reaches for `node.parent`, `node.id`, `node.value`; the node shape
|
|
230
|
+
* types them as `unknown`, so each reach was an assertion of its own. One
|
|
231
|
+
* guarded read is the same answer, checked once.
|
|
232
|
+
*/
|
|
233
|
+
function child(node, key) {
|
|
234
|
+
const value = node?.[key];
|
|
235
|
+
return isNode(value) ? value : void 0;
|
|
236
|
+
}
|
|
237
|
+
/** The name of an identifier node, or `undefined` for anything else. */
|
|
238
|
+
function identifierName(node) {
|
|
239
|
+
if (node?.type !== "Identifier") return;
|
|
240
|
+
const { name } = node;
|
|
241
|
+
return typeof name === "string" ? name : void 0;
|
|
242
|
+
}
|
|
225
243
|
//#endregion
|
|
226
244
|
//#region src/lint/manifest.ts
|
|
227
245
|
/**
|
|
@@ -266,7 +284,7 @@ const RULE_DOCS = {
|
|
|
266
284
|
},
|
|
267
285
|
"a2-known-constructors": {
|
|
268
286
|
channel: "statique",
|
|
269
|
-
convention: "
|
|
287
|
+
convention: "Six constructeurs et seulement six : `specification.api()`, `specification.jobs()`, `specification.cli(bin)`, `specification.integration()`, `specification.website()`, `specification.mobile()` ; tout autre membre (`.app`, `.http`, `.stack`…) est une erreur. Un composant rendu ne démarre rien : c’est une chaîne, pas un membre de ce record.",
|
|
270
288
|
family: "A",
|
|
271
289
|
id: "A2",
|
|
272
290
|
rationale: "Une surface fermée empêche l’invention de constructeurs parallèles et garde l’API mémorisable."
|
|
@@ -350,7 +368,7 @@ const RULE_DOCS = {
|
|
|
350
368
|
},
|
|
351
369
|
"c1-domain-structure": {
|
|
352
370
|
channel: "statique",
|
|
353
|
-
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `facet` (test au root de la facette OU un dossier domaine plus bas, jamais plus profond), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans. Une exception, et une seule : le sol peut être du CODE, et un `<module>.test.ts` posé À CÔTÉ du `<module>.ts` qu’il teste y est légal (c’est la loi I2) ; un test sans module voisin, ou un `*.specification.ts`, reste une violation.",
|
|
371
|
+
convention: "La forme de l’arbre `specs/` se déclare via l’option `depth` : `facet-domain` (défaut — `*.test.ts` à la profondeur facet/domain, `*.specification.ts` au root de la facette), `facet` (test au root de la facette OU un dossier domaine plus bas, jamais plus profond), `mirror` (test à toute profondeur ≥ 1, nommé d’après son dossier), `off`. Dans tous les modes, `off` compris : un dossier à underscore initial est du SOL, jamais un domaine — aucune spec ne vit dedans. Une exception, et une seule : le sol peut être du CODE, et un `<module>.test.ts` posé À CÔTÉ du `<module>.ts` qu’il teste y est légal (c’est la loi I2) ; un test sans module voisin, ou un `*.specification.ts`, reste une violation. Un `.test.tsx` est hors de portée : une unité rendue se spécifie à côté de son composant, jamais dans un arbre facette/domaine — c’est I2 qui tient son voisinage.",
|
|
354
372
|
family: "C",
|
|
355
373
|
id: "C1",
|
|
356
374
|
rationale: "Une profondeur fixe rend la place de chaque fichier prévisible et détectable statiquement."
|
|
@@ -467,6 +485,36 @@ const RULE_DOCS = {
|
|
|
467
485
|
id: "D15",
|
|
468
486
|
rationale: "Un statut isolé ne fige que le code de réponse et jette tout le reste du payload ; le golden complet capture la forme entière et sa grammaire de tokens (complète d12w, qui exige un amas de sondes de corps et manque le cas de la sonde de statut solitaire)."
|
|
469
487
|
},
|
|
488
|
+
"e5-no-simulated-dom": {
|
|
489
|
+
channel: "statique",
|
|
490
|
+
convention: "Un fichier de test ne déclare pas de DOM simulé : la pragma `@vitest-environment happy-dom|jsdom` est une erreur. Portée : les rôles qui EXÉCUTENT un test (`module`, `component`, `specification`) — une config de projet est le domaine de E5b.",
|
|
491
|
+
facet: "component",
|
|
492
|
+
family: "E",
|
|
493
|
+
fix: "Déplacer le test à côté de son composant en `.test.tsx` et le faire collecter par le projet `component()`.",
|
|
494
|
+
id: "E5",
|
|
495
|
+
rationale: "Un DOM simulé se comporte PRESQUE comme un navigateur, et ce qui est livré est jugé par un vrai : la facette component rend dans le Chromium que la facette website pilote déjà.",
|
|
496
|
+
reach: "tests"
|
|
497
|
+
},
|
|
498
|
+
"e5b-no-simulated-dom-config": {
|
|
499
|
+
channel: "statique",
|
|
500
|
+
convention: "Une config vitest ne déclare pas de DOM simulé : `environment: 'happy-dom'|'jsdom'` est une erreur ; `'node'` et `'edge-runtime'` nomment un vrai runtime et restent hors de portée.",
|
|
501
|
+
facet: "component",
|
|
502
|
+
family: "E",
|
|
503
|
+
fix: "Supprimer `environment` et collecter le rendu avec `component()` ; les tests de module restent sous node.",
|
|
504
|
+
id: "E5b",
|
|
505
|
+
rationale: "La pragma (E5) n’engage qu’un fichier ; la config engage tout ce que le projet collecte — c’est ainsi qu’un dépôt hérite d’un DOM simulé que personne n’a choisi test par test.",
|
|
506
|
+
reach: "config"
|
|
507
|
+
},
|
|
508
|
+
"e6-component-project-helper": {
|
|
509
|
+
channel: "statique",
|
|
510
|
+
convention: "Un projet navigateur vient de `component()` : dans une config vitest, une clé `browser` sous `test` qui n'est pas dans un appel à `component(...)` est une erreur. Une clé `browser` ailleurs (un `define`, un enregistrement d'env) n'est pas un bloc de projet et reste hors de portée.",
|
|
511
|
+
facet: "component",
|
|
512
|
+
family: "E",
|
|
513
|
+
fix: "Remplacer le bloc par `component({ vite })` et ne garder que ce qui appartient au projet.",
|
|
514
|
+
id: "E6",
|
|
515
|
+
rationale: "Le provider épinglé à la version exacte du runner, le service worker servi depuis l’installation du framework, la transformation JSX du Vite courant, le pré-bundling d’un cache froid et les dossiers d’artefacts sont autant de runs qui passent ici et échouent sur la machine suivante.",
|
|
516
|
+
reach: "config"
|
|
517
|
+
},
|
|
470
518
|
"f1-no-subpath-import": {
|
|
471
519
|
channel: "statique",
|
|
472
520
|
convention: "Tout s’importe depuis `@jterrazz/test` ; un import de `@jterrazz/test/<subpath>` est une erreur, sauf les subpaths que la map `exports` du paquet publie — `@jterrazz/test/oxlint` (plugin de lint) et `@jterrazz/test/vitest` (surface de configuration du runner) — exemptés partout. La liste est LUE du manifeste, jamais recopiée dans la règle.",
|
|
@@ -483,7 +531,7 @@ const RULE_DOCS = {
|
|
|
483
531
|
},
|
|
484
532
|
"f3-specs-public-entry": {
|
|
485
533
|
channel: "statique",
|
|
486
|
-
convention: "Depuis `specs/`, seul l’import en profondeur des INTERNES du framework est interdit : un chemin relatif résolvant dans `src/{specification,integrations,vitest,lint}/` du dépôt du framework, ou tout `@jterrazz/test/<subpath>` que la map `exports` du paquet ne publie pas — les subpaths publiés sont exemptés, LUS du manifeste comme en F1, qui en tient la liste. Les imports de la source de SA PROPRE app par un consommateur sont toujours permis (c’est le motif documenté)
|
|
534
|
+
convention: "Depuis `specs/`, seul l’import en profondeur des INTERNES du framework est interdit : un chemin relatif résolvant dans `src/{specification,integrations,vitest,lint}/` du dépôt du framework, ou tout `@jterrazz/test/<subpath>` que la map `exports` du paquet ne publie pas — les subpaths publiés sont exemptés, LUS du manifeste comme en F1, qui en tient la liste. Les imports de la source de SA PROPRE app par un consommateur sont toujours permis (c’est le motif documenté). Aucune exception de dossier : une sonde qui ne peut pas atteindre son sujet par l’entrée publique est un test de module à côté de son module, pas une spec.",
|
|
487
535
|
family: "F",
|
|
488
536
|
id: "F3",
|
|
489
537
|
rationale: "Tester par la surface publique garde les specs découplées des chemins internes du framework, sans gêner le consommateur qui importe sa propre app."
|
|
@@ -502,6 +550,26 @@ const RULE_DOCS = {
|
|
|
502
550
|
id: "F5",
|
|
503
551
|
rationale: "Cantonner les fixtures aux tests empêche la donnée de test de fuir dans le code de prod."
|
|
504
552
|
},
|
|
553
|
+
"f6-no-foreign-test-runtime": {
|
|
554
|
+
channel: "statique",
|
|
555
|
+
convention: "Un fichier de test n’importe pas de second runtime de test : `@testing-library/*`, `happy-dom`, `jsdom`, `vitest/browser`, `vitest-browser-*` et `@vitest/browser*` sont des erreurs. Portée : les rôles qui EXÉCUTENT un test (`module`, `component`, `specification`) — la config d’un projet fixture et un module de providers sous `specs/` nomment légitimement l’adaptateur.",
|
|
556
|
+
facet: "component",
|
|
557
|
+
family: "F",
|
|
558
|
+
fix: "Passer par la facette qui remplace le seam : `component.render()`, le vocabulaire d’éléments et le visiteur.",
|
|
559
|
+
id: "F6",
|
|
560
|
+
rationale: "Un test qui importe l’adaptateur parle le dialecte de l’adaptateur : un dépôt finit avec autant de vocabulaires que de seams. Les groupes livrés sont ceux que la facette component REMPLACE ; le reste de la famille suit la vague de règles.",
|
|
561
|
+
reach: "tests"
|
|
562
|
+
},
|
|
563
|
+
"g4-no-dom-in-module-test": {
|
|
564
|
+
channel: "statique",
|
|
565
|
+
convention: "Un test de module ne touche aucun global du DOM : `document`, `window`, `navigator`, `HTMLElement` référencés en position de VALEUR dans un fichier de rôle `module` HORS de `specs/` sont une erreur. Un nom en position de type (`x as HTMLElement`) et un garde `typeof window` n’atteignent aucun document et restent hors de portée ; tant que le rôle `spec` n’existe pas, `module` couvre aussi les `.test.ts` de `specs/`, que la règle laisse donc passer.",
|
|
566
|
+
facet: "component",
|
|
567
|
+
family: "G",
|
|
568
|
+
fix: "Renommer le test en `.test.tsx` à côté du composant qu’il rend, et le faire collecter par `component()`.",
|
|
569
|
+
id: "G4",
|
|
570
|
+
rationale: "Un test de module tourne sous node, où `document` n’existe pas : un test qui en réclame un ne teste pas un module, il rend quelque chose — et il a désormais un endroit où aller.",
|
|
571
|
+
reach: "module"
|
|
572
|
+
},
|
|
505
573
|
"i1-layer-boundaries": {
|
|
506
574
|
channel: "statique",
|
|
507
575
|
convention: "Le projet DÉCLARE ses couches sous `src/` via l’option `layers` (paquets autorisés, imports internes, un dossier = une dépendance, seams) ; tout import hors des arêtes déclarées est une erreur. Sans carte de couches, la règle est inerte.",
|
|
@@ -511,9 +579,11 @@ const RULE_DOCS = {
|
|
|
511
579
|
},
|
|
512
580
|
"i2-sibling-test-naming": {
|
|
513
581
|
channel: "statique",
|
|
514
|
-
convention: "Le test de `<fichier>.ts` est `<fichier>.test.ts` à côté de lui ; un `.test.ts` mal nommé, ou un dossier `__tests__/`, est une erreur.",
|
|
582
|
+
convention: "Le test de `<fichier>.ts` est `<fichier>.test.ts` à côté de lui ; un `.test.ts` mal nommé, ou un dossier `__tests__/`, est une erreur. Une unité RENDUE obéit à la même loi où qu’elle vive : un `.test.tsx` est voisin du `.tsx` qu’il rend, ou du `.ts` du hook ou de la fonction DOM dont il porte l’hôte.",
|
|
515
583
|
family: "I",
|
|
584
|
+
fix: "Poser le test à côté de l’unité qu’il couvre, sous le même basename.",
|
|
516
585
|
id: "I2",
|
|
586
|
+
reach: "tests",
|
|
517
587
|
rationale: "Des tests voisins (parité avec le `foo_test.go` de Go) gardent test et code ensemble et découvrables."
|
|
518
588
|
},
|
|
519
589
|
"i4-no-vi-mock-in-src": {
|
|
@@ -553,6 +623,39 @@ const RULE_DOCS = {
|
|
|
553
623
|
* visits data fixtures nor reads two files at once, so these ship separately.
|
|
554
624
|
*/
|
|
555
625
|
const CHECKER_PASSES = [
|
|
626
|
+
{
|
|
627
|
+
channel: "checker",
|
|
628
|
+
convention: "Chaque membre du workspace qui a des tests À LUI (un script `test` qui ne délègue pas aux membres, ou un `*.test.ts(x)` hors de l’arbre d’un autre paquet) déclare une config vitest (`vitest.config.*`). La passe MEMBRE juge le membre lui-même, avec ou sans arbre `specs/` ; une racine qui ne fait que déléguer ne doit rien.",
|
|
629
|
+
facet: "shared",
|
|
630
|
+
family: "E",
|
|
631
|
+
fix: "Écrire `export default defineSpecConfig()` dans le membre — le préréglage porte les budgets, le dossier d’artefacts et l’exclusion de `_fixtures/`.",
|
|
632
|
+
id: "E3",
|
|
633
|
+
name: "e3-config-present",
|
|
634
|
+
rationale: "Sans config, le budget de 5 s de vitest et un `_fixtures/` non exclu font tourner les tests du membre : le défaut n’est pas choisi, il est hérité.",
|
|
635
|
+
reach: "config"
|
|
636
|
+
},
|
|
637
|
+
{
|
|
638
|
+
channel: "checker",
|
|
639
|
+
convention: "La passe MEMBRE relit la règle E5b là où oxlint peut ne pas atteindre la config d’un membre : `environment: 'happy-dom'|'jsdom'` dans un `vitest.config.*` est une erreur.",
|
|
640
|
+
facet: "component",
|
|
641
|
+
family: "E",
|
|
642
|
+
fix: "Supprimer `environment` et collecter le rendu avec `component()`.",
|
|
643
|
+
id: "E5b",
|
|
644
|
+
name: "e5b-no-simulated-dom-config-member",
|
|
645
|
+
rationale: "La passe statique ne voit que ce que la configuration oxlint du dépôt lui donne ; la passe membre part du manifeste racine et atteint chaque membre déclaré.",
|
|
646
|
+
reach: "config"
|
|
647
|
+
},
|
|
648
|
+
{
|
|
649
|
+
channel: "checker",
|
|
650
|
+
convention: "Un membre ne déclare aucune dépendance que `@jterrazz/test` porte déjà (`msw`, `vitest-mock-extended` ; `yaml` en devDependencies seulement — en dépendance de production c’est la bibliothèque du produit) ni aucun seam retiré (`happy-dom`, `jsdom`, `@testing-library/*`, `@playwright/test`, `mockdate`). Les peers optionnels — `playwright`, `vite`, `react`, `better-sqlite3`, `pg`, `redis`, `testcontainers`… — sont déclarés par le consommateur, à dessein.",
|
|
651
|
+
facet: "shared",
|
|
652
|
+
family: "F",
|
|
653
|
+
fix: "Retirer la déclaration : une transitive est déjà résolue par le paquet ; un seam retiré a une facette qui le remplace (`component()`, `website()`, `clock`, `intercept()`). Les deux cas ont leur propre phrase dans le diagnostic.",
|
|
654
|
+
id: "F8",
|
|
655
|
+
name: "f8-no-seam-dependency",
|
|
656
|
+
rationale: "Une déclaration en double laisse deux versions d’un même seam coexister, et un seam retiré garde un vocabulaire que le framework a remplacé.",
|
|
657
|
+
reach: "config"
|
|
658
|
+
},
|
|
556
659
|
{
|
|
557
660
|
channel: "checker",
|
|
558
661
|
convention: "Tout `{{token}}` dans une fixture `_expected/` — ou dans un flux attendu d’un document `<cas>.spec.yaml` — appartient au vocabulaire figé ; un token inconnu est une erreur.",
|
|
@@ -844,14 +947,19 @@ function sortKey(entry) {
|
|
|
844
947
|
];
|
|
845
948
|
}
|
|
846
949
|
[
|
|
847
|
-
...Object.entries(RULE_DOCS).map(([name, doc]) =>
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
950
|
+
...Object.entries(RULE_DOCS).map(([name, doc]) => {
|
|
951
|
+
const entry = {
|
|
952
|
+
channel: doc.channel,
|
|
953
|
+
convention: doc.convention,
|
|
954
|
+
family: doc.family,
|
|
955
|
+
id: doc.id,
|
|
956
|
+
name,
|
|
957
|
+
rationale: doc.rationale
|
|
958
|
+
};
|
|
959
|
+
if ("fix" in doc) entry.fix = doc.fix;
|
|
960
|
+
if ("reach" in doc) entry.reach = doc.reach;
|
|
961
|
+
return entry;
|
|
962
|
+
}),
|
|
855
963
|
...CHECKER_PASSES,
|
|
856
964
|
...RUNTIME_RULES,
|
|
857
965
|
...PROCESS_RULES
|
|
@@ -863,6 +971,55 @@ function sortKey(entry) {
|
|
|
863
971
|
return av.localeCompare(bv);
|
|
864
972
|
});
|
|
865
973
|
//#endregion
|
|
974
|
+
//#region src/lint/role.ts
|
|
975
|
+
/** The four ground names — what the specs of a row stand ON, never a spec. */
|
|
976
|
+
const GROUND = /* @__PURE__ */ new Set([
|
|
977
|
+
"_expected",
|
|
978
|
+
"_fixtures",
|
|
979
|
+
"_requests",
|
|
980
|
+
"_seeds"
|
|
981
|
+
]);
|
|
982
|
+
/** A vitest config, whatever extension the project writes it in. */
|
|
983
|
+
const CONFIG = /^vitest\.config\.[cm]?[jt]s$/u;
|
|
984
|
+
/**
|
|
985
|
+
* The kind of file this path names.
|
|
986
|
+
*
|
|
987
|
+
* The suffix decides, and the order matters: a `*.specification.ts` is a
|
|
988
|
+
* specification wherever it sits, ground swallows anything under it that is not
|
|
989
|
+
* a test of its own module, and `.tsx` versus `.ts` is what tells a RENDERED
|
|
990
|
+
* unit from a plain one — the same distinction the two project helpers collect
|
|
991
|
+
* on, so a file is judged by the rules of the project that actually runs it.
|
|
992
|
+
*/
|
|
993
|
+
function roleOf(filename) {
|
|
994
|
+
const parts = segments(filename);
|
|
995
|
+
const base = parts.at(-1) ?? "";
|
|
996
|
+
const inSpecs = isUnderSpecs(filename);
|
|
997
|
+
if (base.endsWith(".specification.ts") || base.endsWith(".specification.tsx")) return {
|
|
998
|
+
inSpecs,
|
|
999
|
+
role: "specification"
|
|
1000
|
+
};
|
|
1001
|
+
if (CONFIG.test(base)) return {
|
|
1002
|
+
inSpecs,
|
|
1003
|
+
role: "config"
|
|
1004
|
+
};
|
|
1005
|
+
if (base.endsWith(".test.tsx")) return {
|
|
1006
|
+
inSpecs,
|
|
1007
|
+
role: "component"
|
|
1008
|
+
};
|
|
1009
|
+
if (base.endsWith(".test.ts")) return {
|
|
1010
|
+
inSpecs,
|
|
1011
|
+
role: "module"
|
|
1012
|
+
};
|
|
1013
|
+
if (parts.slice(0, -1).some((segment) => GROUND.has(segment))) return {
|
|
1014
|
+
inSpecs,
|
|
1015
|
+
role: "ground"
|
|
1016
|
+
};
|
|
1017
|
+
return {
|
|
1018
|
+
inSpecs,
|
|
1019
|
+
role: "other"
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
//#endregion
|
|
866
1023
|
//#region src/lint/rules/a1-specification-file.ts
|
|
867
1024
|
const SPECIFICATION_SUFFIX$2 = ".specification.ts";
|
|
868
1025
|
/**
|
|
@@ -873,10 +1030,15 @@ const SPECIFICATION_SUFFIX$2 = ".specification.ts";
|
|
|
873
1030
|
* Framework-internal unit tests of the constructors themselves (this repo's
|
|
874
1031
|
* `src/**\/*.test.ts`) disable the rule via a config override — for consumers
|
|
875
1032
|
* the rule is universal.
|
|
1033
|
+
*
|
|
1034
|
+
* The A family does not reach a `component`-role file. A rendered unit has no
|
|
1035
|
+
* runner to create: no server to start, no database to isolate, no binary to
|
|
1036
|
+
* find — which is why the component facet has no constructor and no
|
|
1037
|
+
* specification file for A1 to point at.
|
|
876
1038
|
*/
|
|
877
1039
|
const a1SpecificationFile = {
|
|
878
1040
|
create(context) {
|
|
879
|
-
if (context.filename.endsWith(SPECIFICATION_SUFFIX$2)) return {};
|
|
1041
|
+
if (context.filename.endsWith(SPECIFICATION_SUFFIX$2) || roleOf(context.physicalFilename).role === "component") return {};
|
|
880
1042
|
return { CallExpression(node) {
|
|
881
1043
|
const member = specificationMember(node);
|
|
882
1044
|
if (member !== void 0) context.report({
|
|
@@ -894,19 +1056,22 @@ const a1SpecificationFile = {
|
|
|
894
1056
|
};
|
|
895
1057
|
//#endregion
|
|
896
1058
|
//#region src/lint/rules/a2-known-constructors.ts
|
|
897
|
-
/** The
|
|
1059
|
+
/** The six constructors, and only six (A2 — see docs/13-linting.md). */
|
|
898
1060
|
const KNOWN_CONSTRUCTORS = /* @__PURE__ */ new Set([
|
|
899
1061
|
"api",
|
|
900
1062
|
"cli",
|
|
1063
|
+
"integration",
|
|
901
1064
|
"jobs",
|
|
902
1065
|
"mobile",
|
|
903
1066
|
"website"
|
|
904
1067
|
]);
|
|
905
1068
|
/**
|
|
906
1069
|
* CONVENTIONS A2 — `specification.api()`, `specification.jobs()`,
|
|
907
|
-
* `specification.cli()`, `specification.
|
|
908
|
-
* `specification.mobile()` are the only
|
|
909
|
-
* (`specification.app`, `.http`, `.stack`, …) is
|
|
1070
|
+
* `specification.cli()`, `specification.integration()`,
|
|
1071
|
+
* `specification.website()` and `specification.mobile()` are the only
|
|
1072
|
+
* members. Any other access (`specification.app`, `.http`, `.stack`, …) is
|
|
1073
|
+
* flagged at the member site. A rendered component has no constructor: it
|
|
1074
|
+
* starts nothing, so it is a chain, not a member of this record.
|
|
910
1075
|
*/
|
|
911
1076
|
const a2KnownConstructors = {
|
|
912
1077
|
create(context) {
|
|
@@ -923,7 +1088,7 @@ const a2KnownConstructors = {
|
|
|
923
1088
|
},
|
|
924
1089
|
meta: {
|
|
925
1090
|
docs: RULE_DOCS["a2-known-constructors"],
|
|
926
|
-
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/13-linting.md)." },
|
|
1091
|
+
messages: { unknownConstructor: "specification.{{member}} does not exist — the only constructors are specification.api(), specification.jobs(), specification.cli(), specification.integration(), specification.website() and specification.mobile() (A2 — see docs/13-linting.md)." },
|
|
927
1092
|
type: "problem"
|
|
928
1093
|
}
|
|
929
1094
|
};
|
|
@@ -2843,6 +3008,122 @@ const d15wStatusOnlyProbe = {
|
|
|
2843
3008
|
}
|
|
2844
3009
|
};
|
|
2845
3010
|
//#endregion
|
|
3011
|
+
//#region src/lint/rules/e5-no-simulated-dom.ts
|
|
3012
|
+
/** The pragma vitest reads to swap a test file's environment. */
|
|
3013
|
+
const PRAGMA = /@vitest-environment\s+(?<environment>[\w-]+)/u;
|
|
3014
|
+
/** The two simulated DOMs. */
|
|
3015
|
+
const SIMULATED$1 = /* @__PURE__ */ new Set(["happy-dom", "jsdom"]);
|
|
3016
|
+
/** The roles that RUN a test file — the only ones a pragma steers. */
|
|
3017
|
+
const REACHED$1 = /* @__PURE__ */ new Set([
|
|
3018
|
+
"component",
|
|
3019
|
+
"module",
|
|
3020
|
+
"specification"
|
|
3021
|
+
]);
|
|
3022
|
+
/**
|
|
3023
|
+
* CONVENTIONS E5 — no simulated DOM, stated per file.
|
|
3024
|
+
*
|
|
3025
|
+
* `@vitest-environment happy-dom` buys a document that behaves almost like a
|
|
3026
|
+
* browser's: almost is the whole problem, because what ships is judged by a
|
|
3027
|
+
* real one. The component facet renders in the Chromium the website facet
|
|
3028
|
+
* already drives, so the pragma has nothing left to buy.
|
|
3029
|
+
*/
|
|
3030
|
+
const e5NoSimulatedDom = {
|
|
3031
|
+
create(context) {
|
|
3032
|
+
if (!REACHED$1.has(roleOf(context.physicalFilename).role)) return {};
|
|
3033
|
+
return { Program(node) {
|
|
3034
|
+
for (const comment of context.sourceCode.getAllComments()) {
|
|
3035
|
+
const environment = PRAGMA.exec(comment.value)?.groups?.environment;
|
|
3036
|
+
if (environment !== void 0 && SIMULATED$1.has(environment)) context.report({
|
|
3037
|
+
data: { environment },
|
|
3038
|
+
messageId: "pragma",
|
|
3039
|
+
node
|
|
3040
|
+
});
|
|
3041
|
+
}
|
|
3042
|
+
} };
|
|
3043
|
+
},
|
|
3044
|
+
meta: {
|
|
3045
|
+
docs: RULE_DOCS["e5-no-simulated-dom"],
|
|
3046
|
+
messages: { pragma: "`@vitest-environment {{environment}}` simulates a browser this framework can open for real — a rendered thing is a `.test.tsx` beside its component, collected by the `component()` project (E5 — docs/13-linting.md#e5-no-simulated-dom)." },
|
|
3047
|
+
type: "problem"
|
|
3048
|
+
}
|
|
3049
|
+
};
|
|
3050
|
+
//#endregion
|
|
3051
|
+
//#region src/lint/rules/e5b-no-simulated-dom-config.ts
|
|
3052
|
+
/** The two simulated DOMs. */
|
|
3053
|
+
const SIMULATED = /* @__PURE__ */ new Set(["happy-dom", "jsdom"]);
|
|
3054
|
+
/**
|
|
3055
|
+
* CONVENTIONS E5b — the same ban, where a project states it for a whole tree.
|
|
3056
|
+
*
|
|
3057
|
+
* The pragma (E5) is one file's; `environment: 'happy-dom'` in a config is
|
|
3058
|
+
* every file the project collects, which is how a repository ends up with a
|
|
3059
|
+
* simulated DOM nobody ever chose per test. `'node'` and `'edge-runtime'` are
|
|
3060
|
+
* out of reach: they name a real runtime, not a drawing of one.
|
|
3061
|
+
*/
|
|
3062
|
+
const e5bNoSimulatedDomConfig = {
|
|
3063
|
+
create(context) {
|
|
3064
|
+
if (roleOf(context.physicalFilename).role !== "config") return {};
|
|
3065
|
+
return { Program(node) {
|
|
3066
|
+
walk(node, (candidate) => {
|
|
3067
|
+
if (candidate.type !== "Property" || propertyKeyName(candidate) !== "environment") return;
|
|
3068
|
+
const environment = stringValue(child(candidate, "value"));
|
|
3069
|
+
if (environment !== void 0 && SIMULATED.has(environment)) context.report({
|
|
3070
|
+
data: { environment },
|
|
3071
|
+
messageId: "configured",
|
|
3072
|
+
node: candidate
|
|
3073
|
+
});
|
|
3074
|
+
});
|
|
3075
|
+
} };
|
|
3076
|
+
},
|
|
3077
|
+
meta: {
|
|
3078
|
+
docs: RULE_DOCS["e5b-no-simulated-dom-config"],
|
|
3079
|
+
messages: { configured: "`environment: '{{environment}}'` gives every file of this project a drawing of a browser — a rendered thing is a `.test.tsx` beside its component, collected by `component()` (E5b — docs/13-linting.md#e5b-no-simulated-dom-config)." },
|
|
3080
|
+
type: "problem"
|
|
3081
|
+
}
|
|
3082
|
+
};
|
|
3083
|
+
//#endregion
|
|
3084
|
+
//#region src/lint/rules/e6-component-project-helper.ts
|
|
3085
|
+
/** Is this property the RUNNER's browser block, rather than a key that shares its name? */
|
|
3086
|
+
function isRunnerBrowserBlock(node) {
|
|
3087
|
+
for (let current = child(node, "parent"); current !== void 0; current = child(current, "parent")) if (current.type === "Property" && propertyKeyName(current) === "test") return true;
|
|
3088
|
+
return false;
|
|
3089
|
+
}
|
|
3090
|
+
/** Is this node inside a call to `component(...)`? */
|
|
3091
|
+
function insideComponentCall(node) {
|
|
3092
|
+
for (let current = child(node, "parent"); current !== void 0; current = child(current, "parent")) if (current.type === "CallExpression" && identifierName(child(current, "callee")) === "component") return true;
|
|
3093
|
+
return false;
|
|
3094
|
+
}
|
|
3095
|
+
/**
|
|
3096
|
+
* CONVENTIONS E6 — a browser project comes from `component()`.
|
|
3097
|
+
*
|
|
3098
|
+
* A hand-rolled `browser: { … }` block is a project that has to rediscover
|
|
3099
|
+
* everything the seam already knows: the provider pinned to the runner's exact
|
|
3100
|
+
* version, the service worker served from the framework's own install, the JSX
|
|
3101
|
+
* transform the current Vite actually uses, the dependencies a cold cache must
|
|
3102
|
+
* pre-bundle, and the artefact directories Browser Mode otherwise leaks at the
|
|
3103
|
+
* repository root. Every one of those is a run that fails on the second
|
|
3104
|
+
* machine, so the helper owns them and a config states only what is its own.
|
|
3105
|
+
*/
|
|
3106
|
+
const e6ComponentProjectHelper = {
|
|
3107
|
+
create(context) {
|
|
3108
|
+
if (roleOf(context.physicalFilename).role !== "config") return {};
|
|
3109
|
+
return { Program(node) {
|
|
3110
|
+
walk(node, (candidate) => {
|
|
3111
|
+
if (candidate.type !== "Property" || propertyKeyName(candidate) !== "browser") return;
|
|
3112
|
+
if (!isRunnerBrowserBlock(candidate)) return;
|
|
3113
|
+
if (!insideComponentCall(candidate)) context.report({
|
|
3114
|
+
messageId: "handRolled",
|
|
3115
|
+
node: candidate
|
|
3116
|
+
});
|
|
3117
|
+
});
|
|
3118
|
+
} };
|
|
3119
|
+
},
|
|
3120
|
+
meta: {
|
|
3121
|
+
docs: RULE_DOCS["e6-component-project-helper"],
|
|
3122
|
+
messages: { handRolled: "Use `component({ vite })` — the provider, the worker and the pipeline are its, and a hand-rolled `browser:` block is the run that passes here and fails on the next machine (E6 — docs/13-linting.md#e6-component-project-helper)." },
|
|
3123
|
+
type: "problem"
|
|
3124
|
+
}
|
|
3125
|
+
};
|
|
3126
|
+
//#endregion
|
|
2846
3127
|
//#region src/lint/package-exports.ts
|
|
2847
3128
|
/**
|
|
2848
3129
|
* The subpaths this package actually publishes, read from its OWN
|
|
@@ -3017,16 +3298,14 @@ const FRAMEWORK_LAYERS = /* @__PURE__ */ new Set([
|
|
|
3017
3298
|
*
|
|
3018
3299
|
* Distinct from F1 (which judges the same specifiers from any file): F3 is the
|
|
3019
3300
|
* specs-specific guard that also reaches relative framework-internal paths.
|
|
3020
|
-
*
|
|
3021
|
-
*
|
|
3301
|
+
* There is no folder exception: a probe that cannot reach its subject through
|
|
3302
|
+
* the public entry is a module test beside its module, not a spec.
|
|
3022
3303
|
*/
|
|
3023
3304
|
const f3SpecsPublicEntry = {
|
|
3024
3305
|
create(context) {
|
|
3025
|
-
|
|
3026
|
-
if (anchor === void 0) return {};
|
|
3306
|
+
if (specsAnchor(context.filename) === void 0) return {};
|
|
3027
3307
|
const published = new Set(declaredSubpaths());
|
|
3028
3308
|
const exempt = published.size === 0 ? "none" : [...published].join(", ");
|
|
3029
|
-
const underIntegrations = anchor.relative[0] === "integrations";
|
|
3030
3309
|
return { ...importSourceVisitor(({ node, source }) => {
|
|
3031
3310
|
if (source.startsWith(`${PACKAGE}/`)) {
|
|
3032
3311
|
if (published.has(source)) return;
|
|
@@ -3044,7 +3323,6 @@ const f3SpecsPublicEntry = {
|
|
|
3044
3323
|
if (marker === -1 && !source.startsWith("src/")) return;
|
|
3045
3324
|
const layer = (marker === -1 ? source.slice(4) : source.slice(marker + 5)).split("/")[0] ?? "";
|
|
3046
3325
|
if (!FRAMEWORK_LAYERS.has(layer)) return;
|
|
3047
|
-
if (underIntegrations && layer === "integrations") return;
|
|
3048
3326
|
context.report({
|
|
3049
3327
|
data: {
|
|
3050
3328
|
published: exempt,
|
|
@@ -3057,7 +3335,7 @@ const f3SpecsPublicEntry = {
|
|
|
3057
3335
|
},
|
|
3058
3336
|
meta: {
|
|
3059
3337
|
docs: RULE_DOCS["f3-specs-public-entry"],
|
|
3060
|
-
messages: { deepImport: "specs/ must not deep-import framework internals — reach the framework via its public entry (@jterrazz/test, or src/index.js in this repo), not \"{{source}}\" (F3 — see docs/13-linting.md). Exempt: the published subpaths {{published}}
|
|
3338
|
+
messages: { deepImport: "specs/ must not deep-import framework internals — reach the framework via its public entry (@jterrazz/test, or src/index.js in this repo), not \"{{source}}\" (F3 — see docs/13-linting.md). Exempt: the published subpaths {{published}}. A probe whose subject is not on the public entry is a module test beside its module." },
|
|
3061
3339
|
type: "problem"
|
|
3062
3340
|
}
|
|
3063
3341
|
};
|
|
@@ -3114,6 +3392,164 @@ const f5FixturesOnlyFromTests = {
|
|
|
3114
3392
|
}
|
|
3115
3393
|
};
|
|
3116
3394
|
//#endregion
|
|
3395
|
+
//#region src/lint/rules/f6-no-foreign-test-runtime.ts
|
|
3396
|
+
const GROUPS = [
|
|
3397
|
+
{
|
|
3398
|
+
fix: "`component.render()` and the element vocabulary render in a real browser — the queries are the same nouns, and they run against what ships",
|
|
3399
|
+
matches: (source) => source === "@testing-library" || source.startsWith("@testing-library/")
|
|
3400
|
+
},
|
|
3401
|
+
{
|
|
3402
|
+
fix: "a drawing of a browser proves what a browser does not — a rendered thing is a `.test.tsx` beside its component, collected by `component()`",
|
|
3403
|
+
matches: (source) => source === "happy-dom" || source === "jsdom"
|
|
3404
|
+
},
|
|
3405
|
+
{
|
|
3406
|
+
fix: "Browser Mode is the seam, not the surface — `component.render()` owns the locators, the visitor and the ARIA tree",
|
|
3407
|
+
matches: (source) => source === "vitest/browser" || source.startsWith("vitest-browser-") || source.startsWith("@vitest/browser")
|
|
3408
|
+
}
|
|
3409
|
+
];
|
|
3410
|
+
/**
|
|
3411
|
+
* The roles that RUN a test — never a fixture app's config or a provider module
|
|
3412
|
+
* that happens to sit under `specs/`. A fixture project's own `vitest.config.ts`
|
|
3413
|
+
* has to name the provider, and a `providers.tsx` has to import the adapter it
|
|
3414
|
+
* wraps: neither is a spec speaking a second dialect.
|
|
3415
|
+
*/
|
|
3416
|
+
const REACHED = /* @__PURE__ */ new Set([
|
|
3417
|
+
"component",
|
|
3418
|
+
"module",
|
|
3419
|
+
"specification"
|
|
3420
|
+
]);
|
|
3421
|
+
/**
|
|
3422
|
+
* CONVENTIONS F6 — a test file imports no second test runtime.
|
|
3423
|
+
*
|
|
3424
|
+
* Every seam the framework owns is one a spec must not reach around: a test
|
|
3425
|
+
* that imports the adapter speaks the adapter's dialect, and a repository ends
|
|
3426
|
+
* up with as many vocabularies as it has seams. The groups the rule carries are
|
|
3427
|
+
* the ones the component facet REPLACES.
|
|
3428
|
+
*/
|
|
3429
|
+
const f6NoForeignTestRuntime = {
|
|
3430
|
+
create(context) {
|
|
3431
|
+
if (!REACHED.has(roleOf(context.physicalFilename).role)) return {};
|
|
3432
|
+
return { ...importSourceVisitor(({ node, source }) => {
|
|
3433
|
+
const group = GROUPS.find((candidate) => candidate.matches(source));
|
|
3434
|
+
if (group !== void 0) context.report({
|
|
3435
|
+
data: {
|
|
3436
|
+
fix: group.fix,
|
|
3437
|
+
source
|
|
3438
|
+
},
|
|
3439
|
+
messageId: "foreignRuntime",
|
|
3440
|
+
node
|
|
3441
|
+
});
|
|
3442
|
+
}) };
|
|
3443
|
+
},
|
|
3444
|
+
meta: {
|
|
3445
|
+
docs: RULE_DOCS["f6-no-foreign-test-runtime"],
|
|
3446
|
+
messages: { foreignRuntime: "`{{source}}` is a second test runtime in a test file: {{fix}} (F6 — docs/13-linting.md#f6-no-foreign-test-runtime)." },
|
|
3447
|
+
type: "problem"
|
|
3448
|
+
}
|
|
3449
|
+
};
|
|
3450
|
+
//#endregion
|
|
3451
|
+
//#region src/lint/rules/g4-no-dom-in-module-test.ts
|
|
3452
|
+
/** The globals that only exist where a document does. */
|
|
3453
|
+
const DOM_GLOBALS = /* @__PURE__ */ new Set([
|
|
3454
|
+
"document",
|
|
3455
|
+
"HTMLElement",
|
|
3456
|
+
"navigator",
|
|
3457
|
+
"window"
|
|
3458
|
+
]);
|
|
3459
|
+
/** The node kinds that BIND a name, and the key each one binds it under. */
|
|
3460
|
+
const BINDINGS = {
|
|
3461
|
+
CatchClause: "param",
|
|
3462
|
+
ClassDeclaration: "id",
|
|
3463
|
+
FunctionDeclaration: "id",
|
|
3464
|
+
ImportDefaultSpecifier: "local",
|
|
3465
|
+
ImportNamespaceSpecifier: "local",
|
|
3466
|
+
ImportSpecifier: "local",
|
|
3467
|
+
VariableDeclarator: "id"
|
|
3468
|
+
};
|
|
3469
|
+
/**
|
|
3470
|
+
* Every name this file declares for itself.
|
|
3471
|
+
*
|
|
3472
|
+
* A file-wide set rather than a scope chain: the rule only needs to know that
|
|
3473
|
+
* `document` is the test's own binding somewhere, and a name shadowed in one
|
|
3474
|
+
* scope is not a global in another. It can only under-report, which is the
|
|
3475
|
+
* right direction for a guard.
|
|
3476
|
+
*/
|
|
3477
|
+
function declaredNames(root) {
|
|
3478
|
+
const names = /* @__PURE__ */ new Set();
|
|
3479
|
+
const add = (node) => {
|
|
3480
|
+
const name = identifierName(node);
|
|
3481
|
+
if (name !== void 0) names.add(name);
|
|
3482
|
+
};
|
|
3483
|
+
walk(root, (node) => {
|
|
3484
|
+
const key = BINDINGS[node.type];
|
|
3485
|
+
if (key !== void 0) add(child(node, key));
|
|
3486
|
+
const { params } = node;
|
|
3487
|
+
if (Array.isArray(params)) {
|
|
3488
|
+
for (const parameter of params) if (isNode(parameter)) add(parameter);
|
|
3489
|
+
}
|
|
3490
|
+
});
|
|
3491
|
+
return names;
|
|
3492
|
+
}
|
|
3493
|
+
/** The node kinds a name sits in when it is a TYPE, not a value the test reaches. */
|
|
3494
|
+
const TYPE_POSITIONS = /* @__PURE__ */ new Set([
|
|
3495
|
+
"TSInterfaceHeritage",
|
|
3496
|
+
"TSQualifiedName",
|
|
3497
|
+
"TSTypeQuery",
|
|
3498
|
+
"TSTypeReference"
|
|
3499
|
+
]);
|
|
3500
|
+
/** Is this identifier read as a value, rather than named as a property, a key or a type? */
|
|
3501
|
+
function isValueReference(node) {
|
|
3502
|
+
const parent = child(node, "parent");
|
|
3503
|
+
if (parent === void 0) return true;
|
|
3504
|
+
if (TYPE_POSITIONS.has(parent.type)) return false;
|
|
3505
|
+
if (parent.type === "UnaryExpression" && parent.operator === "typeof") return false;
|
|
3506
|
+
if (parent.type === "MemberExpression") return child(parent, "property") !== node || parent.computed === true;
|
|
3507
|
+
if (parent.type === "Property") return child(parent, "value") === node || parent.computed === true;
|
|
3508
|
+
return true;
|
|
3509
|
+
}
|
|
3510
|
+
/**
|
|
3511
|
+
* CONVENTIONS G4 — a module test touches no DOM.
|
|
3512
|
+
*
|
|
3513
|
+
* A module test runs under node, where `document` does not exist: a test that
|
|
3514
|
+
* reaches for one is not testing a module, it is rendering something, and the
|
|
3515
|
+
* place for that is a `.test.tsx` beside the component.
|
|
3516
|
+
*
|
|
3517
|
+
* Reach is the `module` role OUTSIDE `specs/`: a `.test.tsx` is the rendered
|
|
3518
|
+
* kind and lives in a page, where those globals are what it is there to use;
|
|
3519
|
+
* and under `specs/` an api or cli spec is a `.test.ts` too until the `spec`
|
|
3520
|
+
* role exists, so the guard stops at the tree it can judge.
|
|
3521
|
+
*
|
|
3522
|
+
* A name in a TYPE position (`x as HTMLElement`) and a `typeof window` guard
|
|
3523
|
+
* name no document: neither reaches one, and a module that must run in both
|
|
3524
|
+
* runtimes is tested by exactly that guard.
|
|
3525
|
+
*/
|
|
3526
|
+
const g4NoDomInModuleTest = {
|
|
3527
|
+
create(context) {
|
|
3528
|
+
const identity = roleOf(context.physicalFilename);
|
|
3529
|
+
if (identity.role !== "module" || identity.inSpecs) return {};
|
|
3530
|
+
let declared = /* @__PURE__ */ new Set();
|
|
3531
|
+
return {
|
|
3532
|
+
Identifier(node) {
|
|
3533
|
+
const name = identifierName(node);
|
|
3534
|
+
if (name === void 0 || !DOM_GLOBALS.has(name) || declared.has(name) || !isValueReference(node)) return;
|
|
3535
|
+
context.report({
|
|
3536
|
+
data: { name },
|
|
3537
|
+
messageId: "domGlobal",
|
|
3538
|
+
node
|
|
3539
|
+
});
|
|
3540
|
+
},
|
|
3541
|
+
Program(node) {
|
|
3542
|
+
declared = declaredNames(node);
|
|
3543
|
+
}
|
|
3544
|
+
};
|
|
3545
|
+
},
|
|
3546
|
+
meta: {
|
|
3547
|
+
docs: RULE_DOCS["g4-no-dom-in-module-test"],
|
|
3548
|
+
messages: { domGlobal: "`{{name}}` does not exist where a module test runs — a rendered thing is a `.test.tsx` beside its component, collected by the `component()` project (G4 — docs/13-linting.md#g4-no-dom-in-module-test)." },
|
|
3549
|
+
type: "problem"
|
|
3550
|
+
}
|
|
3551
|
+
};
|
|
3552
|
+
//#endregion
|
|
3117
3553
|
//#region src/lint/rules/i1-layer-boundaries.ts
|
|
3118
3554
|
const TEST_OR_FIXTURES = /\.(?:test|fixtures)\.[cm]?[jt]sx?$/u;
|
|
3119
3555
|
function matchesPackage(source, packages) {
|
|
@@ -3246,21 +3682,25 @@ const i1LayerBoundaries = {
|
|
|
3246
3682
|
//#region src/lint/rules/i2-sibling-test-naming.ts
|
|
3247
3683
|
const TEST_FILE$1 = /\.test\.[cm]?[jt]sx?$/u;
|
|
3248
3684
|
/**
|
|
3249
|
-
* CONVENTIONS I2 —
|
|
3250
|
-
* `<file>.test.ts` next to it (Go's `foo_test.go` parity).
|
|
3251
|
-
*
|
|
3685
|
+
* CONVENTIONS I2 — a unit's test is its NEIGHBOUR: the test of `<file>.ts` is
|
|
3686
|
+
* `<file>.test.ts` next to it (Go's `foo_test.go` parity). A rendered unit
|
|
3687
|
+
* answers to the same law wherever it lives: a `.test.tsx` sits beside the
|
|
3688
|
+
* `.tsx` it renders, or the `.ts` of the hook or DOM function it hosts. Flags,
|
|
3689
|
+
* on visited files:
|
|
3252
3690
|
*
|
|
3253
3691
|
* - a `src/**` test whose neighbour module `<file>.ts` does not exist;
|
|
3692
|
+
* - a `component`-role test (`*.test.tsx`) with no neighbour of any tree;
|
|
3254
3693
|
* - any file living in a `__tests__/` directory under `src/`;
|
|
3255
3694
|
* - any file under a root-level `tests/` directory (the only test roots are
|
|
3256
|
-
* sibling
|
|
3695
|
+
* sibling tests under `src/` and product specifications in `specs/`).
|
|
3257
3696
|
*/
|
|
3258
3697
|
const i2SiblingTestNaming = {
|
|
3259
3698
|
create(context) {
|
|
3260
3699
|
const file = context.physicalFilename;
|
|
3261
3700
|
const parts = segments(file);
|
|
3262
3701
|
return { Program(node) {
|
|
3263
|
-
|
|
3702
|
+
const rendered = roleOf(file).role === "component";
|
|
3703
|
+
if (parts.includes("src") || rendered) {
|
|
3264
3704
|
if (parts.includes("__tests__")) context.report({
|
|
3265
3705
|
messageId: "testsDir",
|
|
3266
3706
|
node
|
|
@@ -3272,8 +3712,8 @@ const i2SiblingTestNaming = {
|
|
|
3272
3712
|
".js"
|
|
3273
3713
|
].map((extension) => file.replace(TEST_FILE$1, extension));
|
|
3274
3714
|
if (!neighbours.some(isFile)) context.report({
|
|
3275
|
-
data: { expected: neighbours[0]?.split(/[/\\]/u).at(-1) ?? "" },
|
|
3276
|
-
messageId: "orphanTest",
|
|
3715
|
+
data: { expected: neighbours[rendered ? 1 : 0]?.split(/[/\\]/u).at(-1) ?? "" },
|
|
3716
|
+
messageId: rendered ? "orphanComponent" : "orphanTest",
|
|
3277
3717
|
node
|
|
3278
3718
|
});
|
|
3279
3719
|
}
|
|
@@ -3290,6 +3730,7 @@ const i2SiblingTestNaming = {
|
|
|
3290
3730
|
meta: {
|
|
3291
3731
|
docs: RULE_DOCS["i2-sibling-test-naming"],
|
|
3292
3732
|
messages: {
|
|
3733
|
+
orphanComponent: "A component test must sit NEXT to what it renders — no neighbour \"{{expected}}\" found (I2 — see docs/13-linting.md). A hook or a DOM function is the `.ts` of the same basename, with its Host in the test file.",
|
|
3293
3734
|
orphanTest: "A src/ module test must sit NEXT to the module it tests — no neighbour \"{{expected}}\" found (I2 — see docs/13-linting.md). A test needing more than its module is a specification: move it to specs/.",
|
|
3294
3735
|
rootTests: "A root-level tests/ directory is banned — module tests are siblings under src/, product specifications live in specs/ (I2 — see docs/13-linting.md).",
|
|
3295
3736
|
testsDir: "__tests__/ directories are banned under src/ — the test of <file>.ts is its neighbour <file>.test.ts (I2 — see docs/13-linting.md)."
|
|
@@ -3532,11 +3973,16 @@ const plugin = {
|
|
|
3532
3973
|
"d12w-response-body-probe": d12wResponseBodyProbe,
|
|
3533
3974
|
"d13w-unfrozen-negative-fixture": d13wUnfrozenNegativeFixture,
|
|
3534
3975
|
"d15w-status-only-probe": d15wStatusOnlyProbe,
|
|
3976
|
+
"e5-no-simulated-dom": e5NoSimulatedDom,
|
|
3977
|
+
"e5b-no-simulated-dom-config": e5bNoSimulatedDomConfig,
|
|
3978
|
+
"e6-component-project-helper": e6ComponentProjectHelper,
|
|
3535
3979
|
"f1-no-subpath-import": f1NoSubpathImport,
|
|
3536
3980
|
"f2-no-test-imports-in-prod": f2NoTestImportsInProd,
|
|
3537
3981
|
"f3-specs-public-entry": f3SpecsPublicEntry,
|
|
3538
3982
|
"f4-no-test-to-test-import": f4NoTestToTestImport,
|
|
3539
3983
|
"f5-fixtures-only-from-tests": f5FixturesOnlyFromTests,
|
|
3984
|
+
"f6-no-foreign-test-runtime": f6NoForeignTestRuntime,
|
|
3985
|
+
"g4-no-dom-in-module-test": g4NoDomInModuleTest,
|
|
3540
3986
|
"i1-layer-boundaries": i1LayerBoundaries,
|
|
3541
3987
|
"i2-sibling-test-naming": i2SiblingTestNaming,
|
|
3542
3988
|
"i4-no-vi-mock-in-src": i4NoViMockInSrc,
|