@mostajs/geste-film 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,27 @@
1
+ # Changelog — @mostajs/geste-film
2
+
3
+ Format [Keep a Changelog](https://keepachangelog.com/fr/1.0.0/) · [SemVer](https://semver.org/lang/fr/).
4
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com>
5
+
6
+ ## [0.1.0] — 2026-11-07
7
+ ### Added
8
+ - Scénario déclaratif `geste-film/1` : actions `aller`, `cliquer`, `saisir`, `choisir`, `cocher`, `attendre`, `montrer`,
9
+ `defiler`, et `legende` seule ; sélecteurs CSS ou `texte=…` (libellé visible).
10
+ - `verifierScenario`, `empreinteScenario` (SHA-256 du scénario normalisé), `actionDe`, `ACTIONS`, `FORMAT`.
11
+ - `tourner(scenarios, options)` : un Chrome sans écran par série, piloté par le protocole DevTools sur tube
12
+ (`--remote-debugging-pipe`), sans dépendance npm ; un onglet par scénario ; marques (rond, cadre, légende, code) posées
13
+ par le CSSOM sans lever la CSP de la page ; images prises à des moments choisis.
14
+ - Assemblage par ffmpeg : MP4 (H.264, `yuv420p`, `+faststart`), GIF à palette calculée par film, affiche PNG.
15
+ - Bilan `films.json` (`geste-film-bilan/1`, `FORMAT_BILAN`), `ligneJournal`, `trouverChrome`.
16
+ - Erreurs renseignées : `E_INTROUVABLE`, `E_HTTP`, `E_INACTIF`, `E_SCENARIO`, `E_CHROME`, `E_FFMPEG`, `E_STRICT` ; la
17
+ ligne en échec dit l'étape, l'action, le sélecteur, l'adresse et laisse une capture `<code>-echec.png` ; la série continue.
18
+ - Ligne de commande `geste-film verifier|tourner` (code 1 s'il y a un échec).
19
+ - Exemple exécutable `examples/run.mjs` (serveur sous CSP stricte : EX-A1 filmé, EX-A2 en `E_INTROUVABLE`).
20
+ - Essais T-GFM-1 à 10 (3 unitaires, 7 avec Chrome et ffmpeg).
21
+ - Origine : demande du Maître d'ouvrage du 07/11/2026 (un film par ligne de formation). Premier usage : Guerway, fiches
22
+ CFG-A, pilote de 10 lignes, montrées dans ValiTrax par `@mostajs/recette` 0.10.0 (`illustration` par ligne).
23
+ - Livrables DEVRULES §9 (01 à 17), plan Dev+Test, SBOM.
24
+
25
+ ### Changed (pendant le développement, avant publication)
26
+ - Le plan prévoyait `Page.setBypassCSP` ; les marques passent finalement par le CSSOM et la CSP de la page reste
27
+ intacte (essai T-GFM-7).
package/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ @mostajs/geste-film
2
+ Copyright (C) 2026 Dr Hamid MADANI <drmdh@msn.com>
3
+
4
+ This program is free software: you can redistribute it and/or modify it under
5
+ the terms of the GNU Affero General Public License as published by the Free
6
+ Software Foundation, either version 3 of the License, or (at your option) any
7
+ later version.
8
+
9
+ This program is distributed in the hope that it will be useful, but WITHOUT
10
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
11
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
12
+ details.
13
+
14
+ You should have received a copy of the GNU Affero General Public License
15
+ along with this program. If not, see <https://www.gnu.org/licenses/agpl-3.0.html>.
package/README.md CHANGED
@@ -1,3 +1,64 @@
1
- # Temporary Holding Version
1
+ # @mostajs/geste-film
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Auteur** : Dr Hamid MADANI <drmdh@msn.com> · Licence AGPL-3.0-or-later · Niveau N1 · aucune dépendance à l'exécution
4
+
5
+ **Filmer un geste d'utilisation d'une application web** : une ligne de fiche de formation = un scénario JSON = un film.
6
+ Chrome sans écran est piloté par son protocole DevTools **sur tube** (`--remote-debugging-pipe`, ni WebSocket ni
7
+ bibliothèque) ; `ffmpeg` assemble les images en MP4, GIF et affiche. Programmes requis : `google-chrome` ou `chromium`, `ffmpeg`.
8
+
9
+ ```json
10
+ { "format": "geste-film/1", "code": "EX-A1", "titre": "Renommer l’agence et enregistrer",
11
+ "adresse": "http://exemple.banc.test/",
12
+ "etapes": [
13
+ { "legende": "Les réglages de l’agence" },
14
+ { "saisir": "#nom", "valeur": "Agence d’Oran centre", "legende": "Changer le nom" },
15
+ { "cliquer": "texte=Enregistrer", "legende": "« Enregistrer »" },
16
+ { "attendre": "texte=Enregistré", "legende": "Le réglage est enregistré" }
17
+ ] }
18
+ ```
19
+
20
+ ```js
21
+ import { tourner, verifierScenario } from '@mostajs/geste-film';
22
+ const bilan = await tourner(scenarios, { sortie: 'films', regles: 'MAP *.banc.test 127.0.0.1:5555', journal: console.log });
23
+ // films/EX-A1.mp4, films/EX-A1.gif, films/EX-A1.png (affiche), films/films.json (bilan geste-film-bilan/1)
24
+ ```
25
+
26
+ ```bash
27
+ geste-film verifier scenarios/ # validation seule, sans Chrome
28
+ geste-film tourner scenarios/ --sortie films --regles "MAP *.banc.test 127.0.0.1:5555" # code 1 s'il y a un échec
29
+ ```
30
+
31
+ - **Actions** : `aller`, `cliquer`, `saisir` + `valeur`, `choisir` + `valeur`, `cocher`, `attendre`, `montrer`, `defiler`, et `legende` seule.
32
+ - **Sélecteurs** : CSS (`#nom`, `[name=prix]`) ou `texte=…`, le libellé visible que la fiche cite entre « ».
33
+ - **Marques** : rond rouge sur l'élément visé, cadre, bandeau de légende, code de la ligne en coin. Elles sont posées par le
34
+ CSSOM, sans jamais lever la CSP de la page, et retirées après chaque prise de vue.
35
+ - **Images à des moments choisis**, jamais un flux : deux tournages du même scénario donnent les mêmes images (T-GFM-10).
36
+ - **Bilan** `films.json` : statut, fichiers, images, durée, octets, empreinte SHA-256 du scénario, avertissements (CSP, exceptions).
37
+
38
+ ## Un échec qui renseigne
39
+
40
+ Quand une étape échoue, seule sa ligne s'arrête ; la série continue. La ligne dit le code, l'étape, l'action, le sélecteur,
41
+ l'adresse, et montre la page (`<code>-echec.png`) :
42
+
43
+ ```
44
+ ✗ EX-A2, étape 2 sur 2 (cliquer « texte=Publier ce service ») : introuvable après 5 s
45
+ sur http://exemple.banc.test/ ; capture : EX-A2-echec.png
46
+ ```
47
+
48
+ Codes : `E_INTROUVABLE`, `E_HTTP`, `E_INACTIF`, `E_SCENARIO`, `E_CHROME`, `E_FFMPEG`, `E_STRICT`.
49
+
50
+ ## Exemple et essais
51
+
52
+ `node examples/run.mjs` : un petit serveur sous CSP stricte (`examples/formulaire/`) ; EX-A1 est filmé (7 images,
53
+ MP4 d'environ 26 ko, GIF d'environ 32 ko), EX-A2 finit en `E_INTROUVABLE`.
54
+
55
+ `npm test` : 3 essais unitaires (T-GFM-1 à 3) et 7 essais avec Chrome et ffmpeg (T-GFM-4 à 10), puis l'exemple.
56
+
57
+ Le module ne crée aucune session et ne sème aucune donnée : l'application filme **son banc, sur des données fictives**
58
+ (`docs/16-DPIA-CONFORMITE-GESTE-FILM.md`). Premier usage : Guerway, fiches de formation CFG-A (pilote de 10 lignes).
59
+
60
+ ## Documentation
61
+
62
+ Livrables DEVRULES §9 dans `docs/` : étude (01), audit (02), plan (03, 03bis), plan Dev+Test (JSON), tests (04), suivi (05),
63
+ SEO (08), publication (09), présentation commerciale (10, 11), documentation technique (12, 13), image (14), sécurité (15),
64
+ DPIA (16), article (`docs/articles/17-…`). Diaporamas PDF et PPTX dans `docs/slides/`. SBOM : `sbom.cyclonedx.json`.
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ // geste-film — ligne de commande de @mostajs/geste-film.
3
+ // geste-film tourner <dossier-des-scénarios> [--sortie films] [--regles "MAP *.x 127.0.0.1:5555"] [--delai 5000]
4
+ // [--largeur 960] [--hauteur 600] [--strict] [--garder-images]
5
+ // geste-film verifier <dossier-des-scénarios>
6
+ // Code de sortie 1 s'il y a au moins un échec.
7
+ // @author Dr Hamid MADANI <drmdh@msn.com>
8
+ import { readdirSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { tourner, verifierScenario } from '../src/index.js';
11
+
12
+ const [cmd, dossier, ...reste] = process.argv.slice(2);
13
+ const opt = {};
14
+ for (let i = 0; i < reste.length; i++) {
15
+ const k = reste[i];
16
+ if (k === '--strict') opt.strict = true;
17
+ else if (k === '--garder-images') opt.garderImages = true;
18
+ else if (k.startsWith('--')) opt[k.slice(2)] = reste[++i];
19
+ }
20
+ if (!['tourner', 'verifier'].includes(cmd) || !dossier) {
21
+ console.error('usage : geste-film tourner|verifier <dossier-des-scénarios> [--sortie films] [--regles "…"] [--delai ms]');
22
+ process.exit(2);
23
+ }
24
+ const fichiers = readdirSync(dossier).filter((f) => f.endsWith('.json')).sort();
25
+ const scenarios = fichiers.map((f) => { try { return JSON.parse(readFileSync(join(dossier, f), 'utf8')); } catch (e) { return { code: f, _illisible: e.message }; } });
26
+ if (cmd === 'verifier') {
27
+ let n = 0;
28
+ scenarios.forEach((s, i) => { const f = verifierScenario(s); if (f.length) { n++; console.log(`✗ ${fichiers[i]} : ${f.join(' ; ')}`); } else console.log(`✓ ${fichiers[i]}`); });
29
+ process.exit(n ? 1 : 0);
30
+ }
31
+ const bilan = await tourner(scenarios, {
32
+ sortie: opt.sortie || 'films', regles: opt.regles || '', delaiMs: Number(opt.delai) || 5000,
33
+ largeur: Number(opt.largeur) || 960, hauteur: Number(opt.hauteur) || 600, strict: Boolean(opt.strict), garderImages: Boolean(opt.garderImages),
34
+ journal: (l) => console.log(l),
35
+ });
36
+ process.exit(bilan.echecs ? 1 : 0);
package/llms.txt ADDED
@@ -0,0 +1,41 @@
1
+ # @mostajs/geste-film — fiche LLM
2
+ > Filmer un geste d'utilisation d'une application web : un scénario JSON (geste-film/1) par ligne de formation → MP4, GIF, affiche PNG. N1, outil. Aucune dépendance npm à l'exécution ; programmes du système : google-chrome|chromium, ffmpeg. Node >= 20. Auteur : Dr Hamid MADANI <drmdh@msn.com> · AGPL-3.0-or-later
3
+
4
+ ## RÔLE
5
+ Lance UN Chrome sans écran par série (--headless=new, --remote-debugging-pipe, profil temporaire), ouvre un onglet par scénario, joue les étapes, pose des marques (rond, cadre, légende, code) par le CSSOM, prend des PNG à des moments choisis (Page.captureScreenshot), assemble par ffmpeg, écrit <sortie>/films.json. Un échec n'arrête que sa ligne. Ne crée aucune session, ne sème aucune donnée, ne téléverse rien.
6
+
7
+ ## EXPORTS
8
+ - `tourner(scenarios, opts?)` → Promise<bilan> (écrit aussi <sortie>/films.json)
9
+ - `verifierScenario(s)` → string[] (défauts en clair ; [] si juste) — sans Chrome
10
+ - `empreinteScenario(s)` → SHA-256 hex du scénario normalisé (clés triées, espaces réduits)
11
+ - `ligneJournal(film)` → « ✓ code (n images, x ko) » ou « ✗ code, étape n sur m (action « sélecteur ») : message\n sur <adresse> ; capture : <fichier> »
12
+ - `actionDe(etape)` → nom de l'action, 'legende', ou null
13
+ - `trouverChrome(chemin?)` → chemin : option, sinon $CHROME, sinon google-chrome, google-chrome-stable, chromium, chromium-browser (via `which`) ; sinon erreur E_CHROME
14
+ - `ACTIONS` = ['aller','cliquer','saisir','choisir','cocher','attendre','montrer','defiler'] (gelé) · `FORMAT` = 'geste-film/1' · `FORMAT_BILAN` = 'geste-film-bilan/1'
15
+ - Bin : `geste-film verifier <dossier>` | `geste-film tourner <dossier> [--sortie films] [--regles "…"] [--delai ms] [--largeur 960] [--hauteur 600] [--strict] [--garder-images]` ; code 1 s'il y a un échec, 2 si usage faux.
16
+
17
+ ## API — options de tourner (défauts)
18
+ sortie='films' · chrome (chemin) · ffmpeg='ffmpeg' · largeur=960 · hauteur=600 · largeurFilm=960 · regles='' (--host-resolver-rules) · delaiMs=5000 · formats=['mp4','gif'] (l'affiche est toujours produite) · strict=false · garderImages=false (PNG dans <sortie>/images/<code>/) · dureeImageMs=1200 · dureeFinMs=2000 (dernière image) · journal=(ligne)=>{}
19
+
20
+ ## TYPES
21
+ - Scénario : { format:'geste-film/1', code:/^[A-Za-z][A-Za-z0-9]*(-[A-Za-z0-9]+)*$/, titre?, adresse:'http(s)://…', etapes:[Etape≥1] }
22
+ - Etape : UNE action parmi ACTIONS (valeur = sélecteur ; pour `aller` : adresse absolue http(s) ou relative « /… ») + valeur? + legende? ; ou { legende } seule. `saisir`/`choisir` exigent `valeur` ; `cocher` : valeur true|false (défaut true). Toute autre clé = défaut.
23
+ - Sélecteur : CSS, ou `texte=<libellé>` (texte visible, casse et espaces ignorés, guillemets « » " retirés ; égalité d'abord, puis inclusion ; l'élément le plus intérieur ; pour un INPUT : value ou aria-label).
24
+ - Images par action : legende 1 · aller 1 · cliquer 2 · saisir 3 · choisir 2 · cocher 2 · attendre 1 · montrer 1 · defiler 1 (l'ouverture de `adresse` n'en prend pas).
25
+ - Bilan : { format, date, outil:'@mostajs/geste-film@<v>', largeur, hauteur, echecs, films:[{ code, titre, statut:'ok'|'echec', images, dureeMs, octets:{mp4?,gif?,affiche?}, fichiers:{mp4?,gif?,affiche?,images?}, empreinte, avertissements:[{type:'csp'|'exception'|'marques',…}], erreur?:{ code, message, etapes, etape?, action?, selecteur?, adresse?, capture?, statut? } }] }
26
+ - Codes : E_SCENARIO (scénario fautif, avant Chrome ; ou sélecteur CSS invalide, vu dans Chrome) · E_INTROUVABLE (absent après delaiMs) · E_INACTIF (caché, ou désactivé pour cliquer/saisir/choisir/cocher) · E_HTTP (document ≥ 400, ou page qui ne s'ouvre pas) · E_CHROME (absent, ne répond pas, perdu : les lignes suivantes « non tourné ») · E_FFMPEG (PNG gardés dans images/<code>/) · E_STRICT (avertissements en mode strict).
27
+
28
+ ## PIÈGES
29
+ - La légende est COLLANTE : une étape sans `legende` garde celle d'avant (au départ : `titre`).
30
+ - Aucune option pour poser un cookie ou un en-tête : l'application rend l'adresse filmable (banc, session ouverte par son propre mécanisme).
31
+ - Filmer seulement un banc à DONNÉES FICTIVES : le film montre tout ce que la page affiche, et `saisir.valeur` est en clair dans le JSON.
32
+ - `aller` accepte toute adresse http(s) : un scénario est une entrée de confiance, à relire comme du code.
33
+ - `code` sert de nom de fichier (<code>.mp4, <code>-echec.png) : deux scénarios de même code s'écrasent.
34
+ - En mode strict, les fichiers sont produits puis la ligne passe en échec E_STRICT.
35
+ - `<code>-echec.png` est effacé quand la ligne réussit ensuite. Un dossier `.travail-*` vit dans <sortie> pendant la série.
36
+ - Chrome introuvable : chaque ligne tente de nouveau et finit en E_CHROME. `trouverChrome` utilise `which` (Linux, macOS).
37
+ - La CSP de la page n'est jamais levée (pas de Page.setBypassCSP) ; ses violations deviennent des avertissements.
38
+ - La ligne de commande n'expose ni --chrome (utiliser CHROME=…), ni --ffmpeg, ni --formats.
39
+
40
+ ## EXEMPLE
41
+ `node examples/run.mjs` : serveur sous CSP `script-src 'self'` ; EX-A1 filmé (7 images), EX-A2 en échec E_INTROUVABLE. `npm test` : T-GFM-1 à 10 + l'exemple.
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@mostajs/geste-film",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Filmer un geste d'utilisation d'une application web : un scénario déclaratif par ligne de formation, Chrome piloté sur tube DevTools (sans dépendance), pointeur et légende, MP4, GIF et affiche par ffmpeg ; un échec dit la ligne, l'étape, l'élément manquant et montre la page.",
5
+ "author": "Dr Hamid MADANI <drmdh@msn.com>",
6
+ "license": "AGPL-3.0-or-later",
7
+ "type": "module",
8
+ "main": "src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "bin": {
13
+ "geste-film": "bin/geste-film.mjs"
14
+ },
15
+ "files": [
16
+ "src",
17
+ "bin",
18
+ "README.md",
19
+ "llms.txt",
20
+ "CHANGELOG.md"
21
+ ],
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "keywords": [
26
+ "formation",
27
+ "tutoriel",
28
+ "gif",
29
+ "screencast",
30
+ "chrome-devtools-protocol",
31
+ "recette",
32
+ "mostajs"
33
+ ],
34
+ "scripts": {
35
+ "test": "node test-scripts/unit/geste-film.test.mjs && node test-scripts/integration/geste-film-chrome.test.mjs && node examples/run.mjs"
36
+ },
37
+ "dependencies": {},
38
+ "devDependencies": {
39
+ "@mostajs/mjs-unit": "^0.13.1"
40
+ },
41
+ "mostajs": {
42
+ "niveau": "N1",
43
+ "nature": "outil",
44
+ "reutilisabilite": "multi-domaine",
45
+ "depend_de": [],
46
+ "programmes": [
47
+ "google-chrome|chromium",
48
+ "ffmpeg"
49
+ ]
50
+ }
51
+ }
@@ -0,0 +1,43 @@
1
+ // @mostajs/geste-film — assembler les images en MP4 et GIF par ffmpeg ; l'affiche est la première image.
2
+ // @author Dr Hamid MADANI <drmdh@msn.com>
3
+ import { spawn } from 'node:child_process';
4
+ import { writeFileSync, copyFileSync } from 'node:fs';
5
+ import { join, basename } from 'node:path';
6
+
7
+ const lancer = (ffmpeg, args) => new Promise((resoudre, rejeter) => {
8
+ let err = '';
9
+ let p;
10
+ try { p = spawn(ffmpeg, args, { stdio: ['ignore', 'ignore', 'pipe'] }); } catch (e) { return rejeter(e); }
11
+ p.stderr.on('data', (d) => { err = (err + d).slice(-2000); });
12
+ p.once('error', rejeter);
13
+ p.once('close', (c) => (c === 0 ? resoudre() : rejeter(new Error(err.trim().split('\n').at(-1) || `ffmpeg code ${c}`))));
14
+ });
15
+
16
+ /**
17
+ * @param {{ images: {fichier:string,dureeMs:number}[], dossierImages: string, sortie: string, code: string, ffmpeg: string,
18
+ * formats: string[], largeurFilm: number }} o → { mp4?, gif?, affiche } (chemins relatifs à `sortie`)
19
+ */
20
+ export async function assembler({ images, dossierImages, sortie, code, ffmpeg = 'ffmpeg', formats = ['mp4', 'gif'], largeurFilm = 960 }) {
21
+ const affiche = `${code}.png`;
22
+ copyFileSync(images[0].fichier, join(sortie, affiche));
23
+ // Démultiplexeur « concat » : chaque image a sa durée ; la dernière est répétée (règle du démultiplexeur).
24
+ const liste = join(dossierImages, 'liste.txt');
25
+ writeFileSync(liste, images.map((i) => `file '${basename(i.fichier)}'\nduration ${(i.dureeMs / 1000).toFixed(3)}\n`).join('') + `file '${basename(images.at(-1).fichier)}'\n`);
26
+ const entree = ['-y', '-v', 'error', '-f', 'concat', '-safe', '0', '-i', liste];
27
+ const fichiers = { affiche };
28
+ try {
29
+ if (formats.includes('mp4')) {
30
+ fichiers.mp4 = `${code}.mp4`;
31
+ await lancer(ffmpeg, [...entree, '-vf', `fps=10,scale=${largeurFilm}:-2:flags=lanczos,format=yuv420p`, '-c:v', 'libx264', '-preset', 'slow',
32
+ '-tune', 'stillimage', '-crf', '28', '-movflags', '+faststart', '-an', join(sortie, fichiers.mp4)]);
33
+ }
34
+ if (formats.includes('gif')) {
35
+ fichiers.gif = `${code}.gif`;
36
+ await lancer(ffmpeg, [...entree, '-vf', `scale=${largeurFilm}:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=full[p];[b][p]paletteuse=dither=none`,
37
+ '-vsync', 'vfr', '-loop', '0', join(sortie, fichiers.gif)]);
38
+ }
39
+ } catch (e) {
40
+ throw Object.assign(new Error(e.code === 'ENOENT' ? `ffmpeg introuvable (${ffmpeg})` : `ffmpeg : ${e.message}`), { code: 'E_FFMPEG' });
41
+ }
42
+ return fichiers;
43
+ }
package/src/cdp.js ADDED
@@ -0,0 +1,59 @@
1
+ // @mostajs/geste-film — client du protocole DevTools de Chrome sur TUBE (--remote-debugging-pipe) :
2
+ // messages JSON séparés par NUL ; ni WebSocket, ni dépendance.
3
+ // @author Dr Hamid MADANI <drmdh@msn.com>
4
+
5
+ /**
6
+ * @param {{ entree: import('node:stream').Readable, sortie: import('node:stream').Writable }} o
7
+ * entree : ce que Chrome écrit (fd 4) ; sortie : ce que Chrome lit (fd 3).
8
+ */
9
+ export function creerClientCdp({ entree, sortie }) {
10
+ let suivant = 1, tampon = '', ferme = false;
11
+ const attente = new Map();
12
+ const abonnes = new Map();
13
+
14
+ const fermer = (motif = 'tube DevTools fermé') => {
15
+ if (ferme) return;
16
+ ferme = true;
17
+ for (const { rejeter, method } of attente.values()) rejeter(new Error(`${method} : ${motif}`));
18
+ attente.clear();
19
+ };
20
+
21
+ entree.on('data', (morceau) => {
22
+ tampon += morceau.toString('utf8');
23
+ let i;
24
+ while ((i = tampon.indexOf('\0')) >= 0) {
25
+ const brut = tampon.slice(0, i); tampon = tampon.slice(i + 1);
26
+ let m; try { m = JSON.parse(brut); } catch { continue; }
27
+ if (m.id != null && attente.has(m.id)) {
28
+ const a = attente.get(m.id); attente.delete(m.id);
29
+ if (m.error) a.rejeter(Object.assign(new Error(`${a.method} : ${m.error.message}${m.error.data ? ` (${m.error.data})` : ''}`), { cdp: m.error }));
30
+ else a.resoudre(m.result || {});
31
+ } else if (m.method) {
32
+ for (const fn of abonnes.get(m.method) || []) { try { fn(m.params || {}, m.sessionId || null); } catch { /* un abonné fautif n'arrête pas le tube */ } }
33
+ }
34
+ }
35
+ });
36
+ entree.on('end', () => fermer());
37
+ entree.on('close', () => fermer());
38
+ entree.on('error', (e) => fermer(`tube DevTools fermé (${e.message})`));
39
+
40
+ return {
41
+ /** Envoie une commande ; → son résultat, ou une erreur qui nomme la commande. */
42
+ envoyer(method, params = {}, sessionId = null) {
43
+ if (ferme) return Promise.reject(new Error(`${method} : tube DevTools fermé`));
44
+ const id = suivant++;
45
+ return new Promise((resoudre, rejeter) => {
46
+ attente.set(id, { resoudre, rejeter, method });
47
+ sortie.write(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }) + '\0');
48
+ });
49
+ },
50
+ /** S'abonne à un événement ; → une fonction de désabonnement. */
51
+ sur(method, fn) {
52
+ if (!abonnes.has(method)) abonnes.set(method, new Set());
53
+ abonnes.get(method).add(fn);
54
+ return () => abonnes.get(method)?.delete(fn);
55
+ },
56
+ fermer,
57
+ get ferme() { return ferme; },
58
+ };
59
+ }
package/src/chrome.js ADDED
@@ -0,0 +1,57 @@
1
+ // @mostajs/geste-film — trouver et lancer Chrome sans écran, piloté sur tube.
2
+ // @author Dr Hamid MADANI <drmdh@msn.com>
3
+ import { spawn, execFileSync } from 'node:child_process';
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { creerClientCdp } from './cdp.js';
8
+
9
+ const erreur = (code, message) => Object.assign(new Error(message), { code });
10
+
11
+ /** Le chemin de Chrome : l'option, sinon CHROME, sinon google-chrome, chromium, chromium-browser. */
12
+ export function trouverChrome(chemin) {
13
+ if (chemin) return chemin;
14
+ if (process.env.CHROME) return process.env.CHROME;
15
+ for (const c of ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser']) {
16
+ try { execFileSync('which', [c], { stdio: 'ignore' }); return c; } catch { /* suivant */ }
17
+ }
18
+ throw erreur('E_CHROME', 'Chrome introuvable : poser l’option chrome ou CHROME=/chemin');
19
+ }
20
+
21
+ /** Lance Chrome ; → { cdp, fermer, journal() } (journal : les dernières lignes d'erreur de Chrome). */
22
+ export async function lancerChrome({ chrome, largeur = 960, hauteur = 600, regles = '' } = {}) {
23
+ const nav = trouverChrome(chrome);
24
+ const profil = mkdtempSync(join(tmpdir(), 'geste-film-chrome-'));
25
+ const args = ['--headless=new', '--remote-debugging-pipe', `--user-data-dir=${profil}`, '--no-first-run', '--no-default-browser-check',
26
+ '--hide-scrollbars', '--mute-audio', '--force-device-scale-factor=1', '--font-render-hinting=none', '--disable-lcd-text',
27
+ '--enable-unsafe-swiftshader', '--use-angle=swiftshader', '--disable-background-networking', '--disable-extensions',
28
+ '--disable-sync', '--metrics-recording-only', `--window-size=${largeur},${hauteur}`,
29
+ ...(regles ? [`--host-resolver-rules=${regles}`] : []), 'about:blank'];
30
+ const p = spawn(nav, args, { stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'], detached: true });
31
+ const tuer = () => { try { process.kill(-p.pid, 'SIGKILL'); } catch { try { p.kill('SIGKILL'); } catch { /* déjà mort */ } } };
32
+ let journal = '';
33
+ p.stderr.on('data', (d) => { journal = (journal + d).slice(-4000); });
34
+ const demarre = new Promise((resoudre, rejeter) => {
35
+ p.once('error', (e) => rejeter(erreur('E_CHROME', `Chrome ne démarre pas (${nav}) : ${e.message}`)));
36
+ p.once('spawn', resoudre);
37
+ });
38
+ await demarre;
39
+ const cdp = creerClientCdp({ entree: p.stdio[4], sortie: p.stdio[3] });
40
+ p.once('exit', (c) => cdp.fermer(`Chrome s'est arrêté (code ${c})`));
41
+ try {
42
+ await Promise.race([cdp.envoyer('Browser.getVersion'), new Promise((_, r) => setTimeout(() => r(new Error('Chrome ne répond pas')), 30000))]);
43
+ } catch (e) {
44
+ tuer(); rmSync(profil, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
45
+ throw erreur('E_CHROME', `Chrome ne répond pas sur le tube DevTools : ${e.message}${journal ? ` — ${journal.trim().split('\n').at(-1)}` : ''}`);
46
+ }
47
+ const fermer = async () => {
48
+ // Fermeture propre, puis tout le groupe de processus de Chrome (ses enfants écrivent dans le profil jusqu'au bout).
49
+ const fini = p.exitCode != null || p.signalCode != null ? Promise.resolve() : new Promise((r) => p.once('exit', r));
50
+ cdp.envoyer('Browser.close').catch(() => null);
51
+ await Promise.race([fini, new Promise((r) => setTimeout(r, 5000))]);
52
+ tuer();
53
+ await new Promise((r) => setTimeout(r, 100));
54
+ try { rmSync(profil, { recursive: true, force: true, maxRetries: 10, retryDelay: 200 }); } catch { /* profil temporaire : le système le videra */ }
55
+ };
56
+ return { cdp, fermer, journal: () => journal };
57
+ }
package/src/index.js ADDED
@@ -0,0 +1,86 @@
1
+ // @mostajs/geste-film — filmer un geste d'utilisation d'une application web : une ligne de formation = un film.
2
+ // Plan : docs/03-PLAN-DEV-GESTE-FILM.md. Aucune dépendance : Chrome sur tube DevTools, ffmpeg.
3
+ // @author Dr Hamid MADANI <drmdh@msn.com>
4
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync, statSync, readFileSync, renameSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ import { verifierScenario, empreinteScenario } from './scenario.js';
7
+ import { lancerChrome } from './chrome.js';
8
+ import { tournerScenario } from './tournage.js';
9
+ import { assembler } from './assemblage.js';
10
+
11
+ export { verifierScenario, empreinteScenario, actionDe, ACTIONS, FORMAT } from './scenario.js';
12
+ export { trouverChrome } from './chrome.js';
13
+
14
+ const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
15
+ export const FORMAT_BILAN = 'geste-film-bilan/1';
16
+ const taille = (f) => { try { return statSync(f).size; } catch { return 0; } };
17
+
18
+ /** La ligne de journal d'un film : « ✓ code (n images, x ko) » ou « ✗ code, étape n sur m (action « sélecteur ») : … ». */
19
+ export function ligneJournal(f) {
20
+ if (f.statut === 'ok') return `✓ ${f.code} (${f.images} images, ${Math.round((f.octets.mp4 || f.octets.gif || 0) / 1024)} ko)`;
21
+ const e = f.erreur || {};
22
+ const ou = e.etape ? `, étape ${e.etape} sur ${e.etapes}` : '';
23
+ const quoi = e.action ? ` (${e.action}${e.selecteur ? ` « ${e.selecteur} »` : ''})` : '';
24
+ return `✗ ${f.code}${ou}${quoi} : ${e.message}${e.adresse && e.adresse !== 'about:blank' ? `\n sur ${e.adresse}` : ''}${e.capture ? ` ; capture : ${e.capture}` : ''}`;
25
+ }
26
+
27
+ /**
28
+ * Tourne une série de scénarios dans UN Chrome. Un échec n'arrête que sa ligne (sauf Chrome perdu : E_CHROME).
29
+ * → le bilan `geste-film-bilan/1`, écrit aussi dans <sortie>/films.json.
30
+ */
31
+ export async function tourner(scenarios, {
32
+ sortie = 'films', chrome, ffmpeg = 'ffmpeg', largeur = 960, hauteur = 600, largeurFilm = 960, regles = '', delaiMs = 5000,
33
+ formats = ['mp4', 'gif'], strict = false, garderImages = false, dureeImageMs = 1200, dureeFinMs = 2000, journal = () => {},
34
+ } = {}) {
35
+ sortie = resolve(sortie);
36
+ mkdirSync(sortie, { recursive: true });
37
+ const bilan = { format: FORMAT_BILAN, date: new Date().toISOString(), outil: `@mostajs/geste-film@${VERSION}`, largeur, hauteur, films: [], echecs: 0 };
38
+ const travail = mkdtempSync(join(sortie, '.travail-'));
39
+ let nav = null, perdu = null;
40
+ try {
41
+ for (const s of scenarios) {
42
+ const code = typeof s?.code === 'string' ? s.code : '(sans code)';
43
+ const film = { code, titre: s?.titre || '', statut: 'ok', images: 0, dureeMs: 0, octets: {}, fichiers: {}, empreinte: s ? empreinteScenario(s) : null, avertissements: [] };
44
+ const etapes = Array.isArray(s?.etapes) ? s.etapes.length : 0;
45
+ const fautes = verifierScenario(s);
46
+ try {
47
+ if (fautes.length) throw Object.assign(new Error(`scénario fautif : ${fautes.join(' ; ')}`), { code: 'E_SCENARIO' });
48
+ if (perdu) throw Object.assign(new Error(`non tourné : ${perdu}`), { code: 'E_CHROME' });
49
+ if (!nav) { try { nav = await lancerChrome({ chrome, largeur, hauteur, regles }); } catch (e) { perdu = e.message; throw e; } }
50
+ const dossierImages = join(travail, code);
51
+ mkdirSync(dossierImages, { recursive: true });
52
+ let r;
53
+ try {
54
+ r = await tournerScenario(nav.cdp, s, { dossierImages, sortie, largeur, hauteur, delaiMs, dureeImageMs, dureeFinMs });
55
+ } catch (e) {
56
+ if (nav.cdp.ferme) { perdu = `Chrome perdu pendant ${code}`; e.code = 'E_CHROME'; }
57
+ film.avertissements = e.avertissements || [];
58
+ throw e;
59
+ }
60
+ film.avertissements = r.avertissements;
61
+ film.images = r.images.length;
62
+ film.dureeMs = r.images.reduce((t, i) => t + i.dureeMs, 0);
63
+ const garder = () => { const d = join(sortie, 'images', code); rmSync(d, { recursive: true, force: true }); mkdirSync(join(sortie, 'images'), { recursive: true }); renameSync(dossierImages, d); film.fichiers.images = join('images', code); };
64
+ try {
65
+ Object.assign(film.fichiers, await assembler({ images: r.images, dossierImages, sortie, code, ffmpeg, formats, largeurFilm }));
66
+ } catch (e) { garder(); film.fichiers.affiche = `${code}.png`; throw e; }
67
+ for (const k of ['mp4', 'gif', 'affiche']) if (film.fichiers[k]) film.octets[k] = taille(join(sortie, film.fichiers[k]));
68
+ if (garderImages) garder();
69
+ if (strict && film.avertissements.length) throw Object.assign(new Error(`${film.avertissements.length} avertissement(s) en mode strict`), { code: 'E_STRICT' });
70
+ rmSync(join(sortie, `${code}-echec.png`), { force: true });
71
+ } catch (e) {
72
+ film.statut = 'echec';
73
+ film.erreur = { code: e.code || 'E_CHROME', message: e.message, etapes, ...(e.plus || {}) };
74
+ bilan.echecs += 1;
75
+ }
76
+ bilan.films.push(film);
77
+ journal(ligneJournal(film));
78
+ }
79
+ } finally {
80
+ await nav?.fermer();
81
+ rmSync(travail, { recursive: true, force: true });
82
+ }
83
+ writeFileSync(join(sortie, 'films.json'), JSON.stringify(bilan, null, 2) + '\n');
84
+ journal(`bilan : ${bilan.films.length} film(s), ${bilan.echecs} échec(s)${bilan.echecs ? ` (${bilan.films.filter((f) => f.statut !== 'ok').map((f) => f.code).join(', ')})` : ''}`);
85
+ return bilan;
86
+ }
package/src/page.js ADDED
@@ -0,0 +1,88 @@
1
+ // @mostajs/geste-film — fonctions exécutées DANS la page (Runtime.evaluate). Autonomes : aucune variable globale.
2
+ // Les marques sont construites par le CSSOM (element.style.x = …), que la CSP de la page n'interdit pas : la CSP
3
+ // n'est jamais levée (pas de Page.setBypassCSP).
4
+ // @author Dr Hamid MADANI <drmdh@msn.com>
5
+
6
+ /** Trouve l'élément d'un sélecteur (CSS ou « texte=… ») ; selon `quoi`, agit dessus. → { etat, rect?, … } */
7
+ export function agir(sel, quoi, valeur) {
8
+ const norm = (t) => String(t || '').replace(/[‘’ʼ]/g, "'").replace(/[«»"]/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
9
+ const visible = (el) => {
10
+ const r = el.getBoundingClientRect(); const st = getComputedStyle(el);
11
+ return r.width > 0 && r.height > 0 && st.visibility !== 'hidden' && st.display !== 'none' && Number(st.opacity) !== 0;
12
+ };
13
+ let cands;
14
+ try {
15
+ if (sel.startsWith('texte=')) {
16
+ const t = norm(sel.slice(6));
17
+ const tous = [...document.querySelectorAll('body *')].filter((e) => !e.closest('[data-geste-film]') && !['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE'].includes(e.tagName));
18
+ const txt = (e) => norm(e.tagName === 'INPUT' ? (e.value || e.getAttribute('aria-label') || '') : (e.textContent || ''));
19
+ let liste = tous.filter((e) => txt(e) === t);
20
+ if (!liste.length) liste = tous.filter((e) => txt(e).includes(t));
21
+ cands = liste.filter((e) => !liste.some((o) => o !== e && e.contains(o)));
22
+ } else cands = [...document.querySelectorAll(sel)];
23
+ } catch (e) { return { etat: 'invalide', message: String(e.message || e) }; }
24
+ if (!cands.length) return { etat: 'absent' };
25
+ let el = cands.find(visible) || cands[0];
26
+ if (quoi === 'choisir' && el.tagName === 'INPUT' && el.type === 'radio') {
27
+ const r = [...document.querySelectorAll(`input[type=radio][name="${CSS.escape(el.name)}"]`)].find((x) => x.value === String(valeur));
28
+ if (!r) return { etat: 'absent', message: `aucune option « ${valeur} »` };
29
+ el = r.labels?.[0] && visible(r.labels[0]) ? r.labels[0] : r;
30
+ }
31
+ if (!visible(el)) return { etat: 'cache' };
32
+ const champ = el.closest('label')?.control || el;
33
+ const inactif = el.disabled || champ.disabled || el.getAttribute('aria-disabled') === 'true' || Boolean(el.closest('fieldset[disabled]'));
34
+ el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' });
35
+ const b = el.getBoundingClientRect();
36
+ const rect = { x: b.left, y: b.top, w: b.width, h: b.height };
37
+ if (quoi !== 'montrer' && quoi !== 'attendre' && quoi !== 'defiler' && inactif) return { etat: 'inactif', rect };
38
+ if (quoi === 'vider') { champ.focus(); if (typeof champ.select === 'function') champ.select(); return { etat: 'ok', rect }; }
39
+ if (quoi === 'choisir' && champ.tagName === 'SELECT') {
40
+ const o = [...champ.options].find((x) => x.value === String(valeur) || norm(x.textContent) === norm(valeur));
41
+ if (!o) return { etat: 'absent', message: `aucune option « ${valeur} »` };
42
+ champ.value = o.value;
43
+ champ.dispatchEvent(new Event('input', { bubbles: true })); champ.dispatchEvent(new Event('change', { bubbles: true }));
44
+ return { etat: 'ok', rect, fait: true };
45
+ }
46
+ if (quoi === 'cocher') return { etat: 'ok', rect, coche: Boolean(champ.checked) };
47
+ return { etat: 'ok', rect };
48
+ }
49
+
50
+ /** Pose les marques du film : rond sur l'élément, cadre, bandeau de légende, code en coin. */
51
+ export function marquer({ rect, rond, cadre, legende, code }) {
52
+ const css = (el, s) => { for (const [k, v] of Object.entries(s)) el.style[k] = v; return el; };
53
+ const div = () => { const d = document.createElement('div'); d.setAttribute('data-geste-film', ''); return d; };
54
+ document.querySelectorAll('[data-geste-film]').forEach((e) => e.remove());
55
+ document.documentElement.style.caretColor = 'transparent';
56
+ const racine = css(div(), { position: 'fixed', left: '0', top: '0', right: '0', bottom: '0', pointerEvents: 'none', zIndex: '2147483647' });
57
+ if (rect && cadre) racine.append(css(div(), { position: 'fixed', left: `${rect.x - 5}px`, top: `${rect.y - 5}px`, width: `${rect.w + 10}px`, height: `${rect.h + 10}px`, border: '3px solid #E0262B', borderRadius: '8px', boxSizing: 'border-box' }));
58
+ if (rect && rond) racine.append(css(div(), { position: 'fixed', left: `${rect.x + rect.w / 2 - 22}px`, top: `${rect.y + rect.h / 2 - 22}px`, width: '44px', height: '44px', border: '4px solid #E0262B', background: 'rgba(224,38,43,0.18)', borderRadius: '50%', boxSizing: 'border-box' }));
59
+ if (legende) {
60
+ const b = css(div(), { position: 'fixed', left: '0', right: '0', bottom: '0', padding: '10px 16px', background: 'rgba(19,40,66,0.9)', color: '#fff', font: '600 17px/1.35 system-ui, sans-serif', textAlign: 'center' });
61
+ b.textContent = legende; racine.append(b);
62
+ }
63
+ if (code) {
64
+ const c = css(div(), { position: 'fixed', top: '8px', right: '8px', padding: '3px 10px', background: '#0757CA', color: '#fff', font: '700 13px/1.4 system-ui, sans-serif', borderRadius: '999px' });
65
+ c.textContent = code; racine.append(c);
66
+ }
67
+ document.body.append(racine);
68
+ return true;
69
+ }
70
+
71
+ /** Retire les marques ; → le nombre de marques restées (0 attendu). */
72
+ export function retirer() {
73
+ document.querySelectorAll('[data-geste-film]').forEach((e) => e.remove());
74
+ return document.querySelectorAll('[data-geste-film]').length;
75
+ }
76
+
77
+ /** Les violations de CSP relevées par l'écouteur posé avant chaque document ; vidées à la lecture. */
78
+ export function violations() {
79
+ const v = window.__gesteFilmViolations || []; window.__gesteFilmViolations = []; return v;
80
+ }
81
+
82
+ /** Posé avant chaque document (Page.addScriptToEvaluateOnNewDocument). */
83
+ export const ECOUTEUR = `document.addEventListener('securitypolicyviolation', (e) => {
84
+ (window.__gesteFilmViolations = window.__gesteFilmViolations || []).push({ directive: e.effectiveDirective || e.violatedDirective, bloque: String(e.blockedURI || ''), adresse: location.href });
85
+ });`;
86
+
87
+ /** L'expression qui appelle une fonction de page avec ses arguments (JSON). */
88
+ export const appel = (fn, ...args) => `(${fn.toString()})(${args.map((a) => JSON.stringify(a ?? null)).join(',')})`;
@@ -0,0 +1,64 @@
1
+ // @mostajs/geste-film — le scénario `geste-film/1` : validation avant tout tournage, empreinte.
2
+ // @author Dr Hamid MADANI <drmdh@msn.com>
3
+ import { createHash } from 'node:crypto';
4
+
5
+ export const FORMAT = 'geste-film/1';
6
+ /** Les actions d'une étape. `legende` seule est aussi une étape (une image avec la légende). */
7
+ export const ACTIONS = Object.freeze(['aller', 'cliquer', 'saisir', 'choisir', 'cocher', 'attendre', 'montrer', 'defiler']);
8
+ const AVEC_VALEUR = new Set(['saisir', 'choisir']);
9
+ const CODE = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*$/;
10
+ const CLES = new Set([...ACTIONS, 'valeur', 'legende']);
11
+
12
+ const adresseValide = (u, relative = false) => {
13
+ if (typeof u !== 'string' || !u.trim()) return false;
14
+ if (relative && u.startsWith('/') && !u.startsWith('//')) return true;
15
+ try { return ['http:', 'https:'].includes(new URL(u).protocol); } catch { return false; }
16
+ };
17
+
18
+ /** L'action d'une étape : son nom, ou 'legende' pour une étape qui n'a que sa légende, ou null. */
19
+ export function actionDe(e) {
20
+ const a = ACTIONS.filter((k) => e && Object.hasOwn(e, k));
21
+ if (a.length === 1) return a[0];
22
+ if (!a.length && e && typeof e.legende === 'string') return 'legende';
23
+ return null;
24
+ }
25
+
26
+ /** → la liste des défauts du scénario, en clair ; vide s'il est juste. */
27
+ export function verifierScenario(s) {
28
+ if (!s || typeof s !== 'object' || Array.isArray(s)) return ['le scénario doit être un objet JSON'];
29
+ const f = [];
30
+ if (s.format !== FORMAT) f.push(`format « ${s.format ?? '(absent)'} » : attendu « ${FORMAT} »`);
31
+ if (typeof s.code !== 'string' || !CODE.test(s.code)) f.push(`code « ${s.code ?? '(absent)'} » : lettres, chiffres et tirets seulement (ex. CFG-A4)`);
32
+ if (s.titre != null && typeof s.titre !== 'string') f.push('titre : texte attendu');
33
+ if (!adresseValide(s.adresse)) f.push(`adresse « ${s.adresse ?? '(absente)'} » : http:// ou https:// attendu`);
34
+ if (!Array.isArray(s.etapes) || !s.etapes.length) { f.push('aucune étape'); return f; }
35
+ s.etapes.forEach((e, i) => {
36
+ const n = `étape ${i + 1}`;
37
+ if (!e || typeof e !== 'object' || Array.isArray(e)) { f.push(`${n} : objet attendu`); return; }
38
+ const presentes = ACTIONS.filter((k) => Object.hasOwn(e, k));
39
+ if (presentes.length > 1) { f.push(`${n} : une seule action par étape (${presentes.join(', ')})`); return; }
40
+ const inconnues = Object.keys(e).filter((k) => !CLES.has(k));
41
+ if (inconnues.length) f.push(`${n} : action inconnue « ${inconnues.join(', ')} » (connues : ${ACTIONS.join(', ')}, legende)`);
42
+ const a = actionDe(e);
43
+ if (!a) { if (!inconnues.length) f.push(`${n} : aucune action`); return; }
44
+ if (e.legende != null && typeof e.legende !== 'string') f.push(`${n} : légende : texte attendu`);
45
+ if (a === 'legende') return;
46
+ const cible = e[a];
47
+ if (a === 'aller') { if (!adresseValide(cible, true)) f.push(`${n} : adresse « ${cible} » : absolue http(s) ou relative commençant par /`); return; }
48
+ if (typeof cible !== 'string' || !cible.trim() || cible.trim() === 'texte=') f.push(`${n} : sélecteur vide`);
49
+ if (AVEC_VALEUR.has(a) && (e.valeur == null || typeof e.valeur === 'object')) f.push(`${n} : « ${a} » demande une valeur`);
50
+ if (a === 'cocher' && e.valeur != null && typeof e.valeur !== 'boolean') f.push(`${n} : « cocher » : valeur true ou false`);
51
+ });
52
+ return f;
53
+ }
54
+
55
+ const canonique = (v) => {
56
+ if (Array.isArray(v)) return v.map(canonique);
57
+ if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonique(v[k])]));
58
+ return typeof v === 'string' ? v.trim().replace(/\s+/g, ' ') : v;
59
+ };
60
+
61
+ /** SHA-256 du scénario normalisé (clés triées, espaces réduits) : dit si un film est à refaire. */
62
+ export function empreinteScenario(s) {
63
+ return createHash('sha256').update(JSON.stringify(canonique(s))).digest('hex');
64
+ }
@@ -0,0 +1,169 @@
1
+ // @mostajs/geste-film — tourner UN scénario dans un onglet : étapes, marques, images, erreurs renseignées.
2
+ // @author Dr Hamid MADANI <drmdh@msn.com>
3
+ import { writeFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { actionDe } from './scenario.js';
6
+ import { agir, marquer, retirer, violations, ECOUTEUR, appel } from './page.js';
7
+
8
+ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ const echec = (code, message, plus = {}) => Object.assign(new Error(message), { code, plus });
10
+ const MESSAGES = { absent: 'introuvable', cache: 'présent mais caché', inactif: 'présent mais désactivé', invalide: 'sélecteur invalide' };
11
+ const CODES = { absent: 'E_INTROUVABLE', cache: 'E_INACTIF', inactif: 'E_INACTIF', invalide: 'E_SCENARIO' };
12
+
13
+ /**
14
+ * @param {*} cdp client du tube
15
+ * @param {object} s scénario validé
16
+ * @param {{ dossierImages: string, sortie: string, largeur: number, hauteur: number, delaiMs: number, dureeImageMs: number, dureeFinMs: number }} o
17
+ * → { images: [{ fichier, dureeMs }], avertissements, adresse }
18
+ */
19
+ export async function tournerScenario(cdp, s, o) {
20
+ const { targetId } = await cdp.envoyer('Target.createTarget', { url: 'about:blank' });
21
+ const { sessionId } = await cdp.envoyer('Target.attachToTarget', { targetId, flatten: true });
22
+ const env = (m, p) => cdp.envoyer(m, p, sessionId);
23
+ const desabonner = [];
24
+ const sur = (m, fn) => desabonner.push(cdp.sur(m, (p, sid) => { if (sid === sessionId) fn(p); }));
25
+ const avertissements = [];
26
+ const vus = new Set();
27
+ const avertir = (a) => { const k = JSON.stringify(a); if (!vus.has(k)) { vus.add(k); avertissements.push(a); } };
28
+ let cadre = null, enCours = false, statut = 0, adresse = 'about:blank';
29
+ const images = [];
30
+ let legende = s.titre || '';
31
+ let etape = 0;
32
+ try {
33
+ await env('Page.enable'); await env('Runtime.enable'); await env('Network.enable');
34
+ await env('Emulation.setDeviceMetricsOverride', { width: o.largeur, height: o.hauteur, deviceScaleFactor: 1, mobile: false });
35
+ await env('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] });
36
+ await env('Page.addScriptToEvaluateOnNewDocument', { source: ECOUTEUR });
37
+ cadre = (await env('Page.getFrameTree')).frameTree.frame.id;
38
+ sur('Page.frameStartedLoading', (p) => { if (p.frameId === cadre) enCours = true; });
39
+ sur('Page.frameStoppedLoading', (p) => { if (p.frameId === cadre) enCours = false; });
40
+ sur('Network.responseReceived', (p) => { if (p.type === 'Document' && p.frameId === cadre) { statut = p.response.status; adresse = p.response.url; } });
41
+ sur('Runtime.exceptionThrown', (p) => avertir({ type: 'exception', message: p.exceptionDetails?.exception?.description?.split('\n')[0] || p.exceptionDetails?.text || 'exception' }));
42
+
43
+ const evaluer = async (expr) => {
44
+ const r = await env('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true });
45
+ if (r.exceptionDetails) throw echec('E_CHROME', `évaluation dans la page : ${r.exceptionDetails.exception?.description || r.exceptionDetails.text}`);
46
+ return r.result.value;
47
+ };
48
+ const releverViolations = async () => {
49
+ for (const v of (await evaluer(appel(violations)).catch(() => [])) || []) avertir({ type: 'csp', directive: v.directive, bloque: v.bloque, adresse: v.adresse });
50
+ };
51
+ // La page se pose : chargement fini, polices prêtes. Puis le statut HTTP du document.
52
+ const poser = async () => {
53
+ await pause(150);
54
+ const fin = Date.now() + o.delaiMs * 3;
55
+ while (enCours && Date.now() < fin) await pause(50);
56
+ await evaluer('document.fonts ? document.fonts.ready.then(() => true) : true').catch(() => null);
57
+ await pause(60);
58
+ if (statut >= 400) throw echec('E_HTTP', `la page répond ${statut}`, { statut });
59
+ };
60
+ const aller = async (url) => {
61
+ const cible = new URL(url, adresse === 'about:blank' ? s.adresse : adresse).href;
62
+ enCours = true;
63
+ const r = await env('Page.navigate', { url: cible });
64
+ if (r.errorText) throw echec('E_HTTP', `la page ne s’ouvre pas : ${r.errorText}`, { statut: 0 });
65
+ adresse = cible;
66
+ await poser();
67
+ };
68
+ const prendre = async ({ rect = null, rond = false, cadre: encadre = false } = {}) => {
69
+ await evaluer(appel(marquer, { rect, rond, cadre: encadre, legende, code: s.code }));
70
+ const { data } = await env('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false, fromSurface: true });
71
+ const reste = await evaluer(appel(retirer));
72
+ if (reste) avertir({ type: 'marques', message: `${reste} marque(s) restée(s) dans la page` });
73
+ const fichier = join(o.dossierImages, `${String(images.length + 1).padStart(3, '0')}.png`);
74
+ writeFileSync(fichier, Buffer.from(data, 'base64'));
75
+ images.push({ fichier, dureeMs: o.dureeImageMs });
76
+ await releverViolations();
77
+ };
78
+ // Attend l'élément jusqu'au délai ; l'erreur dit pourquoi il ne convient pas.
79
+ const reperer = async (sel, quoi = 'reperer', valeur = null) => {
80
+ const fin = Date.now() + o.delaiMs;
81
+ let r;
82
+ for (;;) {
83
+ r = await evaluer(appel(agir, sel, quoi, valeur));
84
+ if (r.etat === 'ok' || r.etat === 'invalide' || Date.now() >= fin) break;
85
+ await pause(100);
86
+ }
87
+ if (r.etat !== 'ok') {
88
+ const delai = r.etat === 'absent' ? ` après ${(o.delaiMs / 1000).toLocaleString('fr-FR')} s` : '';
89
+ throw echec(CODES[r.etat] || 'E_INTROUVABLE', `${MESSAGES[r.etat] || r.etat}${delai}${r.message ? ` (${r.message})` : ''}`);
90
+ }
91
+ await pause(40);
92
+ return (await evaluer(appel(agir, sel, 'reperer', valeur))).rect || r.rect;
93
+ };
94
+ const cliquerA = async (rect) => {
95
+ const x = rect.x + rect.w / 2, y = rect.y + rect.h / 2;
96
+ enCours = false;
97
+ await releverViolations();
98
+ await env('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
99
+ await env('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
100
+ await env('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
101
+ };
102
+
103
+ await aller(s.adresse);
104
+ for (const e of s.etapes) {
105
+ etape += 1;
106
+ if (typeof e.legende === 'string') legende = e.legende;
107
+ const a = actionDe(e);
108
+ const sel = e[a];
109
+ try {
110
+ if (a === 'legende') await prendre();
111
+ else if (a === 'aller') { await aller(sel); await prendre(); }
112
+ else if (a === 'cliquer') {
113
+ const rect = await reperer(sel);
114
+ await prendre({ rect, rond: true, cadre: true });
115
+ await cliquerA(rect); await poser(); await prendre();
116
+ } else if (a === 'saisir') {
117
+ const rect = await reperer(sel);
118
+ await prendre({ rect, rond: true, cadre: true });
119
+ await reperer(sel, 'vider');
120
+ const t = String(e.valeur), m = Math.ceil(t.length / 2);
121
+ await env('Input.dispatchKeyEvent', { type: 'keyDown', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 });
122
+ await env('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 });
123
+ if (t.slice(0, m)) await env('Input.insertText', { text: t.slice(0, m) });
124
+ await prendre({ rect, cadre: true });
125
+ if (t.slice(m)) await env('Input.insertText', { text: t.slice(m) });
126
+ await prendre({ rect, cadre: true });
127
+ } else if (a === 'choisir') {
128
+ const rect = await reperer(sel, 'reperer', e.valeur);
129
+ await prendre({ rect, rond: true, cadre: true });
130
+ const r = await evaluer(appel(agir, sel, 'choisir', e.valeur));
131
+ if (r.etat !== 'ok') throw echec(CODES[r.etat] || 'E_INTROUVABLE', `${MESSAGES[r.etat] || r.etat}${r.message ? ` (${r.message})` : ''}`);
132
+ if (!r.fait) await cliquerA(r.rect);
133
+ await poser(); await prendre({ rect: r.rect, cadre: true });
134
+ } else if (a === 'cocher') {
135
+ const rect = await reperer(sel);
136
+ await prendre({ rect, rond: true, cadre: true });
137
+ const voulu = e.valeur == null ? true : e.valeur;
138
+ const r = await evaluer(appel(agir, sel, 'cocher'));
139
+ if (r.coche !== voulu) await cliquerA(rect);
140
+ await poser(); await prendre({ rect, cadre: true });
141
+ } else if (a === 'attendre' || a === 'montrer' || a === 'defiler') {
142
+ const rect = await reperer(sel, a);
143
+ await prendre(a === 'defiler' ? {} : { rect, cadre: true });
144
+ }
145
+ } catch (err) {
146
+ err.plus = { ...(err.plus || {}), etape, action: a, selecteur: typeof sel === 'string' ? sel : null };
147
+ throw err;
148
+ }
149
+ }
150
+ await releverViolations();
151
+ if (images.length) images.at(-1).dureeMs = o.dureeFinMs;
152
+ return { images, avertissements, adresse };
153
+ } catch (err) {
154
+ // Montrer la page telle que le robot l'a trouvée.
155
+ try {
156
+ await env('Runtime.evaluate', { expression: appel(retirer), returnByValue: true });
157
+ const { data } = await env('Page.captureScreenshot', { format: 'png' });
158
+ const capture = `${s.code}-echec.png`;
159
+ writeFileSync(join(o.sortie, capture), Buffer.from(data, 'base64'));
160
+ err.plus = { ...(err.plus || {}), capture };
161
+ } catch { /* l'onglet ne répond plus : pas de capture */ }
162
+ err.plus = { ...(err.plus || {}), adresse, etape: err.plus?.etape ?? etape };
163
+ err.images = images; err.avertissements = avertissements;
164
+ throw err;
165
+ } finally {
166
+ for (const d of desabonner) d();
167
+ await cdp.envoyer('Target.closeTarget', { targetId }).catch(() => null);
168
+ }
169
+ }