docs(concept): solder la doc-debt des 6 concepts

Dette accumulée depuis le 13/07 (27 marqueurs). Au-delà du vidage, trois
corrections de doctrine réellement fausse — c'est ce que le reconcile devait
attraper :

- app-security : `sharedWallet.ts` capture le mot de passe à l'ÉVALUATION du
  module. Tant qu'un repli existait, un global posé trop tard ne faisait que
  dégrader ; depuis que le wallet partagé est l'unique mode, il rend la barrière
  INUTILISABLE (écran d'erreur, aucun champ). Conséquence non anticipée de la
  décision shared-wallet-only → nouveau caveat.
- bdd-testing : la doctrine rendait des tests faux-verts. `ctx.newPage()` sur le
  profil persistant relit l'IndexedDB local et ne prouve JAMAIS la durabilité
  broker ; seul un contexte partagé neuf tranche. Un agent suivant la doctrine
  écrivait un test qui passe sans rien vérifier → nouveau caveat.
- app-architecture : `knowledge_routing` décrivait encore une route `/login`
  disparue, et `knowledge_screen-pattern` citait `LoginScreen` qui n'existe
  plus. Nouveau caveat sur les deux espaces d'id vus depuis un écran.

Aussi : data-layer/knowledge_context-internals décrit la jointure
participation→profil et corrige un mécanisme de changement d'identité périmé ;
tech-stack raccroche la table des scripts au vrai point d'entrée cucumber ;
functional-domain note qu'« implémenté » ≠ « durable ».

Trois marqueurs soldés comme sans objet : ils visaient
`reconnexion-socket-mort.{feature,steps.ts}`, absents de l'arbre ET de tout
l'historique — expérience abandonnée avant tout commit. Ce qu'elle devait
établir est capturé ailleurs (caveat de durabilité, post-mortem polyfill, fiche
INBOX socket-death).

Liens morts vers une décision disparue avec le concept `nextgraph-platform`
réparés. Reste au lint : le brief 07-06 (superseded) porte des file:line et des
références aux internes NextGraph — laissé intact, il décrit l'Option-B encore
implémentée et se dissoudra à la graduation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
This commit is contained in:
Sylvain Duchesne
2026-07-27 14:43:09 +02:00
parent a8401bd143
commit 96e28a702f
28 changed files with 306 additions and 105 deletions
-20
View File
@@ -1,20 +0,0 @@
# Doc-debt — bdd-testing
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/modules/event/features/reconnexion-persistance-e2e.feature @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/e2e/reconnexion-persistance.steps.ts @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/test-harness/harness-ng.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/test-harness/harness.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-socket-mort.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/reconnexion-socket-mort.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-meme-identite.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/reconnexion.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-froide-sans-local.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/reconnexion-froide-sans-local.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/auth/steps/ui/barriere-acces.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/support/hooks.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/isolation.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/auth/steps/data/connexion.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
@@ -31,6 +31,7 @@ Tests BDD **Cucumber/Gherkin en français** (`Etant donné`, `Quand`, `Alors`) s
- [[knowledge_data-layer-broker]] — couche `@data` : harness broker, cycle de vie wallet, bridge
- [[knowledge_e2e-layer]] — couche `@e2e` : app réelle dans l'iframe
- [[knowledge_multibrowser-harness]] — plusieurs navigateurs isolés × modèle de wallet (private/shared), injection storageState
- [[caveat_reconnexion-froide-local-vs-broker]] — « page fraîche » ≠ démarrage à froid : quel montage prouve la durabilité broker, et lequel relit le local
- [[decision_2026-03-12_headless-wallet-creation]] — pourquoi le wallet de test est créé en UI headless
- [[caveat_source-grep-vestiges]] — vestiges de l'ère « analyse de source » dans `world.ts`
- [[cookbook_add-scenario]] — ajouter un scénario/step (couches, piège de sérialisation `evaluate`, `@wip`)
@@ -0,0 +1,85 @@
---
type: caveat
summary: Une « page fraîche » ouverte via ctx.newPage() sur le contexte Chromium PERSISTANT ne prouve JAMAIS la durabilité broker — elle relit l'IndexedDB local du même profil. Seul un contexte non-persistant issu de freshBrowser, amorcé uniquement par le storageState capturé au BeforeAll, tranche broker-vs-local.
last_checked: 2026-07-27
---
# Piège : « page fraîche » ≠ démarrage à froid (local vs broker)
Les scénarios de **reconnexion** posent tous la même question — *l'utilisateur retrouve-t-il ses
propres données après avoir fermé et rouvert ?* — mais **selon le contexte navigateur choisi, ils
ne répondent pas à la même question**. C'est le piège : le montage le plus naturel (`ctx.newPage()`)
donne un vert qui ne prouve rien sur le broker.
## Les deux montages, et ce que chacun prouve
| Montage | Où | Ce qu'il prouve | Ce qu'il ne prouve PAS |
|---|---|---|---|
| `this.page!.context().newPage()` — page fraîche sur le contexte **persistant** (`.playwright-profile`) | `reconnexion.steps.ts` (@data), `reconnexion-persistance.steps.ts` (@e2e) | nouveau login broker → **session verifier fraîche** (mémoire vide), remontage complet des providers | rien sur la **durabilité broker** : le profil détient **encore les repos locaux** en IndexedDB, un lecteur « frais » peut donc rouvrir **depuis le local** |
| `spawnContext('shared')` — contexte **non-persistant** issu de `freshBrowser` | `reconnexion-froide-sans-local.steps.ts` (@data) | la donnée **a atteint le broker** (ou non) | rien sur le parcours UI réel (c'est le harness, pas l'app) |
**Invariant.** Toute assertion de la forme « l'écriture est durable côté broker » **exige** le second
montage. Écrire cette assertion sur une page fraîche du contexte persistant produit un test
faussement vert (ou un rouge qu'on impute au broker alors qu'il est local/timing).
## Ce qui rend le verdict « no-local » valide (à ne pas casser)
Trois conditions, toutes vérifiées dans `reconnexion-froide-sans-local.steps.ts` :
1. **Process séparé**`freshBrowser` est un `chromium.launch` non-persistant, distinct du profil
porteur du wallet (cf. [[knowledge_multibrowser-harness]] pour l'isolation prouvée jusqu'à
l'origine broker).
2. **Partition hermétique** — chaque `newContext()` Playwright a son propre stockage ; aucun
IndexedDB partagé avec la page d'écriture.
3. **Le seul état pré-injecté est `pool.sharedWalletState`**, capturé **une fois au `BeforeAll`**,
donc **avant** que le scénario n'écrive quoi que ce soit → le snapshot **ne peut pas** contenir la
donnée sous test.
> **Impact si on touche à la capture du storageState** (`hooks.ts` `BeforeAll` → `pool.sharedWalletState`) :
> la déplacer plus tard, la ré-capturer par scénario, ou y ajouter un warm-up qui écrit des données
> **invalide silencieusement** le verdict de tous les scénarios « à froid sans local » — ils
> passeraient au vert en relisant le snapshot. Le step **échoue franchement** si
> `sharedWalletState` est absent (c'est voulu : pas de verdict plutôt qu'un faux verdict).
## Reconnexion ≠ isolation : c'est l'identifiant qui décide
`isolation.steps.ts` et `reconnexion.steps.ts` montent **la même mécanique** (page fraîche + un
identifiant injecté dans `localStorage['festipod.account.identifier']` via `addInitScript`, avant
tout script, sur toutes les origines). Une seule chose les sépare :
- **reconnexion** : on réinjecte `this.freshIdentifier`**la MÊME identité** que la page d'écriture.
- **isolation** : on frappe un **nouvel** identifiant → identité B distincte.
Changer cet identifiant transforme donc silencieusement un test de reconnexion en test d'isolation
(et réciproquement). `this.freshIdentifier` est posé par le `Before` de `hooks.ts` pour **tout**
scénario `@data`/`@e2e` mono-navigateur.
## Lecture : réactive, même quand on « attend longtemps »
Les `Then` de reconnexion lisent l'état **réactif** (`homeEventTitles` sur le bridge, via
`waitForFunction`) — jamais une boucle de re-lecture broker ([[rule_no-broker-polling]]). Le step de
diagnostic long (« … en laissant jusqu'à 60 secondes à la barrière avec rechargements ») boucle bien,
mais sur **l'état réactif déjà poussé** + des **rechargements complets** de la page (chaque reload =
nouveau montage = nouvelle tentative de barrière de sync) : c'est le fallback pragmatique explicitement
autorisé par la règle, pas du polling broker. Le distinguo à garder : *observer l'état réactif*
*ré-émettre une lecture broker*.
## État courant des scénarios
`reconnexion-froide-sans-local.feature`, le scénario `@reconnexion-pause` de
`reconnexion-meme-identite.feature` et `reconnexion-persistance-e2e.feature` sont **`@wip`** : ce sont
des **instruments de diagnostic** (ils impriment un verdict sur stdout / en pièce jointe Cucumber),
pas des gardes de régression. `@wip` est exclu du run par défaut (`cucumber.json`) — les lancer
explicitement avec `--tags`. Le scénario **non-`@wip`** de `reconnexion-meme-identite.feature`, lui,
est une vraie garde et doit rester vert.
> Le *pourquoi* côté NextGraph (ce qu'une écriture doit franchir pour être durable, comportement du
> socket, réouverture des repos) appartient au SDK `@ng-eventually/client` — pas à ce repo. Ici on ne
> décrit que **le montage de test qui rend un verdict lisible**.
## Liens
- [[knowledge_data-layer-broker]] — la couche `@data` mono-navigateur (profil persistant).
- [[knowledge_multibrowser-harness]] — `freshBrowser`, `spawnContext`, `pool.sharedWalletState`.
- [[knowledge_e2e-layer]] — le pendant `@e2e` (app réelle) du montage « fermer et rouvrir ».
- [[rule_no-broker-polling]] — la ligne rouge que les steps d'attente ne doivent pas franchir.
@@ -8,7 +8,7 @@ last_checked: 2026-07-06
Le profil Chromium persistant `.playwright-profile` (racine du working tree) porte le **wallet
partagé** ouvert par toute la suite `@data`/`@e2e`. Ce wallet **accumule des données à chaque
run** : comptes shim (un par scénario, via l'identifiant frais `freshScenarioUsername`), docs
run** : comptes shim (un par scénario, via l'identifiant frais `freshScenarioIdentifier`), docs
d'entités seedés, dépôts d'inbox historiques… Le private store est le **point d'ancrage du shim**
(résolution de compte) et est interrogé par **toute** lecture/écriture (`resolveAccount`,
`listMyEntityDocs`, …).
@@ -26,7 +26,7 @@ recréer un frais :
mv .playwright-profile /tmp/festipod-bloated-$(date +%s)
```
L'identifiant frais par scénario (`freshScenarioUsername`) borne le *registre* des comptes mais
L'identifiant frais par scénario (`freshScenarioIdentifier`) borne le *registre* des comptes mais
**pas** la croissance physique du private store partagé — d'où la récurrence. Une hygiène durable
(purge périodique / wallet jetable par run) reste à mettre en place ; en attendant, si les
`resolveAccount failed`/timeouts réapparaissent, repartir d'un profil frais.
@@ -27,7 +27,7 @@ Tags de scénario : `@ui` / `@data` / `@e2e` (couche) + **`@wip`** pour un scén
## Config
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (exclut les scénarios WIP) ; `language: fr`. **Runner = Node + tsx** (`node --import tsx/esm node_modules/.bin/cucumber-js`), pas Bun — les plugins (Playwright, happy-dom) ne chargent pas en import Bun natif. Ne pas « bunifier » `cucumber:run`/`test:data`.
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (exclut les scénarios WIP) ; `language: fr`. **Runner = Node + tsx**, pas Bun — les plugins (Playwright, happy-dom) ne chargent pas en import Bun natif. Ne pas « bunifier » `cucumber:run`/`test:data`. ⚠️ Le runner doit pointer sur l'**entrée JS réelle du paquet** (`node_modules/@cucumber/cucumber/bin/cucumber.js`), **jamais** sur `node_modules/.bin/cucumber-js` : selon l'installeur, `.bin/` contient un **shim shell** (pas du JS) que `node --import tsx/esm` ne peut pas exécuter.
## Le harness de test est buildé à la demande
@@ -1,7 +1,7 @@
---
type: knowledge
summary: Couche @data — Playwright pilote Chromium (profil persistant) qui s'authentifie au broker NextGraph réel chargeant harness-ng.tsx en iframe ; cycle de vie wallet automatisé (création + login bootstrap), bridge window.__testData, fallback mock
last_checked: 2026-07-05
summary: Couche @data — Playwright pilote Chromium (profil persistant) qui s'authentifie au broker NextGraph réel chargeant harness-ng.tsx en iframe ; cycle de vie wallet automatisé (création + login bootstrap), bridge window.__testData, fallback mock ; isolation par identifiant virtuel frais (this.freshIdentifier), plus de purge par scénario
last_checked: 2026-07-27
---
# Couche `@data` (broker réel)
@@ -50,17 +50,22 @@ Cucumber → Playwright (Chromium, profil persistant)
`ensureCurrentUser()` avant `joinEvent` (sinon participation écrite sans user → jetée en
lecture, ne fait jamais l'aller-retour) et attendent (`waitForFunction`) que la participation
soit relue.
- **Caveat wallet persistant + isolation par scénario (T03.j)** : le wallet partagé **accumule**
le registre de comptes émulé et les docs per-entité à chaque scénario/run. Le fan-out de lecture
(`listEntityDocs` = `allAccounts()` → 1 SELECT/compte) parcourt tous les docs de tous les comptes
→ ralentit et fait *timeouter* les steps quand le wallet est pollué. Ce registre vit **côté
broker** : supprimer `.playwright-profile/` ne le nettoie PAS (re-sync depuis le broker) et force
une re-auth lente — mauvais levier. À la place, le `Before` @data appelle
`window.__testData.resetDataState()` : **UN** SPARQL DELETE sur le graphe ancre (private-store)
qui efface tous les records `urn:ng-eventually:shim:Account` `allAccounts()` s'effondre à vide →
le fan-out se **borne** à ce que le scénario courant reprovisionne (comptes recréés paresseusement
par `ensureAccount`). O(1) sur UN graphe — **pas** un delete en fan-out (qui saturait le navigateur,
cf. T03.i `authClearParticipation` retiré). Borné à ≤10s (`Promise.race`) pour ne pas disputer le
budget 60s du `Before` (login broker déjà lent). Infra de test uniquement — ne touche ni la lib ni
le modèle produit ni le chemin de lecture applicatif. Le seed connecté reste **allégé** (peu de
docs) car chaque `docCreate` est un aller-retour broker sériel ~2s.
- **Isolation par scénario = identifiant virtuel frais, PAS de purge.** Le `Before` @data mint un
identifiant unique par scénario (`freshScenarioIdentifier` dans `hooks.ts`), l'expose en
`this.freshIdentifier` sur le World, et l'injecte par `addInitScript` dans
`localStorage['festipod.account.identifier']` **sur toutes les origines** (y compris l'iframe
harness sur 127.0.0.1) — avant tout script. Le shim sert alors un **compte virtuel frais et vide**,
dont le registre part vide *par construction* : **rien à purger**. L'ancien reset par scénario
(`window.__testData.resetDataState()`, un SPARQL DELETE des records
`urn:ng-eventually:shim:Account` sur le graphe ancre) **n'est plus appelé** — il coûtait jusqu'à
10 s prélevés sur le budget 60 s du `Before`, déjà mangé par le login broker. Le helper existe
encore sur le bridge (`harness-ng.tsx`) mais n'est plus dans le chemin par défaut : ne pas le
remettre dans le `Before` sans mesurer.
- **Ce que l'identifiant frais NE borne PAS** : la croissance *physique* du wallet partagé — voir
[[caveat_wallet-bloat-hang]] (profil à mettre de côté quand les lectures ancrées se mettent à
*hang*).
- `this.freshIdentifier` est aussi ce qui distingue un test de **reconnexion** (même identifiant
réinjecté) d'un test d'**isolation** (nouvel identifiant) — cf.
[[caveat_reconnexion-froide-local-vs-broker]].
- Le seed connecté reste **allégé** (peu de docs) car chaque `docCreate` est un aller-retour broker
sériel ~2s.
@@ -1,6 +1,7 @@
---
type: knowledge
summary: Couche @e2e — Playwright boote l'app RÉELLE (pas un harness) dans l'iframe broker, interagit via appFrame.evaluate()/locator(), réutilise setupBrokerPage() de @data ; teste navigation/redirects/clics, pas de fallback mock
summary: Couche @e2e — Playwright boote l'app RÉELLE (pas un harness) dans l'iframe broker, interagit via appFrame.evaluate()/locator(), réutilise setupBrokerPage() de @data ; teste navigation/redirects/clics, pas de fallback mock ; identité par scénario (this.freshIdentifier) + barrière d'accès désactivée par init script ; idiome « fermer et rouvrir » pour les scénarios de reconnexion
last_checked: 2026-07-27
---
# Couche `@e2e` (app réelle)
@@ -42,6 +43,45 @@ Navigation : `window.history.pushState` + dispatch `popstate` (routing path-base
> **Ne pas re-vérifier en `@e2e` ce que `@ui` couvre déjà** — `@e2e` doit casser quand la *collaboration* entre couches casse, pas quand une icône change (cf. [[rule_test-layer-contracts]]).
## Identité du scénario + barrière d'accès
Deux réglages posés par le `Before` de `hooks.ts` conditionnent **tout** scénario `@e2e` :
- **`this.freshIdentifier`** — un identifiant virtuel **unique par scénario**, injecté par
`addInitScript` dans `localStorage['festipod.account.identifier']` sur **toutes** les origines
avant tout script. L'app réelle boote donc directement sur cette identité, et chaque scénario part
d'un espace vide. C'est la **même** mécanique qu'en `@data` (même champ du World).
- **Barrière d'accès désactivée** — `browserContext.addInitScript` pose
`globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` sur le contexte **persistant** : `@e2e` voit
l'app, pas l'`AccessGateScreen`. Les contextes **frais** (`@humain`, cf.
[[knowledge_multibrowser-harness]]) n'héritent pas de ce réglage → barrière ON chez eux.
> **Impact :** toute page ouverte à la main dans un step (`ctx.newPage()`) doit **re-poser les deux
> init scripts elle-même** — `addInitScript` du contexte ne s'applique qu'aux pages du contexte, et
> l'identifiant doit être écrit **avant** le premier script de l'app.
## Idiome « fermer et rouvrir » (scénarios de reconnexion)
`reconnexion-persistance-e2e.feature` / `src/modules/event/steps/e2e/reconnexion-persistance.steps.ts`
reproduisent, dans la VRAIE app, le parcours « je crée, je ferme, je reviens » :
1. **Création par le vrai formulaire** — le step pilote l'assistant de création réel au DOM
(assistant en 3 étapes, sélection par *placeholder* : nom de l'événement, lieu ; bouton de
soumission par son libellé). ⚠️ **Ces steps sont couplés aux libellés FR de l'écran de création** :
renommer un placeholder ou le bouton de soumission casse le scénario, pas l'app.
2. **Réouverture** — seconde page sur le **même** contexte persistant, avec la **même**
`this.freshIdentifier` + la barrière désactivée, puis `pool.setupBrokerPage(page, pool.appUrl!)`
→ nouveau login broker, session verifier fraîche.
3. **Preuve** — le step capture la console des **deux** pages et publie un résumé via `this.attach`
(pièce jointe Cucumber) + stdout ; un dump brut des lignes de connexion/sync est **opt-in** par
la variable d'environnement `RECO_RAW_DUMP=1` (bruyant, coupé par défaut).
> **Limite à connaître** : ce montage prouve la reconnexion *du parcours*, **pas** la durabilité
> broker de l'écriture — la seconde page partage l'IndexedDB du profil persistant. Voir
> [[caveat_reconnexion-froide-local-vs-broker]] pour le montage qui, lui, tranche broker-vs-local.
Le scénario est **`@wip`** (instrument de diagnostic, exclu du run par défaut).
## Smoke `@smoke` — garde la classe « page blanche une fois connecté »
`@e2e @smoke` (`src/modules/home/features/accueil-connecte-rend.feature`) garde une
@@ -1,6 +1,7 @@
---
type: knowledge
summary: Couche @ui — renderHelper.tsx rend tout écran dans LocalDataProvider + happy-dom, world.renderCurrentScreen() l'invoque à chaque navigateTo, assertions sur le DOM rendu avec les fixtures de seed déterministes
summary: Couche @ui — renderHelper.tsx rend tout écran dans LocalDataProvider + happy-dom, world.renderCurrentScreen() l'invoque à chaque navigateTo, assertions sur le DOM rendu avec les fixtures de seed déterministes ; piège des écrans qui lisent un global injecté au build (barrière d'accès → import lazy obligatoire)
last_checked: 2026-07-27
---
# Couche `@ui`
@@ -30,4 +31,27 @@ expect(labels.some(t => t.includes("Nom de l'événement *"))).to.be.true;
- `currentScreenId: string | null` — l'écran courant.
- Helpers d'assertion : `getDomText()` (texte du DOM), `hasText(t)`, `hasField(name)`, `hasElement(selector)` — ils **préfèrent le DOM rendu** mais **retombent sur la source** des écrans pour les steps non migrés (vestige, voir [[caveat_source-grep-vestiges]]).
## ⚠️ Écrans qui lisent un global injecté au **build** (barrière d'accès)
`src/modules/auth/sharedWallet.ts` **capture au moment de l'évaluation du module** un global posé par
`build.ts` (`__FESTIPOD_SHARED_WALLET_PASSWORD__`). Le harness `@ui` tourne sous Node **sans passer
par le build** → ce global est absent, `hasSharedWallet()` retourne faux, et comme le **wallet
partagé est le seul mode supporté** (concept `app-security`), `AccessGateScreen` rend sa branche
**erreur de configuration** : **aucun champ identifiant** dans le DOM → tous les steps de la barrière
échouent avec un message trompeur (« champ introuvable »).
**Le montage obligatoire** (appliqué dans `src/modules/auth/steps/ui/barriere-acces.steps.ts`) :
1. poser le global **en tête du module de steps**, avant tout import de l'écran ;
2. **importer l'écran paresseusement** (`await import(...)` mémoïsé) — un `import` statique serait
**hissé au-dessus** de l'affectation et `sharedWallet.ts` capturerait une valeur vide.
> **Impacts si tu touches à ça :**
> - Ajouter un `import` statique de `AccessGateScreen` (ou de tout module qui atteint
> `sharedWallet.ts`) dans **n'importe quel** fichier de steps `@ui` ré-introduit le bug — Cucumber
> charge tous les modules de steps, l'écran serait évalué avant que le global soit posé.
> - Le déterminisme actuel repose sur le fait que **ce fichier est le seul** module `@ui` à atteindre
> `sharedWallet.ts`. Un second point d'entrée rendrait l'ordre d'évaluation non garanti → il
> faudrait alors déplacer l'injection du global dans le support partagé, pas la dupliquer.
> Les classes `app-*` confirment le thème moderne (cf. `app-architecture`). Les anti-patterns (regex sur source, détails d'implémentation) sont proscrits par [[rule_test-layer-contracts]]. Pour écrire un nouveau scénario, voir [[cookbook_add-scenario]].