@nodefony/devkit 10.0.0-beta.5 → 10.0.0-beta.6
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/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorateParam.js +1 -1
- package/dist/index.js +3 -3
- package/dist/nodefony/controllers/DevkitController.js +1 -1
- package/dist/nodefony/controllers/McpController.js +2 -2
- package/dist/nodefony/controllers/ProfileSessionController.js +1 -1
- package/dist/nodefony/mcp/profileRoute.js +28 -9
- package/dist/nodefony/service/DevkitService.js +4 -4
- package/docs/index.md +1 -1
- package/package.json +11 -11
- package/skills/nodefony-add-crud/SKILL.md +38 -23
- package/skills/nodefony-add-realtime-channel/SKILL.md +376 -111
- package/skills/nodefony-add-realtime-channel/references/binaire.md +191 -0
- package/skills/nodefony-add-realtime-channel/references/production.md +121 -0
- package/skills/nodefony-browser/SKILL.md +27 -2
- package/skills/nodefony-browser/scripts/audit.mjs +12 -0
- package/skills/nodefony-browser/scripts/inspect.mjs +40 -81
- package/skills/nodefony-browser/scripts/lib/actions.mjs +113 -0
- package/skills/nodefony-browser/scripts/lib/browser.mjs +50 -11
- package/skills/nodefony-browser/scripts/lib/probes.mjs +56 -0
- package/skills/nodefony-browser/scripts/watch.mjs +27 -11
- package/skills/nodefony-dev/SKILL.md +11 -0
- package/skills/nodefony-dev/references/rapport-de-build.md +19 -0
- package/skills/nodefony-devops/SKILL.md +28 -21
- package/skills/nodefony-migrate-schema/SKILL.md +110 -20
- package/skills/nodefony-migrate-schema/references/verdicts.md +22 -20
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# Des octets dans le temps réel — mode d'emploi complet
|
|
2
|
+
|
|
3
|
+
> Chargé depuis `SKILL.md` dès qu'une charge contient des octets (`Uint8Array`, `Buffer`, CBOR,
|
|
4
|
+
> `$bytes`). Tout ce qui suit se vérifie dans la documentation installée :
|
|
5
|
+
> `node_modules/@nodefony/realtime/docs/protocole.md` (section « Des octets dans la charge »),
|
|
6
|
+
> `node_modules/@nodefony/realtime/docs/configuration.md` (clé `codec`),
|
|
7
|
+
> `node_modules/nodefony/docs/client.md` (« Choisir le codec de trame »),
|
|
8
|
+
> `node_modules/nodefony/docs/cbor.md` (le codec lui-même).
|
|
9
|
+
|
|
10
|
+
## Sommaire
|
|
11
|
+
|
|
12
|
+
1. [Le modèle](#1-le-modèle--une-frame-comme-les-autres)
|
|
13
|
+
2. [Mise en place](#2-mise-en-place--deux-lignes-une-par-côté)
|
|
14
|
+
3. [Publier et recevoir](#3-publier-et-recevoir)
|
|
15
|
+
4. [Canal ou flux brut ?](#4-canal-ou-flux-brut-)
|
|
16
|
+
5. [Lire des octets sans se tromper](#5-lire-des-octets-sans-se-tromper)
|
|
17
|
+
6. [Plusieurs serveurs](#6-plusieurs-serveurs)
|
|
18
|
+
7. [Chaque message, sa cause, ton geste](#7-chaque-message-sa-cause-ton-geste)
|
|
19
|
+
8. [Ce qui arrive SANS message](#8-ce-qui-arrive-sans-message)
|
|
20
|
+
9. [Prouver que ça marche](#9-prouver-que-ça-marche)
|
|
21
|
+
|
|
22
|
+
## 1. Le modèle — une frame comme les autres
|
|
23
|
+
|
|
24
|
+
- Une trame qui porte des octets est une **notification JSON-RPC ordinaire** dont la charge est, ou
|
|
25
|
+
contient, un `Uint8Array`. Il n'y a pas de seconde grammaire.
|
|
26
|
+
- Le **codec** ne dit que la forme sur le fil : en CBOR, les octets voyagent tels quels ; sur une
|
|
27
|
+
connexion restée en texte, ils voyagent sous la convention `{"$bytes":"<base64url>"}`, que le
|
|
28
|
+
client relit en `Uint8Array` sans rien importer.
|
|
29
|
+
- Le codec se **négocie** par connexion : le navigateur l'offre, le serveur choisit et l'annonce
|
|
30
|
+
dans `realtime:welcome`. Un client qui ne l'offre pas reste en texte — rien ne casse.
|
|
31
|
+
- Parce que c'est la même frame, les octets d'un canal passent par **les mêmes gardes** que le
|
|
32
|
+
texte : politique du canal, rôles, audit, sonde.
|
|
33
|
+
|
|
34
|
+
**N'invente jamais d'en-tête binaire maison** (`[n° de canal][charge]`) : il contournerait toutes
|
|
35
|
+
les gardes et serait à maintenir des deux côtés.
|
|
36
|
+
|
|
37
|
+
## 2. Mise en place — deux lignes, une par côté
|
|
38
|
+
|
|
39
|
+
Serveur (`nodefony.config.ts`) :
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
use("@nodefony/realtime", { codec: "cbor" });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Navigateur — à la **PREMIÈRE** création de la socket partagée (souvent le noyau client) :
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { NodefonySocket } from "nodefony/client";
|
|
49
|
+
import { CBOR_FRAME_CODEC } from "nodefony/cbor";
|
|
50
|
+
|
|
51
|
+
const socket = NodefonySocket.shared({
|
|
52
|
+
url: "/api/live/realtime",
|
|
53
|
+
codec: CBOR_FRAME_CODEC,
|
|
54
|
+
});
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
| Serveur | Navigateur offre CBOR | Fil retenu | Octets reçus en |
|
|
58
|
+
| ------------------------ | --------------------- | ---------- | ---------------------------- |
|
|
59
|
+
| `codec: "cbor"` | oui | CBOR | `Uint8Array` |
|
|
60
|
+
| `codec: "cbor"` | non | texte | `Uint8Array` (via `$bytes`) |
|
|
61
|
+
| `codec: "json"` (défaut) | oui ou non | texte | 🔴 objet déformé `{"0":1,…}` |
|
|
62
|
+
|
|
63
|
+
D'où la règle : **dès que des octets circulent, le serveur passe en `codec: "cbor"`**. Côté
|
|
64
|
+
navigateur, offrir CBOR est un gain (≈ 30× plus rapide à recevoir 4 Ko d'octets, ~25 % d'octets en
|
|
65
|
+
moins), pas une obligation. Ce gain ne vaut QUE pour des octets : sur des objets ordinaires, CBOR
|
|
66
|
+
encode plus lentement que JSON, natif au moteur, et ne décode pas plus vite. N'offre le codec que si
|
|
67
|
+
des octets circulent.
|
|
68
|
+
|
|
69
|
+
## 3. Publier et recevoir
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// serveur — depuis un service, une tâche, un minuteur
|
|
73
|
+
import { getRealtimeHub } from "@nodefony/realtime";
|
|
74
|
+
getRealtimeHub().publish("capteur:raw", {
|
|
75
|
+
seq: 42,
|
|
76
|
+
frame: new Uint8Array([1, 2, 3]),
|
|
77
|
+
});
|
|
78
|
+
getRealtimeHub().publish("capteur:raw", new Uint8Array([1, 2, 3])); // charge = les octets seuls
|
|
79
|
+
|
|
80
|
+
// serveur — dans un RealtimeController, la fabrique du canal reçoit `publish`
|
|
81
|
+
// navigateur
|
|
82
|
+
socket.subscribe("capteur:raw");
|
|
83
|
+
socket.on("capteur:raw", (charge) => {
|
|
84
|
+
/* charge.frame est un Uint8Array */
|
|
85
|
+
});
|
|
86
|
+
socket.publish("doc:42", { maj: new Uint8Array(update) }); // canal entrant déclaré côté serveur
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- `subscribe(canal)` prend **un seul argument** ; le handler se pose par `on(canal, handler)`.
|
|
90
|
+
- Une charge est un objet, un tableau, des octets, ou absente — jamais un scalaire
|
|
91
|
+
(`publish("c", 42)` lève une `TypeError` qui nomme le canal).
|
|
92
|
+
- Un `Buffer` de Node est un `Uint8Array` : il part en octets, jamais en `{type:"Buffer",data:[…]}`.
|
|
93
|
+
- Une `Date` revient en chaîne et un `bigint` est refusé — exactement comme en JSON.
|
|
94
|
+
|
|
95
|
+
## 4. Canal ou flux brut ?
|
|
96
|
+
|
|
97
|
+
| Besoin | Voie |
|
|
98
|
+
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
|
|
99
|
+
| Des octets qui appartiennent à un canal (cas normal) | `codec: "cbor"` + `publish` / `on(canal)` |
|
|
100
|
+
| Un protocole binaire entièrement propre à l'application | `onRealtimeBinary()` (serveur) / `onBinary()` (navigateur), connexion **texte** seulement |
|
|
101
|
+
|
|
102
|
+
🔴 **Le flux brut est HORS des gardes.** Une trame reçue par `onRealtimeBinary(data)` ne passe ni
|
|
103
|
+
par le verrou de frame ni par les politiques d'action et de canal. Seule la connexion a franchi
|
|
104
|
+
l'Origin, l'authentificateur et le pare-feu. Si tu l'emploies, **toute décision d'accès est à
|
|
105
|
+
toi** : lis l'identité par `RequestContext.getAuthenticatedUser()` et refuse explicitement.
|
|
106
|
+
|
|
107
|
+
🔴 **Le flux brut n'existe pas sur une connexion CBOR** : toute trame binaire y est une frame du
|
|
108
|
+
codec. `onRealtimeBinary()` n'y est jamais appelée, `sendBinaryToClient()` et `sendBinary()` rendent
|
|
109
|
+
`false`. Ne mélange pas les deux voies sur une même application sans le savoir.
|
|
110
|
+
|
|
111
|
+
Dans le doute, **préfère un canal + `Uint8Array`** (§3) : même politique, mêmes gardes, même bus
|
|
112
|
+
entre serveurs — le flux brut ne se justifie que pour un protocole binaire qui n'a pas de canal.
|
|
113
|
+
|
|
114
|
+
## 5. Lire des octets sans se tromper
|
|
115
|
+
|
|
116
|
+
- **`DataView` : passe la tranche entière.** `new DataView(data.buffer, data.byteOffset,
|
|
117
|
+
data.byteLength)`. Un `Buffer` de Node ne commence pas à 0 dans son tampon : `new
|
|
118
|
+
DataView(data.buffer)` lit des octets qui ne sont pas les tiens.
|
|
119
|
+
- Côté serveur, `onRealtimeBinary` reçoit un `Buffer` de `ws` typé `Uint8Array` : écris ton
|
|
120
|
+
décodeur sur `Uint8Array`, il servira tel quel dans le navigateur.
|
|
121
|
+
- Si tu appelles toi-même `decodeCbor()` sur un tampon que tu réutilises ensuite, **copie** les
|
|
122
|
+
octets décodés à garder (`slice()`) : ce sont des vues sur ce tampon.
|
|
123
|
+
|
|
124
|
+
### 🔴 Le piège du `$bytes` forgé
|
|
125
|
+
|
|
126
|
+
Quand le serveur a `codec: "cbor"` et qu'une connexion est restée en texte (la convention d'octets
|
|
127
|
+
y est armée), un objet de la forme **exacte** `{ $bytes: "<chaîne>" }` (seule clé, valeur chaîne)
|
|
128
|
+
est réservé aux octets. L'encodeur **refuse** d'en envoyer un qui ne vient pas
|
|
129
|
+
d'octets : un client texte le relirait en octets, un client CBOR en objet.
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
socket.publish("prix", { montant: { $bytes: "12 euros" } }); // 🔴 TypeError
|
|
133
|
+
socket.publish("prix", { montant: { $bytes: "12 euros", devise: "EUR" } }); // ✅ deux clés
|
|
134
|
+
socket.publish("prix", { montant: { raw: "12 euros" } }); // ✅ autre clé
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- Navigateur : `emit` / `publish` **lèvent** à la ligne fautive.
|
|
138
|
+
- Serveur : la publication n'atteint pas les abonnés texte, une ligne `ERROR` par publication.
|
|
139
|
+
- Un tel objet **conservé** (état initial renvoyé à chaque nouvel abonné) est refusé **à chaque
|
|
140
|
+
envoi**. Renomme la clé une fois pour toutes. Ne contourne pas en encodant toi-même en base64.
|
|
141
|
+
- Avec `codec: "json"`, `$bytes` est une clé comme une autre : la règle ne s'applique pas.
|
|
142
|
+
|
|
143
|
+
## 6. Plusieurs serveurs
|
|
144
|
+
|
|
145
|
+
Les octets traversent le bus entre exemplaires (`redis`, `cluster`) **intacts**, scellés comme le
|
|
146
|
+
texte si `backplane.secret` est posé : rien à configurer de plus. Un canal suivi à la fois par des
|
|
147
|
+
connexions CBOR et texte paie un encodage par codec, pas un par abonné.
|
|
148
|
+
|
|
149
|
+
## 7. Chaque message, sa cause, ton geste
|
|
150
|
+
|
|
151
|
+
Les textes entre guillemets sont recopiés du code ; `<…>` marque une partie variable.
|
|
152
|
+
|
|
153
|
+
| Où | Message | Cause | Geste |
|
|
154
|
+
| -------------------------------------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- |
|
|
155
|
+
| exception (navigateur) / journal `ERROR` (serveur) | `codec JSON : objet {"$bytes": …} non issu d'octets` | objet applicatif de la forme exacte du marqueur, vers une connexion texte | envoyer un vrai `Uint8Array`, ou renommer / ajouter une clé (§5) |
|
|
156
|
+
| journal `ERROR` serveur | `rpc <action> : réponse non encodable` | une `@RealtimeAction` a rendu une valeur que le codec refuse (`$bytes` forgé, cycle, `bigint`) ; le client reçoit `-32603` générique | corriger la valeur rendue |
|
|
157
|
+
| exception | `Impossible d'encoder un BigInt — JSON.stringify le refuse aussi` | `bigint` dans la charge, sous CBOR | convertir en chaîne ou en nombre |
|
|
158
|
+
| exception | `Impossible d'encoder une structure circulaire — JSON.stringify la refuse aussi` | cycle dans la charge | aplatir la donnée |
|
|
159
|
+
| notice navigateur (`-32700`) | `Trame binaire écartée : en JSON-RPC texte elle n'a pas de canal` | le serveur écrit du binaire brut sur une connexion texte sans `onBinary()` | lire par `onBinary()`, ou passer par un canal + CBOR |
|
|
160
|
+
| notice navigateur (`-32700`) | `Trame illisible écartée : elle ne se décode pas en <protocole>` | trame que le codec ne décode pas | côté serveur : chercher qui écrit hors du codec |
|
|
161
|
+
| notice navigateur | `Codec « <protocole> » annoncé sans avoir été offert : la connexion reste en JSON-RPC texte` | serveur qui annonce un codec que ce client n'a pas offert | mettre client et serveur à la même version |
|
|
162
|
+
| notice navigateur | `Flux binaire brut indisponible sous <protocole>` | `onBinary()` / `sendBinary()` sur une connexion CBOR | publier les octets sur un canal (§4) |
|
|
163
|
+
| journal `WARNING` serveur, une fois par connexion | `WS realtime : trame binaire reçue et écartée` | binaire brut reçu sur une connexion texte, sans `onRealtimeBinary()` | passer par un canal + CBOR, ou surcharger `onRealtimeBinary()` (§4, hors gardes) |
|
|
164
|
+
| journal `WARNING` serveur, une fois par connexion | `WS realtime : sendBinaryToClient refusé` | `sendBinaryToClient()` sur une connexion CBOR | publier les octets sur un canal |
|
|
165
|
+
| journal `WARNING` serveur, une fois par nature | `WS realtime : trame binaire illisible pour <protocole> reçue et écartée` | trame CBOR malformée ou tronquée ; **aucune réponse** n'est renvoyée (pas d'oracle pour un fuzzer), connexion conservée | côté client : chercher qui écrit hors du codec |
|
|
166
|
+
| exception (`CborError`) | `CBOR refusé à l'octet <n> : <raison>` | `decodeCbor()` appelé par l'application sur des octets invalides ; `err.offset` dit où | valider la source ; ne jamais faire confiance à des octets reçus |
|
|
167
|
+
|
|
168
|
+
## 8. Ce qui arrive SANS message
|
|
169
|
+
|
|
170
|
+
- **Serveur resté en `codec: "json"`** : un `Uint8Array` part en `{"0":1,"1":2,…}` — aucune erreur.
|
|
171
|
+
C'est le piège n°1 : vérifier la clé `codec` avant tout autre diagnostic.
|
|
172
|
+
- **`codec` passé à un `shared()` qui n'est pas le premier** : l'instance existante revient,
|
|
173
|
+
réglages compris ; la connexion reste en texte (un avertissement hors production seulement).
|
|
174
|
+
- **`sendBinary()` sur un transport fermé** : rend `false`, compté dans `framesUnsent`, sans notice.
|
|
175
|
+
- **Codec non annoncé par l'accueil** : le client reste en texte, sans notice — c'est le cas
|
|
176
|
+
normal d'un serveur en `codec: "json"`.
|
|
177
|
+
- **Ne déduis jamais le codec de l'en-tête `Sec-WebSocket-Protocol`** renvoyé au handshake : la
|
|
178
|
+
bibliothèque WebSocket le renvoie qu'elle parle le sous-protocole ou non. Seul `socket.codec`
|
|
179
|
+
fait foi.
|
|
180
|
+
|
|
181
|
+
## 9. Prouver que ça marche
|
|
182
|
+
|
|
183
|
+
- `socket.codec` vaut `"nodefony.jsonrpc.cbor.v1"` une fois l'accueil reçu ; `"nodefony.jsonrpc.v1"`
|
|
184
|
+
= texte.
|
|
185
|
+
- Compteurs côté navigateur : `socket.binaryFramesDiscarded`, `socket.malformedFramesDiscarded`
|
|
186
|
+
doivent rester à 0.
|
|
187
|
+
- Un test qui le prouve publie un `Uint8Array` sur un canal et vérifie que l'abonné reçoit un
|
|
188
|
+
`Uint8Array` **identique** (`instanceof Uint8Array` et mêmes octets) — sous CBOR ET sous texte.
|
|
189
|
+
- Pour lire le fil à l'œil dans l'inspecteur réseau : `NF_REALTIME_CODEC=json` remet le serveur en
|
|
190
|
+
texte. Exception de débogage : la taille des trames et la contre-pression changent, aucune mesure
|
|
191
|
+
prise ainsi ne vaut pour la production.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Ce qui ne se voit qu'en production — plusieurs serveurs, réseau, révocation, charge
|
|
2
|
+
|
|
3
|
+
> Chargé depuis `SKILL.md` §8. Tout se vérifie dans la documentation installée :
|
|
4
|
+
> `node_modules/@nodefony/realtime/docs/configuration.md` (drivers, clés, variables),
|
|
5
|
+
> `node_modules/@nodefony/realtime/docs/securite.md` (codes de fermeture, `allow` lent),
|
|
6
|
+
> `node_modules/@nodefony/realtime/docs/cadence-et-contre-pression.md`,
|
|
7
|
+
> `node_modules/@nodefony/realtime/docs/canaux-a-membres.md` (gestes qui traversent les serveurs),
|
|
8
|
+
> `node_modules/@nodefony/realtime/docs/observabilite.md` (les compteurs qui accusent un oubli).
|
|
9
|
+
|
|
10
|
+
## 1. Plusieurs serveurs — rien ne traverse sans intention
|
|
11
|
+
|
|
12
|
+
| Déploiement | `backplane.driver` | Ce qui se passe sinon |
|
|
13
|
+
| ------------------------------------ | ----------------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
14
|
+
| un seul processus | `loopback` | — |
|
|
15
|
+
| `nodefony cluster -w N`, une machine | `cluster` (défaut de l'app générée) | sans bus, un abonné sur le worker 2 ne reçoit pas ce que publie le worker 1 |
|
|
16
|
+
| plusieurs machines ou pods | `redis` (+ `@nodefony/redis`) | `loopback` à plusieurs répliques : la moitié des utilisateurs ne reçoit plus rien, sans erreur |
|
|
17
|
+
|
|
18
|
+
Un bus branché ne diffuse **que les canaux déclarés** : pose `@RealtimeBroadcast("chat:", "room:")`
|
|
19
|
+
sur la classe du contrôleur, **sur tous les serveurs** (le receveur aussi). Un préfixe couvre
|
|
20
|
+
`chat:`, `chat:room-42`, `chat:room-42:1000`. Le défaut est l'isolement : un canal d'observabilité
|
|
21
|
+
décrit SON serveur, il ne doit pas traverser. Le framework diffuse `user:` lui-même.
|
|
22
|
+
|
|
23
|
+
- L'oubli se voit : `localOnlyTotal` monte sur la sonde dès qu'un bus est branché et qu'un canal
|
|
24
|
+
non déclaré est publié ; les préfixes diffusés sont listés au démarrage.
|
|
25
|
+
- Le bus est un transport, pas un journal : un serveur absent 30 s ne rattrape rien. Redis
|
|
26
|
+
injoignable au démarrage : repli sur ce seul processus, WARNING, l'application démarre.
|
|
27
|
+
- **Secret du bus** : `NF_REALTIME_BACKPLANE_SECRET` (≥ 32 caractères), le même partout ; un
|
|
28
|
+
message non scellé est refusé et compté (`sealRejectedTotal`). Deux applications sur un même
|
|
29
|
+
Redis : `backplane.namespace` distinct — c'est une clé de sécurité, pas de confort.
|
|
30
|
+
- **Ce qui traverse, et ce qui ne traverse pas** : les publications des canaux diffusés, et deux
|
|
31
|
+
ordres qui ne font qu'enlever — `closeChannel(canal)` et `evictUser(canal, compte)`. Les gestes
|
|
32
|
+
par CONNEXION (`subscribeConnection`, `evictConnection`, `channelMembers`) sont locaux au
|
|
33
|
+
serveur qui tient la socket : appelle-les depuis une entrée client (`@RealtimeInbound`, qui
|
|
34
|
+
s'exécute par construction sur ce serveur), jamais depuis une requête HTTP routée au hasard.
|
|
35
|
+
- **Bus partagé = bus privé.** Un ordre d'éjection capté sur le bus peut être rejoué par qui y
|
|
36
|
+
écrit : l'effet reste une coupure (jamais une admission), et changer le secret rend caducs les
|
|
37
|
+
ordres captés. Risque écrit et accepté (ADR-0022 §6).
|
|
38
|
+
|
|
39
|
+
## 2. Reconnexion — ce qui est rejoué, ce qui ne l'est pas
|
|
40
|
+
|
|
41
|
+
- Le client rejoue **ses abonnements** au `realtime:welcome` (imposés compris), et chacun repasse
|
|
42
|
+
par la politique et par `allow`. D'où la règle : l'appartenance est écrite là où `allow` la lit
|
|
43
|
+
AVANT d'abonner, sinon le membre est refusé à la première coupure (sur mobile : dans la minute).
|
|
44
|
+
- Il ne rejoue **ni les messages publiés pendant la coupure, ni les requêtes en vol**. Une
|
|
45
|
+
discussion se reprend par une route (`GET /chat/history?since=<dernier id>`), dédoublonnée sur
|
|
46
|
+
l'identifiant persistant, triée sur l'identifiant croissant de la base — jamais une horloge.
|
|
47
|
+
- Un canal fermé par le serveur (`realtime:closed`, `ended` ou `evicted`) n'est pas rejoué. Un
|
|
48
|
+
rechargement de page, si : c'est `allow` qui tranche.
|
|
49
|
+
- `connected` ne veut pas dire abonné : `await socket.whenSubscribed(canal)` avant de charger
|
|
50
|
+
l'état initial, sinon un événement entre les deux est perdu.
|
|
51
|
+
|
|
52
|
+
## 3. Garanties — ce que le framework promet, et rien de plus
|
|
53
|
+
|
|
54
|
+
| Livraison | Ordre | Livraison | En cas de perte |
|
|
55
|
+
| --------- | ------------------------------------------------ | ---------------- | --------------------------------------------------------------------------- |
|
|
56
|
+
| socket | par flux et par émetteur ; jamais entre serveurs | au plus une fois | un trou, jamais un réordonnancement ; client qui ne lit plus fermé (`1013`) |
|
|
57
|
+
| flux SSE | par flux | au plus une fois | un trou ; la reprise appartient à l'action |
|
|
58
|
+
| bus | par émetteur | au plus une fois | un trou |
|
|
59
|
+
|
|
60
|
+
Une alarme machine qui ne doit pas se perdre se bâtit **au-dessus** : identifiant dans la charge,
|
|
61
|
+
accusé par une entrée client, réémission par l'application. Le socle ne stocke rien.
|
|
62
|
+
|
|
63
|
+
## 4. Révocation — une socket survit à sa session
|
|
64
|
+
|
|
65
|
+
Toutes les 30 s, chaque connexion authentifiée est revalidée. Les codes vivent dans un seul
|
|
66
|
+
registre, exporté par `nodefony` : **compare aux constantes, jamais aux nombres**.
|
|
67
|
+
|
|
68
|
+
| Code | Constante | Cause | Le client |
|
|
69
|
+
| ------ | ----------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------- |
|
|
70
|
+
| `4001` | `IDENTITY_DEAD_CLOSE_CODE` | session détruite ou expirée, jeton révoqué | s'arrête |
|
|
71
|
+
| `4003` | `ORIGIN_REFUSED_CLOSE_CODE` | origine refusée à la poignée de main | s'arrête |
|
|
72
|
+
| `4005` | `IDENTITY_REFRESH_CLOSE_CODE` | droits changés, identité vivante | revient aussitôt — lis `onIdentity()` pour rafraîchir l'écran |
|
|
73
|
+
| `4006` | `TOKEN_EXPIRED_CLOSE_CODE` | jeton PORTEUR expiré sur une socket ouverte | s'arrête : recrée la socket avec un jeton neuf AVANT `exp` |
|
|
74
|
+
| `4007` | `BACKPRESSURE_CLOSE_CODE` | émis par le client : sa file d'envoi ne se vide plus | se reconnecte |
|
|
75
|
+
| `1013` | `TRY_AGAIN_LATER_CLOSE_CODE` | serveur : client qui ne lit plus, ou authentification trop lente (10 s) | revient, avec recul |
|
|
76
|
+
|
|
77
|
+
- Un refus à la poignée de main est un **statut HTTP** (401/403) que le navigateur cache : la
|
|
78
|
+
socket sonde l'adresse en HTTP et traite 401/403/404 comme définitifs. Jamais de `4006` avant
|
|
79
|
+
authentification.
|
|
80
|
+
- Un membre retiré d'une salle reste abonné tant que sa connexion vit : l'exclusion immédiate, c'est
|
|
81
|
+
`evictUser` ; la révocation ferme la connexion entière.
|
|
82
|
+
|
|
83
|
+
## 5. Contre-pression et bornes — une règle, trois endroits
|
|
84
|
+
|
|
85
|
+
- **Serveur** (`@nodefony/http`, clés `websocket` ET `websocketSecure` — régler l'un ne règle pas
|
|
86
|
+
l'autre) : `maxBackpressure` 4 MiB, `backpressurePolicy: "drop"` (défaut) ou `"close"`,
|
|
87
|
+
`backpressureCloseAfterDrops` 1000 → fermeture `1013`. `drop` ne trie pas : une réponse RPC
|
|
88
|
+
peut partir aussi. Un flux où chaque message compte choisit `close`, ou un accusé applicatif.
|
|
89
|
+
- **Débit entrant** : `maxFramesPerSecond` (défaut `0` = désactivé), fermeture `1008`
|
|
90
|
+
sur un solde de refus — jamais au premier dépassement (une reconnexion qui rejoue ses
|
|
91
|
+
abonnements est une rafale légitime). Pour un jeu : `120` et `frameRateCloseAfterRejects: 1000`.
|
|
92
|
+
- **Navigateur** : `maxBufferedAmount` (désactivé par défaut) jette un coup périmé au lieu de
|
|
93
|
+
l'empiler, compté dans `framesDropped` ; `backpressureCloseAfterDrops` ferme en `4007`.
|
|
94
|
+
- **Cadence adaptative** : un canal d'ÉTAT se demande `base:<ms>` ; le serveur le sert par
|
|
95
|
+
`createRealtimeChannel` + `parseRate` (`nodefony`), le client par
|
|
96
|
+
`useNodefonyAdaptiveChannelData`. Jamais sur un canal d'événements.
|
|
97
|
+
- `slowConsumer.bytes` (`@nodefony/realtime`) compte, ne freine pas.
|
|
98
|
+
|
|
99
|
+
## 6. `allow` sous charge
|
|
100
|
+
|
|
101
|
+
- Rejeu au redémarrage : N clients × M canaux appels d'`allow` en rafale. Parade : une règle en
|
|
102
|
+
classe qui lit un registre partagé, ou un cache court côté application.
|
|
103
|
+
- `allowTimeoutMs` 5 s → `unavailable` (le client réessaie seul, 3 fois, gigue complète) ;
|
|
104
|
+
`maxPendingAllowsPerConnection` 8 (la demande attend son tour) ; `maxPendingAllowsTotal` 512
|
|
105
|
+
→ `unavailable` ; coupe-circuit 50 dépassements en 5 s. Compteurs : `allowTimedOutTotal`,
|
|
106
|
+
`allowSaturatedTotal`.
|
|
107
|
+
- Un `allow` qui lève = `forbidden` pour tous, sans un mot en production : une règle en classe
|
|
108
|
+
fait échouer le DÉMARRAGE si sa dépendance manque. Préfère-la dès qu'elle lit la base.
|
|
109
|
+
|
|
110
|
+
## 7. Lire la sonde avant d'accuser le code
|
|
111
|
+
|
|
112
|
+
`GET /nodefony/realtime/api/health` (rôle d'administration) ou le canal `nodefony:socket`.
|
|
113
|
+
|
|
114
|
+
| Signal | Lecture |
|
|
115
|
+
| ------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
116
|
+
| `publishTotal` monte, `fanoutTotal` stagne | tu publies dans le vide : nom de canal différent entre serveur et client |
|
|
117
|
+
| `localOnlyTotal` > 0 sur un canal applicatif | `@RealtimeBroadcast` oublié |
|
|
118
|
+
| `ingressRejectedTotal`, `sealRejectedTotal` | quelqu'un d'autre écrit sur le bus, ou rotation du secret en cours |
|
|
119
|
+
| `backpressure.slowConsumers`, `drops` | un client ne suit pas ; la protection agit — ce n'est pas une panne |
|
|
120
|
+
| `allowTimedOutTotal`, `authenticateTimedOutTotal` | la base ne répond plus |
|
|
121
|
+
| `systemFloorDeniedTotal` | un client demande un canal de plateforme sans module de sécurité |
|
|
@@ -70,7 +70,10 @@ npx playwright install chromium
|
|
|
70
70
|
|
|
71
71
|
`NF_BROWSER_ENGINE=chrome` (ou `msedge`, `chromium`) impose un navigateur précis. Un choix explicite
|
|
72
72
|
n'est **jamais** complété par un repli : se rabattre en silence attribuerait la mesure au mauvais
|
|
73
|
-
moteur.
|
|
73
|
+
moteur. `NF_BROWSER_ENGINE=firefox` (ou `webkit`) change de MOTEUR — après
|
|
74
|
+
`npx playwright install firefox` : ce que dicte une norme (sécurité du contenu, rapports de
|
|
75
|
+
violation, cookies) diverge d'un moteur à l'autre, et Chromium seul n'en dit rien. L'audit
|
|
76
|
+
Lighthouse, lui, reste réservé à Chromium. ⚠️ À ne pas confondre avec `NF_BROWSER_CHANNEL`, qui désigne le canal d'un socket
|
|
74
77
|
applicatif — le mot « canal » sert aux deux dans des mondes différents.
|
|
75
78
|
|
|
76
79
|
## L'autre voie — en conteneur, et QUAND s'en servir
|
|
@@ -182,6 +185,15 @@ Réglages par variables d'environnement : `NF_BROWSER_BASE`, `NF_BROWSER_PAGE`,
|
|
|
182
185
|
(`libellé=sélecteur`, séparés par des virgules), `NF_BROWSER_FAMILIES`, `NF_BROWSER_WIDTHS`,
|
|
183
186
|
`NF_BROWSER_SEUIL_LOURD`, `NF_BROWSER_SEUIL_LENT`, `NF_BROWSER_ACTIONS`, `NF_BROWSER_FULLPAGE`. Le détail vit dans l'en-tête de chaque script.
|
|
184
187
|
|
|
188
|
+
**Lire un état que seule ton application connaît** (un store, l'instantané d'une socket) :
|
|
189
|
+
`NF_BROWSER_EVAL` prend une expression évaluée DANS la page après les actions et les mesures, et
|
|
190
|
+
rend son résultat dans le champ `evaluated` — ou `{ error }` si elle lève. Une forme fonction
|
|
191
|
+
(`async () => …`) est appelée, jamais seulement définie.
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
NF_BROWSER_EVAL='() => document.title' node scripts/inspect.mjs /tableau-de-bord
|
|
195
|
+
```
|
|
196
|
+
|
|
185
197
|
### Un écran qui n'existe qu'après un GESTE — `NF_BROWSER_ACTIONS`
|
|
186
198
|
|
|
187
199
|
Certaines pages ne sont pas un état mais un **parcours** : un formulaire qui ne déplie ses questions
|
|
@@ -196,7 +208,9 @@ Grammaire : `verbe:cible[=valeur]`, séquence séparée par `|`, verbe facultati
|
|
|
196
208
|
cible cherchée d'abord comme **texte visible** puis comme sélecteur CSS. Verbes : `clic` · `double`
|
|
197
209
|
· `droit` (clic droit) · `survol` · `saisir:Nom=valeur` · `choisir:Liste=valeur` (une option d'un
|
|
198
210
|
`<select>`, par sa valeur ou son libellé ; la cible peut être le libellé qui l'enveloppe) ·
|
|
199
|
-
`touche:Enter` · `voir` · `defiler:600` · `attendre
|
|
211
|
+
`touche:Enter` · `voir` · `defiler:600` · `attendre` · `connecter:` (se connecte dans un
|
|
212
|
+
AUTRE onglet, sans recharger la page mesurée — qui s'ouvre alors anonyme : une page déjà
|
|
213
|
+
ouverte voit-elle la session apparaître ?).
|
|
200
214
|
|
|
201
215
|
- 🔴 **`voir` plutôt qu'un clic pour amener dans la vue** : sur un formulaire, le texte d'une
|
|
202
216
|
question est un `label` — cliquer dessus coche la case qu'il décrit, et tu observes alors un écran
|
|
@@ -381,6 +395,17 @@ en bout : le message part-il, revient-il, et l'écran le reçoit-il ?
|
|
|
381
395
|
> lui, vaut pour toute attente que tu écriras : tant qu'elle n'a pas échoué une fois, elle ne
|
|
382
396
|
> discrimine rien.
|
|
383
397
|
|
|
398
|
+
### Voir la trame qu'un GESTE envoie — `NF_BROWSER_ACTIONS`
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
docker exec -e "NF_BROWSER_ACTIONS=Supprimer|Confirmer" mon-app-browser node /app/see-screen/watch.mjs /articles 4000
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
La même grammaire que pour `inspect.mjs`, jouée une fois la page ouverte, **pendant** l'observation :
|
|
405
|
+
la trame qu'un bouton envoie par la socket est enregistrée comme les autres. C'est la seule façon de
|
|
406
|
+
prouver qu'une écriture passe par la socket. Sans la trame, un client qui se replie sur HTTP en cas
|
|
407
|
+
d'échec affiche le même écran. Une cible introuvable arrête la sonde (code 65).
|
|
408
|
+
|
|
384
409
|
### Éprouver la REPRISE après une coupure réseau — `NF_BROWSER_CUT`
|
|
385
410
|
|
|
386
411
|
```bash
|
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
USER,
|
|
42
42
|
} from "./lib/browser.mjs";
|
|
43
43
|
import {
|
|
44
|
+
browserEngine,
|
|
44
45
|
browserOrder,
|
|
45
46
|
captureSlug,
|
|
46
47
|
colorSchemeLaunchArgs,
|
|
@@ -104,6 +105,17 @@ const profile = mkdtempSync(path.join(tmpdir(), "nf-audit-"));
|
|
|
104
105
|
// seul Chrome est installé — là même où `inspect.mjs` passait.
|
|
105
106
|
let ctx = null;
|
|
106
107
|
const failures = [];
|
|
108
|
+
// Lighthouse parle au navigateur par le protocole de débogage de Chromium :
|
|
109
|
+
// Firefox et WebKit ne l'exposent pas. Refuser en le disant, plutôt que de se
|
|
110
|
+
// rabattre sur Chromium et d'attribuer l'audit au moteur qu'on n'a pas lancé.
|
|
111
|
+
const wanted = (process.env.NF_BROWSER_ENGINE ?? "").trim();
|
|
112
|
+
if (wanted && browserEngine(wanted).engine !== "chromium") {
|
|
113
|
+
console.error(
|
|
114
|
+
`L'audit exige un navigateur de la famille Chromium (protocole de débogage) — « ${wanted} » n'en est pas un.\n` +
|
|
115
|
+
"Pour regarder la page sous ce moteur : inspect.mjs avec le même NF_BROWSER_ENGINE.",
|
|
116
|
+
);
|
|
117
|
+
process.exit(64); // EX_USAGE
|
|
118
|
+
}
|
|
107
119
|
for (const channel of browserOrder(process.env.NF_BROWSER_ENGINE)) {
|
|
108
120
|
try {
|
|
109
121
|
ctx = await chromium.launchPersistentContext(profile, {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* `@env` NF_BROWSER_PAGE chemin de la page à ouvrir (défaut /)
|
|
17
17
|
* `@env` NF_BROWSER_EXPECT texte DISCRIMINANT attendu avant de mesurer (défaut : aucun, on mesure après domcontentloaded)
|
|
18
18
|
* `@env` NF_BROWSER_FAMILIES familles de sondes à activer, séparées par des virgules (a11y, axe, rendu, reseau, perf, stockage, responsive — ou « toutes ») ; défaut : aucune, le socle seul
|
|
19
|
-
* `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge) ; sans lui, le premier qui répond
|
|
19
|
+
* `@env` NF_BROWSER_ENGINE navigateur imposé (chromium, chrome, msedge, firefox, webkit) ; sans lui, le premier qui répond
|
|
20
20
|
* `@env` NF_BROWSER_COLOR_SCHEME schéma de couleurs émulé (light, dark, no-preference) — un défaut peut n'exister que dans UN thème
|
|
21
21
|
* `@env` NF_BROWSER_STORAGE entrées de stockage local posées AVANT chargement (`clé=valeur`) — pour une application qui MÉMORISE son thème
|
|
22
22
|
* `@env` NF_BROWSER_LOGIN chemin du formulaire de connexion de TON application — requis dès qu'un identifiant est donné, aucun défaut n'est deviné
|
|
@@ -34,11 +34,14 @@
|
|
|
34
34
|
* `@env` NF_BROWSER_PRIMARY sélecteur de l'action principale pour la famille pli (défaut : le plus grand bouton de validation visible)
|
|
35
35
|
* `@env` NF_BROWSER_SEUIL_LOURD bytes au-delà desquels une ressource est « lourde » (défaut 512000)
|
|
36
36
|
* `@env` NF_BROWSER_SEUIL_LENT millisecondes au-delà desquelles une réponse est « lente » (défaut 1000)
|
|
37
|
+
* `@env` NF_BROWSER_EVAL expression JS évaluée DANS la page après les actions et les mesures (une forme fonction est appelée) ;
|
|
38
|
+
* son résultat sérialisé sort dans `evaluated` — l'état propre à l'application (store, socket)
|
|
37
39
|
* `@requires` conteneur du profil `browser` démarré · serveur joignable depuis le conteneur
|
|
38
40
|
* `@output` un objet JSON sur stdout + une capture PNG horodatée dans /output
|
|
39
41
|
* `@exit` 0 mesure rendue (le verdict est une DONNÉE, pas un code de retour) · 64 usage (famille inconnue, identifiant sans chemin de connexion) · 65 texte attendu jamais apparu
|
|
40
42
|
*/
|
|
41
43
|
import { existsSync, readFileSync } from "node:fs";
|
|
44
|
+
import { playActions } from "./lib/actions.mjs";
|
|
42
45
|
import path from "node:path";
|
|
43
46
|
import * as decor from "./lib/browser.mjs";
|
|
44
47
|
import { open, goTo, LOGIN, OUTPUT } from "./lib/browser.mjs";
|
|
@@ -48,14 +51,17 @@ import {
|
|
|
48
51
|
captureSlug,
|
|
49
52
|
parseActions,
|
|
50
53
|
parseFamilies,
|
|
54
|
+
signsInMidway,
|
|
51
55
|
parseProbes,
|
|
52
56
|
parseWidths,
|
|
57
|
+
pageExpression,
|
|
53
58
|
summarizeAxe,
|
|
54
59
|
verdictGlobal,
|
|
55
60
|
} from "./lib/probes.mjs";
|
|
56
61
|
|
|
57
62
|
const PAGE = process.argv[2] ?? process.env.NF_BROWSER_PAGE ?? "/";
|
|
58
63
|
const EXPECT = process.argv[3] ?? process.env.NF_BROWSER_EXPECT ?? "";
|
|
64
|
+
const EVAL = pageExpression(process.env.NF_BROWSER_EVAL);
|
|
59
65
|
/**
|
|
60
66
|
* Ce qu'il faut FAIRE avant de mesurer — une séquence, pas un geste.
|
|
61
67
|
*
|
|
@@ -81,6 +87,7 @@ const EXPECT = process.argv[3] ?? process.env.NF_BROWSER_EXPECT ?? "";
|
|
|
81
87
|
* | `voir` | amène dans la vue SANS cliquer |
|
|
82
88
|
* | `defiler` | fait défiler de N pixels (`defiler:600`), page ou conteneur |
|
|
83
89
|
* | `attendre` | attend qu'un texte APPARAISSE (après une action lente) |
|
|
90
|
+
* | `connecter` | se connecte dans un AUTRE onglet (`connecter:`), sans recharger |
|
|
84
91
|
*
|
|
85
92
|
* 🔴 `voir` existe parce qu'un clic n'est pas neutre : sur un formulaire, le
|
|
86
93
|
* texte d'une question est un `label` — cliquer dessus COCHE la case qu'il
|
|
@@ -148,7 +155,9 @@ async function axeSource() {
|
|
|
148
155
|
|
|
149
156
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
150
157
|
|
|
151
|
-
|
|
158
|
+
// `connecter:` dans la séquence ⇒ la page s'ouvre ANONYME (cf signsInMidway).
|
|
159
|
+
const ANONYMOUS = signsInMidway(ACTIONS);
|
|
160
|
+
const { browser, ctx, page, reuse } = await open({ anonymous: ANONYMOUS });
|
|
152
161
|
|
|
153
162
|
// ── Collecteurs — posés AVANT toute navigation ──────────────────────────────
|
|
154
163
|
const consoleErrors = [];
|
|
@@ -230,7 +239,7 @@ if (active.has("reseau")) {
|
|
|
230
239
|
});
|
|
231
240
|
}
|
|
232
241
|
|
|
233
|
-
await goTo(page, ctx, PAGE, reuse);
|
|
242
|
+
await goTo(page, ctx, PAGE, reuse, { anonymous: ANONYMOUS });
|
|
234
243
|
|
|
235
244
|
// Attendre un texte DISCRIMINANT, jamais `networkidle` : une application qui se
|
|
236
245
|
// monte puis demande ses données passe par un état « réseau calme » où l'écran
|
|
@@ -259,84 +268,12 @@ if (EXPECT) {
|
|
|
259
268
|
// toute mesure. Une action qui ne trouve pas sa cible ARRÊTE la sonde : la
|
|
260
269
|
// mesure qui suivrait porterait sur un écran qu'on n'a pas ouvert, et rien ne
|
|
261
270
|
// le dirait.
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
console.error(`defiler attend un nombre de pixels, reçu « ${target} »`);
|
|
267
|
-
await browser.close();
|
|
268
|
-
process.exit(64); // EX_USAGE
|
|
269
|
-
}
|
|
270
|
-
// Le conteneur qui défile RÉELLEMENT, pas la fenêtre : sur une console à
|
|
271
|
-
// barre latérale fixe, `window.scrollBy` ne bouge rien du tout.
|
|
272
|
-
await page.evaluate((dy) => {
|
|
273
|
-
const scrollable = [...document.querySelectorAll("*")].find(
|
|
274
|
-
(el) => el.scrollHeight > el.clientHeight + 40 && el.clientHeight > 200,
|
|
275
|
-
);
|
|
276
|
-
(scrollable ?? window).scrollBy(0, dy);
|
|
277
|
-
}, pixels);
|
|
278
|
-
continue;
|
|
279
|
-
}
|
|
280
|
-
// Le premier candidat VISIBLE, pas le premier du DOM : un libellé apparaît
|
|
281
|
-
// souvent d'abord dans un menu replié ou un gabarit caché, et agir là ne fait
|
|
282
|
-
// rien tout en passant pour un succès.
|
|
283
|
-
const candidates = page.getByText(target, { exact: false });
|
|
284
|
-
let locator = null;
|
|
285
|
-
const total = await candidates.count();
|
|
286
|
-
for (let i = 0; i < total; i += 1) {
|
|
287
|
-
const c = candidates.nth(i);
|
|
288
|
-
if (await c.isVisible().catch(() => false)) {
|
|
289
|
-
locator = c;
|
|
290
|
-
break;
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
// Aucun candidat visible À CET INSTANT ne dit pas que la cible est un
|
|
294
|
-
// sélecteur : juste après un clic qui navigue, la page suivante n'est pas
|
|
295
|
-
// encore rendue. On attend donc le texte OU le sélecteur — et le texte seul
|
|
296
|
-
// quand la cible n'est pas du CSS valide. Trancher sur l'instant faisait
|
|
297
|
-
// chercher une balise `<FreeOTP>` une fois sur trois.
|
|
298
|
-
if (locator === null) {
|
|
299
|
-
// Une navigation en vol détruirait le contexte de l'évaluation ci-dessous.
|
|
300
|
-
await page.waitForLoadState("domcontentloaded").catch(() => {});
|
|
301
|
-
const isCss = await page
|
|
302
|
-
.evaluate((s) => {
|
|
303
|
-
try {
|
|
304
|
-
document.querySelector(s);
|
|
305
|
-
return true;
|
|
306
|
-
} catch {
|
|
307
|
-
return false;
|
|
308
|
-
}
|
|
309
|
-
}, target)
|
|
310
|
-
.catch(() => false);
|
|
311
|
-
locator = isCss
|
|
312
|
-
? candidates.or(page.locator(target)).first()
|
|
313
|
-
: candidates.first();
|
|
314
|
-
}
|
|
315
|
-
try {
|
|
316
|
-
await locator.waitFor({ timeout: 15000 });
|
|
317
|
-
if (verb === "clic") await locator.click();
|
|
318
|
-
else if (verb === "double") await locator.dblclick();
|
|
319
|
-
else if (verb === "droit") await locator.click({ button: "right" });
|
|
320
|
-
else if (verb === "survol") await locator.hover();
|
|
321
|
-
else if (verb === "saisir") await locator.fill(value);
|
|
322
|
-
// `choisir` : la cible peut être le `<label>` qui ENVELOPPE la liste —
|
|
323
|
-
// Playwright reporte alors le choix sur son contrôle. La valeur est celle
|
|
324
|
-
// de l'option (`value`), sinon son libellé.
|
|
325
|
-
else if (verb === "choisir") {
|
|
326
|
-
const chosen = await locator.selectOption(value);
|
|
327
|
-
if (chosen.length === 0) throw new Error(`option « ${value} » absente`);
|
|
328
|
-
} else if (verb === "touche") await locator.press(value || "Enter");
|
|
329
|
-
else if (verb === "voir") await locator.scrollIntoViewIfNeeded();
|
|
330
|
-
// `attendre` : le `waitFor` ci-dessus EST l'action.
|
|
331
|
-
} catch {
|
|
332
|
-
console.error(
|
|
333
|
-
`Action « ${verb}:${target} » impossible\n` +
|
|
334
|
-
`Page réellement ouverte : ${page.url()}\n` +
|
|
335
|
-
"→ le libellé a changé, l'élément n'est pas encore rendu, ou il faut " +
|
|
336
|
-
"agir sur autre chose avant lui (NF_BROWSER_ACTIONS accepte une séquence, séparée par « | »).",
|
|
337
|
-
);
|
|
271
|
+
{
|
|
272
|
+
const failed = await playActions(page, ACTIONS);
|
|
273
|
+
if (failed !== null) {
|
|
274
|
+
console.error(failed.message);
|
|
338
275
|
await browser.close();
|
|
339
|
-
process.exit(
|
|
276
|
+
process.exit(failed.code);
|
|
340
277
|
}
|
|
341
278
|
}
|
|
342
279
|
|
|
@@ -998,12 +935,34 @@ if (active.has("pli")) {
|
|
|
998
935
|
}, primarySelector);
|
|
999
936
|
}
|
|
1000
937
|
|
|
938
|
+
// ── Lire un ÉTAT de l'application — `NF_BROWSER_EVAL` ───────────────────────
|
|
939
|
+
// Ce qu'aucune famille ne mesure parce que c'est propre à l'application : l'état
|
|
940
|
+
// d'un store, l'instantané d'une socket, une valeur calculée par la page. Évalué
|
|
941
|
+
// APRÈS les actions et les mesures, sur l'écran qu'on décrit. Une expression qui
|
|
942
|
+
// lève rend son erreur, jamais un champ absent : un « rien » se lirait comme
|
|
943
|
+
// « la page n'a pas cet état ».
|
|
944
|
+
if (EVAL) {
|
|
945
|
+
try {
|
|
946
|
+
measured.evaluated = await page.evaluate(EVAL);
|
|
947
|
+
} catch (e) {
|
|
948
|
+
measured.evaluated = { error: String(e).slice(0, 300) };
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
1001
952
|
// ── Capture — AVANT la famille responsive, qui déforme le viewport ──────────
|
|
1002
953
|
const slug = captureSlug(PAGE);
|
|
1003
954
|
const shot = path.join(OUTPUT, `${slug}-${stamp}.png`);
|
|
1004
955
|
// `NF_BROWSER_FULLPAGE=1` : la page ENTIÈRE, pas la fenêtre. Un formulaire long
|
|
1005
956
|
// (ou une page qu'on vient de déplier) a l'essentiel SOUS la ligne de flottaison
|
|
1006
957
|
// — la capture par défaut laisse alors conclure « ce n'est pas là ».
|
|
958
|
+
//
|
|
959
|
+
// La console se FIGE avant la capture : sous WebKit, le pilote injecte un
|
|
960
|
+
// `<style>` pour capturer, et une politique de sécurité du contenu stricte le
|
|
961
|
+
// refuse. Ce refus appartient à l'instrument, pas à la page — compté, il ferait
|
|
962
|
+
// accuser l'application d'une violation qu'elle ne commet pas. (Le rapport, lui,
|
|
963
|
+
// part quand même vers le serveur : une collecte de rapports sous WebKit doit
|
|
964
|
+
// écarter les `style-src-elem` `inline` émis à l'instant de la capture.)
|
|
965
|
+
const consoleFrozen = consoleErrors.slice();
|
|
1007
966
|
await page.screenshot({
|
|
1008
967
|
path: shot,
|
|
1009
968
|
fullPage: process.env.NF_BROWSER_FULLPAGE === "1",
|
|
@@ -1071,7 +1030,7 @@ console.log(
|
|
|
1071
1030
|
// L'écran mesuré : une proportion sans son écran ne conclut rien.
|
|
1072
1031
|
viewport: `${decor.VIEWPORT.name}${decor.VIEWPORT.mobile ? " (téléphone)" : ""}`,
|
|
1073
1032
|
...measured,
|
|
1074
|
-
consoleErrors,
|
|
1033
|
+
consoleErrors: consoleFrozen,
|
|
1075
1034
|
uncaughtErrors,
|
|
1076
1035
|
// Le chemin RENDU est celui où l'appelant trouvera l'image. Dans un
|
|
1077
1036
|
// conteneur, `/output` est un volume monté et ne veut rien dire au
|