diff --git a/.github/workflows/recette.yml b/.github/workflows/recette.yml new file mode 100644 index 0000000..7169067 --- /dev/null +++ b/.github/workflows/recette.yml @@ -0,0 +1,87 @@ +# Recette Phase T — recette testeur de Control Vault (données fictives, Google entièrement simulé). +# +# Workflow SÉPARÉ du pipeline de publication : il ne construit rien de publiable, ne publie rien, +# ne reçoit aucun secret ni variable de configuration Google. Il exécute uniquement +# tests/recette/ (configuration Playwright dédiée, jamais lancée par `npm run test:e2e`) +# et archive recette-output/ (matrice, erreurs, captures, traces) pendant 14 jours. +# +# Toute modification de ce fichier est une modification de CI : revue humaine obligatoire. + +name: Recette Phase T + +on: + pull_request: + paths: + - 'tests/recette/**' + - '.github/workflows/recette.yml' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: recette-${{ github.ref }} + cancel-in-progress: true + +jobs: + recette: + name: Recette testeur (Chromium bureau + WebKit iPhone) + runs-on: ubuntu-24.04 + timeout-minutes: 80 + permissions: + contents: read + # Même image que « Vérification complète » (pipeline.yml) : navigateurs de la version + # exacte du verrou déjà présents, aucune installation système ni `playwright install`. + container: + image: mcr.microsoft.com/playwright:v1.56.1-noble + options: --ipc=host + env: + RECETTE_SHA: ${{ github.sha }} + RECETTE_EXPLORE_MS: '2100000' + RECETTE_MONKEY_MS: '300000' + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 — SHA vérifié le 23/10/2026 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 — SHA vérifié le 23/10/2026 + with: + node-version: 22 + cache: npm + - name: Installation (verrouillée) + run: npm ci + - name: Build de démonstration (sans configuration Google) + run: npm run build + - name: TypeScript de la recette + run: npx tsc -p tests/recette/tsconfig.json + - name: Navigateurs Playwright préinstallés + timeout-minutes: 2 + run: | + node --input-type=module <<'NODE' + import { access } from 'node:fs/promises'; + import { constants } from 'node:fs'; + import { chromium, webkit } from '@playwright/test'; + for (const browser of [chromium, webkit]) { + await access(browser.executablePath(), constants.X_OK); + console.log(`Navigateur ${browser.name()} présent dans l'image.`); + } + NODE + - name: Recette (parcours, exploration, monkey, reproductions) — Chromium + WebKit iPhone + run: npx playwright test -c tests/recette/playwright.recette.config.ts + env: + CI: '1' + - name: Synthèse dans le résumé du run + if: always() + run: | + if [ -f recette-output/MATRICE.md ]; then + sed -n '1,/^## Éléments KO/p' recette-output/MATRICE.md >> "$GITHUB_STEP_SUMMARY" + else + echo "MATRICE.md absente : voir les journaux du job." >> "$GITHUB_STEP_SUMMARY" + fi + - name: Archive de la recette (données fictives uniquement) + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 — SHA vérifié le 23/10/2026 + with: + name: recette-phase-t-${{ github.run_id }} + path: recette-output/ + if-no-files-found: error + retention-days: 14 diff --git a/tests/recette/bugs.recette.ts b/tests/recette/bugs.recette.ts new file mode 100644 index 0000000..9802612 --- /dev/null +++ b/tests/recette/bugs.recette.ts @@ -0,0 +1,108 @@ +// Recette Phase T — reproductions des anomalies trouvées (AUCUN correctif ici). +// Chaque test décrit le comportement ATTENDU et est marqué test.fail() : il « réussit » tant que +// l'anomalie existe et deviendra « unexpectedly passed » (rouge) le jour où elle sera corrigée, +// pour rappeler de retirer le marqueur. Ces tests ne tournent que dans le workflow « Recette Phase T » +// (configuration séparée) : ils n'affectent jamais « Vérification complète ». +import { test, expect, type Page } from '@playwright/test'; +import { appendFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { installFakeGoogle, STANDALONE_INIT, type FakeOptions } from './fake-google'; +import { OUT, Recorder } from './recorder'; +import { isIphone, openApp, settle } from './session'; + +function note(info: import('@playwright/test').TestInfo, id: string, observed: string, reproduced: boolean) { + mkdirSync(join(OUT, 'partials'), { recursive: true }); + appendFileSync(join(OUT, 'partials', `${Recorder.browserName(info)}-bugs.jsonl`), JSON.stringify({ id, test: info.title, browser: Recorder.browserName(info), reproduced, observed }) + '\n'); +} + +async function drive(page: Page, context: import('@playwright/test').BrowserContext, info: import('@playwright/test').TestInfo, options: FakeOptions = {}) { + if (isIphone(info)) await context.addInitScript(STANDALONE_INIT); + await installFakeGoogle(context, options); + await openApp(page, '#/drive'); + await page.getByRole('button', { name: 'Préparer la connexion Google' }).click(); await settle(page); + await page.getByRole('button', { name: 'Se connecter à Google', exact: true }).click(); await settle(page); +} +const status = (page: Page) => page.locator('#main p[role=status]').first().innerText(); +const phase = (page: Page) => page.locator('#main .panel-body > p').filter({ hasText: /^(En attente|Envoi en cours|Envoyé|Octets distants|Conflit|Synchronisation bloquée|Erreur — action nécessaire)/ }).first().innerText(); + +test('BUG-01 connexion : fermer (ou bloquer) la fenêtre de consentement Google affiche « Session Google expirée »', async ({ context, page }, info) => { + test.fail(true, 'Anomalie connue (recette Phase T) : message trompeur, aucune session n’a jamais existé.'); + await drive(page, context, info, { consent: 'popup-closed' }); + const msg = await status(page), ph = await phase(page); + note(info, 'BUG-01', `${msg} / ${ph}`, /expirée/.test(msg) || ph === 'Synchronisation bloquée'); + expect(msg, 'un abandon volontaire ne doit pas être présenté comme une session expirée').not.toMatch(/expirée/); + expect(ph).not.toBe('Synchronisation bloquée'); +}); + +test('BUG-02 connexion : refuser l’autorisation Google Drive affiche « Session Google expirée » au lieu d’expliquer la permission manquante', async ({ context, page }, info) => { + test.fail(true, 'Anomalie connue (recette Phase T) : message trompeur, la cause (permission drive.file refusée) n’est pas dite.'); + await drive(page, context, info, { consent: 'scope-refused' }); + const msg = await status(page); + note(info, 'BUG-02', msg, /expirée/.test(msg) || !/autoris|permission/i.test(msg)); + expect(msg).not.toMatch(/expirée/); + expect(msg).toMatch(/autoris|permission/i); +}); + +test('BUG-03 dossier : annuler le sélecteur Google Drive affiche « Erreur — action nécessaire » et « Opération non confirmée »', async ({ context, page }, info) => { + test.fail(true, 'Anomalie connue (recette Phase T) : une annulation volontaire est présentée comme une erreur.'); + await drive(page, context, info, { picker: 'cancel' }); + await page.getByRole('button', { name: 'Confirmer ce compte dédié' }).click(); await settle(page); + await page.getByRole('button', { name: 'Choisir le dossier Google Drive' }).click(); await settle(page); + const msg = await status(page), ph = await phase(page); + note(info, 'BUG-03', `${msg} / ${ph}`, ph === 'Erreur — action nécessaire' || /non confirmée/.test(msg)); + expect(ph, 'annuler le choix du dossier n’est pas une erreur').not.toBe('Erreur — action nécessaire'); + expect(msg).not.toMatch(/Opération non confirmée/); +}); + +test('BUG-04 cohérence : sur l’origine connectable, l’Accueil annonce « Google Drive — Connexion vérifiée » sans aucun compte connecté', async ({ context, page }, info) => { + test.fail(true, 'Anomalie connue (recette Phase T) : état de démonstration présenté comme réel à côté du vrai parcours Google Drive (à arbitrer).'); + if (isIphone(info)) await context.addInitScript(STANDALONE_INIT); + await installFakeGoogle(context); + await openApp(page, '#/drive'); + await expect(page.locator('#main p[role=status]').first()).toContainText('Prêt à connecter Google Drive'); + await openApp(page, '#/'); + const tile = (await page.locator('#main').getByRole('link', { name: /Google Drive/ }).first().innerText()).replace(/\s+/g, ' '); + note(info, 'BUG-04', tile, /Connexion vérifiée/.test(tile)); + expect(tile, 'aucun compte Google n’est connecté dans cette session').not.toMatch(/Connexion vérifiée/); +}); + +test('BUG-05 accessibilité : « Restaurer cette version » est désactivé sans description accessible de la condition (case de confirmation)', async ({ context, page }, info) => { + test.fail(true, 'Anomalie mineure connue (recette Phase T) : bouton désactivé sans aria-describedby ni title.'); + if (isIphone(info)) await context.addInitScript(STANDALONE_INIT); + await installFakeGoogle(context); + await openApp(page, '#/applis/race-control'); + await page.getByRole('button', { name: /Ouvrir la restauration/ }).first().click(); + const go = page.getByRole('button', { name: 'Restaurer cette version' }); + await expect(go).toBeDisabled(); + const described = await go.evaluate((b) => { + const ids = (b.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean); + return ids.map((id) => document.getElementById(id)?.textContent?.trim() || '').join(' ') || b.getAttribute('title') || ''; + }); + note(info, 'BUG-05', described ? `description : ${described}` : 'désactivé, ni aria-describedby ni title (la case « Je remplace l’état actuel… » n’est pas reliée au bouton)', !described); + expect(described, 'la raison de la désactivation doit être exposée au bouton').not.toBe(''); +}); + +test('BUG-06 sauvegarde : « Annuler » devient désactivé pendant le contrôle de l’empreinte sans aucune explication', async ({ context, page }, info) => { + test.fail(true, 'Anomalie mineure connue (recette Phase T) : le code annonce « on retire le bouton » mais le laisse désactivé, sans raison affichée.'); + if (isIphone(info)) await context.addInitScript(STANDALONE_INIT); + await installFakeGoogle(context); + await openApp(page, '#/applis'); + // L'état est bref : un observateur enregistre l'instant où « Annuler » passe à disabled et ce qui l'explique alors. + await page.evaluate(() => { + const w = window as unknown as { __bug06?: { why: string } }; + new MutationObserver((list) => { + for (const m of list) { + const b = m.target as HTMLElement; + if (w.__bug06 || m.attributeName !== 'disabled' || b.tagName !== 'BUTTON' || b.textContent?.trim() !== 'Annuler' || !b.hasAttribute('disabled')) continue; + const ids = (b.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean); + w.__bug06 = { why: ids.map((id) => document.getElementById(id)?.textContent?.trim() || '').join(' ') || b.getAttribute('title') || '' }; + } + }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['disabled'] }); + }); + await page.locator('#main').getByRole('button', { name: 'Sauvegarder', exact: true }).first().click(); + const seen = await expect.poll(() => page.evaluate(() => !!(window as unknown as { __bug06?: unknown }).__bug06), { timeout: 20_000 }).toBe(true).then(() => true, () => false); + if (!seen) { note(info, 'BUG-06', 'état transitoire non observé pendant cette exécution', false); test.skip(true, 'état transitoire non observé'); return; } + const why = await page.evaluate(() => (window as unknown as { __bug06: { why: string } }).__bug06.why); + note(info, 'BUG-06', why ? `description : ${why}` : 'bouton « Annuler » désactivé, ni aria-describedby, ni title, ni texte expliquant que l’envoi ne peut plus être annulé', !why); + expect(why, 'la raison de la désactivation doit être exposée (ou le bouton retiré)').not.toBe(''); +}); diff --git a/tests/recette/error-rules.ts b/tests/recette/error-rules.ts new file mode 100644 index 0000000..4a5dacc --- /dev/null +++ b/tests/recette/error-rules.ts @@ -0,0 +1,30 @@ +// Recette Phase T — explication des erreurs console connues. Tout le reste est « inexpliquée ». +import type { ErrorRow } from './recorder'; + +type E = Pick & Partial>; +const RULES: { test: (e: E) => boolean; why: string }[] = [ + { + // Playwright (WebKit) insère avant chaque capture d'écran pour synchroniser les animations + // (inPagePrepareForScreenshots / shouldToggleStyleSheetToSyncAnimations). La CSP stricte de l'application + // (style-src 'self') la refuse et WebKit le journalise. Absent sous Chromium, une erreur par capture. + test: (e) => e.type === 'console' && /webkit/.test(e.browser ?? '') && e.pendantCapture === true && + /^Refused to apply a stylesheet because its hash, its nonce, or 'unsafe-inline' does not appear in the style-src directive/.test(e.text), + why: 'Artefact du banc : feuille de style vide injectée par Playwright WebKit avant chaque capture d’écran, refusée (à juste titre) par la CSP style-src \'self\' de l’application.', + }, + { + // OpaqueDrive.createFolder / uploadPacket relisent d'abord l'identifiant réservé (generateIds) : + // le 404 « pas encore créé » est le cas nominal, traité par l'application. Le navigateur le journalise quand même. + test: (e) => e.type === 'console' && /status of 404/.test(e.text) && /^https:\/\/www\.googleapis\.com\/drive\/v3\/files\/recette-gen-\d+/.test(e.location ?? ''), + why: 'Relecture d’un identifiant Drive réservé avant sa création (contrat « réserver puis relire ») : 404 nominal, géré par l’application, journalisé par le navigateur.', + }, + { + test: (e) => e.type === 'console' && (/ERR_BLOCKED_BY_CLIENT|blockedbyclient/i.test(e.text) || + (/Failed to load resource/.test(e.text) && !!e.location && !e.location.startsWith('https://vault.example.com/') && !e.location.startsWith('https://www.googleapis.com/'))), + why: 'Requête vers une origine externe interrompue volontairement par le banc de recette (aucun réseau réel).', + }, +]; + +export function explain(e: E): { why: string; expected: boolean } { + const rule = RULES.find((r) => r.test(e)); + return rule ? { why: rule.why, expected: true } : { why: 'Non expliquée par le banc : à analyser (voir contexte, capture et trace).', expected: false }; +} diff --git a/tests/recette/exploration.recette.ts b/tests/recette/exploration.recette.ts new file mode 100644 index 0000000..a9a0962 --- /dev/null +++ b/tests/recette/exploration.recette.ts @@ -0,0 +1,173 @@ +// Recette Phase T — exploration systématique : chaque bouton, lien et champ visible de chaque écran +// est activé un par un, avec réinitialisation complète de l'état avant chaque élément. +import { test, type Page } from '@playwright/test'; +import { arm, inventory, probe, SAMPLE_TEXTS, type Found } from './explore'; +import type { MatrixRow, Verdict } from './recorder'; +import { clearOrigin, openApp, settle, startSession } from './session'; +import { explain } from './error-rules'; + +interface Screen { id: string; hash: string; steps: string[]; depth: number } +// États réels de l'écran Google Drive, atteints par les boutons de l'interface (clé d'inventaire). +const DRIVE: Screen[] = [ + { id: 'Google Drive · initial', hash: '#/drive', steps: [], depth: 0 }, + { id: 'Google Drive · connexion préparée', hash: '#/drive', steps: ['bouton|Préparer la connexion Google#1'], depth: 0 }, + { id: 'Google Drive · compte affiché', hash: '#/drive', steps: ['bouton|Préparer la connexion Google#1', 'bouton|Se connecter à Google#1'], depth: 0 }, + { id: 'Google Drive · compte confirmé', hash: '#/drive', steps: ['bouton|Préparer la connexion Google#1', 'bouton|Se connecter à Google#1', 'bouton|Confirmer ce compte dédié#1'], depth: 0 }, + { id: 'Google Drive · dossiers listés', hash: '#/drive', steps: ['bouton|Préparer la connexion Google#1', 'bouton|Se connecter à Google#1', 'bouton|Confirmer ce compte dédié#1', 'bouton|Retrouver les dossiers dédiés#1'], depth: 0 }, + { id: 'Google Drive · dossier sélectionné, versions listées', hash: '#/drive', steps: ['bouton|Préparer la connexion Google#1', 'bouton|Se connecter à Google#1', 'bouton|Confirmer ce compte dédié#1', 'bouton|Retrouver les dossiers dédiés#1', 'bouton|Utiliser ce dossier#1', 'bouton|Actualiser les versions#1'], depth: 0 }, +]; +// Groupes indépendants (un test chacun) pour paralléliser ; chaque groupe possède ses préfixes de route +// et explore aussi les routes et fenêtres qu'il découvre dans ce périmètre. +const GROUPS: { name: string; owns: RegExp; screens: Screen[] }[] = [ + { name: 'accueil', owns: /^#\/$/, screens: [{ id: 'Accueil', hash: '#/', steps: [], depth: 0 }] }, + { name: 'google-drive', owns: /^#\/drive/, screens: DRIVE }, + { name: 'fichiers-applications', owns: /^#\/(fichiers|applis)/, screens: [{ id: 'Fichiers', hash: '#/fichiers', steps: [], depth: 0 }, { id: 'Applications', hash: '#/applis', steps: [], depth: 0 }] }, + { name: 'historique-securite-reglages', owns: /^#\/(historique|securite|reglages)/, screens: [ + { id: 'Historique', hash: '#/historique', steps: [], depth: 0 }, { id: 'Sécurité', hash: '#/securite', steps: [], depth: 0 }, + { id: 'Réglages', hash: '#/reglages', steps: [], depth: 0 }] }, +]; +const MAX_SCREENS = Number(process.env.RECETTE_MAX_SCREENS || 30); // par groupe +const MAX_DIALOG_DEPTH = 2; +const BUDGET_MS = Number(process.env.RECETTE_EXPLORE_MS || 30 * 60_000); // par groupe + +async function replay(page: Page, s: Screen) { + for (const key of s.steps) { + const inv = await inventory(page); + const f = inv.find((x) => x.key === key); + if (!f) throw new Error(`Étape introuvable « ${key} » pour l’écran « ${s.id} »`); + await page.locator(`[data-recette-idx="${f.idx}"]`).click({ timeout: 5000 }); + await settle(page); + } +} + +for (const group of GROUPS) test(`exploration systématique · ${group.name}`, async ({ context, page }, info) => { + test.setTimeout(BUDGET_MS + 10 * 60_000); + const { rec, fake, stop } = await startSession(context, page, info, 'exploration-' + group.name); + const rows: MatrixRow[] = []; + const screens: (Screen & { found: number; tested: number; error?: string })[] = []; + const queue: Screen[] = [...group.screens]; + const known = new Set(queue.map((s) => s.id)); + const knownHashes = new Set(queue.map((s) => s.hash)); + const dialogTitles = new Set(); + const t0 = Date.now(); + const packet = fake.seeds[0].bytes; // sauvegarde chiffrée fictive pour tout sélecteur de fichier + const reset = async (s: Screen) => { + fake.reset(); + await clearOrigin(page); + await openApp(page, s.hash); + await settle(page, 1000); + await replay(page, s); + }; + try { + while (queue.length) { + const screen = queue.shift()!; + rec.context = screen.id; + let list: Found[]; + try { await reset(screen); list = await inventory(page); } + catch (e) { screens.push({ ...screen, found: 0, tested: 0, error: String((e as Error).message).slice(0, 300) }); continue; } + await rec.shot(page, screen.id + ' (écran)'); + const entry = { ...screen, found: list.length, tested: 0 }; + screens.push(entry); + for (const item of list) { + const row: MatrixRow = { browser: rec.browser, screen: screen.id, element: item.label, kind: item.kind, selector: item.selector, result: 'NON TESTABLE', detail: '' }; + rows.push(row); + if (Date.now() - t0 > BUDGET_MS) { row.detail = 'non exécuté : budget de temps de l’exploration épuisé'; continue; } + if (item.external) { row.detail = `lien externe (${item.href}) : non suivi, navigation externe bloquée`; continue; } + rec.context = `${screen.id} › ${item.label}`; + const started = Date.now(); + try { + await reset(screen); + const cur = (await inventory(page)).find((x) => x.key === item.key); + if (!cur) { row.detail = 'élément transitoire : absent après réinitialisation (flux animé déjà avancé ou terminé), non rejouable de façon fiable'; continue; } + if (cur.disabled) { + row.result = cur.explanation ? 'OK' : 'DÉSACTIVÉ-SANS-EXPLICATION'; + row.detail = cur.explanation ? `désactivé, explication visible (${cur.explanation})` : 'désactivé, aucune explication visible (ni aria-describedby, ni title, ni texte d’aide voisin)'; + entry.tested++; row.screenshot = await rec.shot(page, `${screen.id} ${item.label} désactivé`); continue; + } + const before = await probe(page, cur.idx); + const focusOf = (i: number) => page.evaluate((x) => { const a = document.activeElement; return !a || a === document.body ? 'body' : a.getAttribute('data-recette-idx') === String(x) ? 'cible' : a.outerHTML.slice(0, 80); }, i).catch(() => ''); + const focusBefore = await focusOf(cur.idx); + const errorsBefore = rec.errors.length; + const events: string[] = []; + const onDownload = (d: { suggestedFilename(): string }) => events.push('téléchargement ' + d.suggestedFilename()); + const onPopup = (p: Page) => { events.push('nouvel onglet ' + p.url()); void p.close().catch(() => {}); }; + const onChooser = (c: { setFiles(f: unknown): Promise }) => { events.push('sélecteur de fichier ouvert'); void c.setFiles({ name: 'recette-fictive.json', mimeType: 'application/json', buffer: packet }).catch(() => {}); }; + const onLoad = () => events.push('page rechargée'); + page.on('download', onDownload); page.on('popup', onPopup); page.on('filechooser', onChooser); page.on('load', onLoad); + await arm(page); + const target = page.locator(`[data-recette-idx="${cur.idx}"]`); + let typed: string | null = null; + try { + if (/^champ:(text|search|email|url|tel|password|number)$|^zone-texte$/.test(cur.kind)) { + typed = cur.kind === 'champ:number' ? '42' : SAMPLE_TEXTS[rows.length % SAMPLE_TEXTS.length]; + await target.fill(typed, { timeout: 5000 }); + } else if (cur.kind === 'liste') { + await target.selectOption({ index: 1 }, { timeout: 5000 }).catch(() => target.selectOption({ index: 0 })); + } else if (cur.kind === 'champ:file') { + await target.setInputFiles({ name: 'recette-fictive.json', mimeType: 'application/json', buffer: packet }); + } else { + // Élément hors écran tant qu'il n'a pas le focus (lien d'évitement) : activation au clavier, comme un utilisateur. + const keyboard = async (why: string) => { await target.focus(); await page.keyboard.press('Enter'); events.push('activé au clavier (' + why + ')'); }; + if (cur.offscreen) await keyboard('placé hors écran tant qu’il n’a pas le focus'); + else { + try { await target.click({ timeout: 4000 }); } + catch (e) { + if (!/Timeout|outside of the viewport|intercepts pointer/.test(String(e))) throw e; + await keyboard('clic souris impossible : ' + String((e as Error).message).split('\n')[0].replace(/^locator\.click: /, '').slice(0, 60)); + } + } + } + } catch (e) { + row.result = 'KO'; row.detail = 'action impossible : ' + String((e as Error).message).split('\n')[0].slice(0, 200); + } + await settle(page); + const after = await probe(page, cur.idx); + const focusAfter = await focusOf(cur.idx); + page.off('download', onDownload); page.off('popup', onPopup); page.off('filechooser', onChooser); page.off('load', onLoad); + const errs = rec.since(errorsBefore); + row.screenshot = await rec.shot(page, `${screen.id} ${item.label}`); + entry.tested++; + if (row.result === 'KO') continue; + const signals: string[] = [...events]; + if (after.hash !== before.hash) signals.push('navigation vers ' + after.hash); + if (after.dialogs > before.dialogs) signals.push('fenêtre ouverte « ' + after.dialogTitle + ' »'); + if (after.dialogs < before.dialogs) signals.push('fenêtre fermée'); + if (after.mutations > 0) signals.push(after.mutations + ' modification(s) du DOM'); + if (after.scrolled) signals.push('défilement'); + if (focusAfter !== focusBefore && focusAfter !== 'cible' && focusAfter !== 'body') signals.push('focus déplacé vers ' + focusAfter.replace(/\s+/g, ' ').slice(0, 60)); + if (typed !== null && after.value !== null) signals.push(`saisie acceptée (${after.value.length} car.)`); + let verdict: Verdict = 'OK'; + const unexplained = errs.filter((e) => !explain(e).expected); + if (errs.length > unexplained.length) signals.push(`${errs.length - unexplained.length} erreur(s) console attendue(s) (voir errors.json)`); + let detail = signals.join(' ; '); + if (unexplained.length) { verdict = 'KO'; detail = `${unexplained.length} erreur(s) inexpliquée(s) : ` + unexplained.map((e) => `[${e.type}] ${e.text}`).join(' | ').slice(0, 400) + (detail ? ' — ' + detail : ''); } + else if (!signals.length) { + if (cur.current) detail = 'lien vers l’écran déjà affiché : aucune action attendue'; + else { verdict = 'KO'; detail = 'aucune réaction visible (ni DOM, ni navigation, ni défilement, ni téléchargement)'; } + } + row.result = verdict; row.detail = detail; + // Découverte : nouvel écran (route) ou nouvelle fenêtre modale à explorer à son tour. + if (known.size < MAX_SCREENS) { + if (after.hash !== before.hash && !knownHashes.has(after.hash) && group.owns.test(after.hash)) { + const id = 'Route ' + after.hash; knownHashes.add(after.hash); known.add(id); queue.push({ id, hash: after.hash, steps: [], depth: 0 }); + } else if (after.dialogs > before.dialogs && screen.depth < MAX_DIALOG_DEPTH) { + // Une même fenêtre (même titre) n'est explorée qu'une fois par groupe, depuis le premier écran qui l'ouvre. + const title = after.dialogTitle || item.label, id = `${screen.id} › fenêtre « ${title} »`; + if (!dialogTitles.has(title)) { dialogTitles.add(title); known.add(id); queue.push({ id, hash: screen.hash, steps: [...screen.steps, item.key], depth: screen.depth + 1 }); } + } + } + } catch (e) { + row.result = 'KO'; row.detail = 'erreur du banc de recette : ' + String((e as Error).message).split('\n')[0].slice(0, 200); + } finally { + row.ms = Date.now() - started; + if (process.env.RECETTE_DEBUG) console.log(`${row.ms} ms · ${screen.id} · ${item.label} · ${row.result} · ${row.detail.slice(0, 120)}`); + } + } + } + } finally { + rec.write('exploration-' + group.name, { browser: rec.browser, durationMs: Date.now() - t0, screens: screens.map(({ id, hash, steps, found, tested, error }) => ({ id, hash, steps, found, tested, error })), rows, + blockedExternal: [...new Set(fake.blocked.map((b) => new URL(b.url).origin))] }); + rec.write('exploration-' + group.name + '-errors', rec.errors); + await stop(); + } +}); diff --git a/tests/recette/explore.ts b/tests/recette/explore.ts new file mode 100644 index 0000000..d637543 --- /dev/null +++ b/tests/recette/explore.ts @@ -0,0 +1,125 @@ +// Recette Phase T — inventaire et activation systématique des éléments interactifs. +import type { Page } from '@playwright/test'; + +export interface Found { + idx: number; key: string; kind: string; label: string; selector: string; + external: boolean; href: string | null; disabled: boolean; explanation: string; current: boolean; offscreen: boolean; + inputType: string | null; +} + +/** + * Inventaire DANS la page : boutons, liens, champs visibles du premier plan + * (la feuille modale ouverte la plus haute, sinon le document). Chaque cible reçoit + * data-recette-idx pour être activée ensuite par Playwright comme un utilisateur. + */ +export async function inventory(page: Page): Promise { + return page.evaluate(() => { + const open = [...document.querySelectorAll('dialog[open]')] as HTMLElement[]; + const root: ParentNode = open.at(-1) ?? document; + document.querySelectorAll('[data-recette-idx]').forEach((e) => e.removeAttribute('data-recette-idx')); + const SEL = 'a[href], button, input, select, textarea, summary, [role=button], [role=link], [role=tab], [role=radio], [role=checkbox], [role=switch], [role=menuitem], [role=option], [contenteditable=true]'; + const visible = (e: Element) => { + const r = (e as HTMLElement).getBoundingClientRect(); + if (r.width < 1 || r.height < 1) return false; + const anyE = e as HTMLElement & { checkVisibility?: (o: unknown) => boolean }; + if (anyE.checkVisibility && !anyE.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true })) return false; + const cs = getComputedStyle(e); + return cs.visibility !== 'hidden' && cs.display !== 'none' && Number(cs.opacity) > 0.05; + }; + const text = (e: Element | null) => (e?.textContent || '').replace(/\s+/g, ' ').trim(); + const labelOf = (e: HTMLElement) => { + const by = e.getAttribute('aria-labelledby'); + const fromLabels = (e as HTMLInputElement).labels ? [...((e as HTMLInputElement).labels ?? [])].map(text).join(' ') : ''; + return (e.getAttribute('aria-label') || (by ? by.split(/\s+/).map((id) => text(document.getElementById(id))).join(' ') : '') || + fromLabels || (e as HTMLElement).innerText?.replace(/\s+/g, ' ').trim() || e.getAttribute('placeholder') || e.getAttribute('title') || + (e as HTMLInputElement).value || e.getAttribute('name') || e.tagName.toLowerCase()).slice(0, 90); + }; + const cssPath = (e: Element) => { + const parts: string[] = []; + for (let n: Element | null = e; n && n !== document.body && parts.length < 6; n = n.parentElement) { + if (n.id) { parts.unshift('#' + n.id); break; } + const p = n.parentElement; const same = p ? [...p.children].filter((c) => c.tagName === n!.tagName) : []; + const cls = [...n.classList].filter((c) => !/^(view-enter|stagger)$/.test(c)).slice(0, 2).map((c) => '.' + c).join(''); + parts.unshift(n.tagName.toLowerCase() + cls + (same.length > 1 ? `:nth-of-type(${same.indexOf(n) + 1})` : '')); + } + return parts.join(' > '); + }; + const explanation = (e: HTMLElement) => { + const by = e.getAttribute('aria-describedby'); + const desc = by ? by.split(/\s+/).map((id) => text(document.getElementById(id))).join(' ') : ''; + if (desc) return 'aria-describedby : ' + desc; + if (e.title) return 'title : ' + e.title; + // Texte d'aide visible dans le même bloc (heuristique : role=status / alert, .hint, .muted, .row-sub, .note). + const block = e.closest('.set-row, .panel-body, .field, .sheet-body, form, section, li') as HTMLElement | null; + const hint = block ? [...block.querySelectorAll('[role=status], [role=alert], .hint, .muted, .row-sub, .note, .help, small')].find((h) => visible(h) && text(h)) : null; + return hint ? 'texte voisin : ' + text(hint).slice(0, 120) : ''; + }; + const seen = new Set(); const counts = new Map(); const out: unknown[] = []; + for (const raw of [...root.querySelectorAll(SEL)] as HTMLElement[]) { + if (seen.has(raw) || raw.closest('[inert]') || raw.closest('[aria-hidden="true"]')) continue; + let target: HTMLElement = raw; + if (!visible(raw)) { + // Champ masqué visuellement mais activable par son libellé (cases / boutons radio stylés). + const lab = (raw as HTMLInputElement).labels?.[0]; + if (raw.tagName === 'INPUT' && lab && visible(lab) && (raw as HTMLInputElement).type !== 'file') target = lab; else continue; + } + seen.add(raw); + const tag = raw.tagName.toLowerCase(), type = tag === 'input' ? ((raw as HTMLInputElement).type || 'text') : null; + const role = raw.getAttribute('role'); + const kind = tag === 'a' ? 'lien' : tag === 'button' ? (role === 'radio' ? 'bouton-radio' : role === 'tab' ? 'onglet' : 'bouton') + : tag === 'input' ? 'champ:' + type : tag === 'select' ? 'liste' : tag === 'textarea' ? 'zone-texte' : tag === 'summary' ? 'résumé' : 'role:' + (role || tag); + const label = labelOf(raw); + // Clé stable entre réinitialisations : les dates relatives de la démonstration (heures, minutes) varient. + const base = kind + '|' + label.replace(/\d+/g, '#'); const n = (counts.get(base) ?? 0) + 1; counts.set(base, n); + const href = tag === 'a' ? (raw as HTMLAnchorElement).href : null; + let external = false; + if (href) { try { const u = new URL(href, location.href); external = u.origin !== location.origin || (raw as HTMLAnchorElement).target === '_blank'; } catch { external = true; } } + const disabled = !!((raw as HTMLButtonElement).disabled || raw.getAttribute('aria-disabled') === 'true' || raw.closest('fieldset[disabled]')); + const idx = out.length; + target.setAttribute('data-recette-idx', String(idx)); + const rect = target.getBoundingClientRect(); + const offscreen = rect.bottom + scrollY <= 0 || rect.right + scrollX <= 0; + out.push({ idx, key: base + '#' + n, kind, label, selector: cssPath(raw), external, href, disabled, explanation: disabled ? explanation(raw) : '', + offscreen, current: !!href && !external && new URL(href, location.href).hash === (location.hash || '#/'), inputType: type }); + } + return out as never; + }); +} + +export interface Probe { mutations: number; scrolled: boolean; hash: string; dialogs: number; dialogTitle: string; value: string | null } + +/** Démarre l'observation (mutations DOM, défilement) avant une action. */ +export async function arm(page: Page) { + await page.evaluate(() => { + const w = window as unknown as { __recette?: { m: number; s: number; obs: MutationObserver } }; + w.__recette?.obs.disconnect(); + const rec = { m: 0, s: 0, obs: new MutationObserver((list) => { rec.m += list.filter((x) => !(x.type === 'attributes' && x.attributeName === 'data-recette-idx')).length; }) }; + rec.obs.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true }); + w.__recette = rec; + if (!(window as unknown as { __recetteScroll?: boolean }).__recetteScroll) { + (window as unknown as { __recetteScroll?: boolean }).__recetteScroll = true; + addEventListener('scroll', () => { const r = (window as unknown as { __recette?: { s: number } }).__recette; if (r) r.s++; }, { capture: true, passive: true }); + } + }); +} + +export async function probe(page: Page, idx?: number): Promise { + return page.evaluate((i) => { + const w = window as unknown as { __recette?: { m: number; s: number } }; + const open = [...document.querySelectorAll('dialog[open]')]; + const el = i === undefined ? null : document.querySelector(`[data-recette-idx="${i}"]`) as HTMLInputElement | null; + return { mutations: w.__recette?.m ?? 0, scrolled: (w.__recette?.s ?? 0) > 0, hash: location.hash, dialogs: open.length, + dialogTitle: (open.at(-1)?.querySelector('h2, h1')?.textContent || open.at(-1)?.getAttribute('aria-label') || '').trim(), value: el && 'value' in el ? String(el.value) : null }; + }, idx).catch(() => ({ mutations: 0, scrolled: false, hash: '', dialogs: 0, dialogTitle: '', value: null })); +} + +/** Générateur pseudo-aléatoire reproductible (mulberry32). */ +export function rng(seed: number) { + let a = seed >>> 0; + return () => { a = (a + 0x6d2b79f5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; +} + +export const SAMPLE_TEXTS = [ + 'recette fictive', 'Révision 42 — été', '', '"; DROP TABLE x; --', '😀 émoji ✓', ' ', '0', '-1', + 'a'.repeat(300), '../../etc/passwd', 'javascript:alert(1)', '%00%0a', 'ÀÉÎÕÜ ß ç', '١٢٣ عربى', ' espaces ', +]; diff --git a/tests/recette/fake-google.ts b/tests/recette/fake-google.ts new file mode 100644 index 0000000..792ca96 --- /dev/null +++ b/tests/recette/fake-google.ts @@ -0,0 +1,163 @@ +// Recette Phase T — Google entièrement simulé. +// Aucune requête ne quitte le navigateur : tout est servi ou bloqué par les routes Playwright. +// Repris de tests/e2e/connected-drive.spec.ts (même configuration publique fictive, +// mêmes faux scripts GIS / Picker, même faux Drive) et de tests/fixtures/race-packet.ts +// (paquets Race Control fictifs, chiffrés par le vrai codec propriétaire épinglé). +import type { BrowserContext, Request } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { packetFixture } from '../fixtures/race-packet'; +import { readRacePacket } from '../../src/core/race-packet'; + +export const ORIGIN = 'https://vault.example.com'; +export const RACE_ORIGIN = 'https://race.example.com'; +export const SCOPE = 'https://www.googleapis.com/auth/drive.file'; +export const FAKE_TOKEN = 'fixture-token'; // jeton fictif, accepté uniquement par le faux Drive ci-dessous +export const FAKE_ACCOUNT = 'recette-fictive@example.invalid'; +export const FOLDER_ID = 'folder-recette-A'; +export const FOLDER_NAME = 'Sauvegardes Race Control (fictif)'; +// Configuration publique fictive (même forme que la vraie, valeurs inventées, aucune clé réelle). +const CONFIG = { approved: true, clientId: ['123456', 'fake.apps.googleusercontent.com'].join('-'), apiKey: 'AIza' + 'a'.repeat(35), appId: '123456', raceOrigin: RACE_ORIGIN, vaultOrigin: ORIGIN }; + +export interface SeedVersion { fileId: string; hash: string; cipher: string; bytes: Buffer; trips: number } +let seedsPromise: Promise | null = null; +/** Deux sauvegardes chiffrées fictives (61 et 120 trajets), produites par le codec Race Control épinglé. */ +export function seeds(): Promise { + seedsPromise ??= (async () => { + const out: SeedVersion[] = []; + for (const trips of [61, 120]) { + const fx = await packetFixture(trips); + const packet = await readRacePacket(fx.bytes, fx.hash); + out.push({ fileId: 'version-recette-' + trips, hash: fx.hash, cipher: packet.info.cipherSha256, bytes: Buffer.from(fx.bytes), trips }); + } + return out; + })(); + return seedsPromise; +} + +const TYPES: Record = { js: 'text/javascript', css: 'text/css', woff2: 'font/woff2', json: 'application/json', webmanifest: 'application/manifest+json', png: 'image/png', svg: 'image/svg+xml', html: 'text/html' }; + +export interface Traffic { url: string; method: string; at: number } + +/** + * Installe l'application (dist/) sur une origine HTTPS fictive et un Google/Drive simulé. + * Toute autre requête est interrompue et consignée dans `blocked`. + */ +export interface FakeOptions { + /** Réponse du faux consentement Google : accordé, fenêtre fermée par l'utilisateur, ou autorisation Drive décochée. */ + consent?: 'granted' | 'popup-closed' | 'scope-refused'; + /** Réponse du faux Google Picker : dossier choisi ou annulation par l'utilisateur. */ + picker?: 'picked' | 'cancel'; +} +export async function installFakeGoogle(context: BrowserContext, options: FakeOptions = {}) { + const consent = options.consent ?? 'granted', pickerAction = options.picker ?? 'picked'; + const vs = await seeds(); + type Entry = { meta: Record; bytes: Buffer }; + const state = { + files: new Map(), pending: new Map>(), + folders: new Map>(), next: 0, + }; + const blocked: Traffic[] = [], googleCalls: Traffic[] = []; + const reset = () => { + state.files.clear(); state.pending.clear(); state.folders.clear(); state.next = 0; + state.folders.set(FOLDER_ID, { id: FOLDER_ID, name: FOLDER_NAME, createdTime: '2026-10-20T08:00:00.000Z', mimeType: 'application/vnd.google-apps.folder', trashed: false, ownedByMe: true, appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); + for (const v of vs) state.files.set(v.fileId, { bytes: v.bytes, meta: { id: v.fileId, name: 'rc-' + v.hash + '.json', mimeType: 'application/json', parents: [FOLDER_ID], size: String(v.bytes.length), trashed: false, appProperties: { cv_contract: 'rc-v1', cv_file: v.hash, cv_cipher: v.cipher } } }); + }; + reset(); + + // 1) Filet : tout ce qui n'est pas explicitement simulé est bloqué (enregistré par Playwright en premier = évalué en dernier). + await context.route('**/*', (r) => { blocked.push({ url: r.request().url(), method: r.request().method(), at: Date.now() }); return r.abort('blockedbyclient'); }); + + // 2) Application construite (dist/) servie sur l'origine fictive, CSP élargie aux seuls hôtes Google simulés. + await context.route(ORIGIN + '/**', async (r) => { + const path = new URL(r.request().url()).pathname; + if (path === '/drive-config.json') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(CONFIG) }); + const target = resolve('dist', '.' + (path === '/' ? '/index.html' : path)); + if (!target.startsWith(resolve('dist') + '/')) return r.fulfill({ status: 404, body: '' }); + let body: Buffer; + try { body = readFileSync(target); } catch { return r.fulfill({ status: 404, contentType: 'text/plain', body: 'absent' }); } + if (path === '/' || path === '/index.html') body = Buffer.from(body.toString() + .replace("script-src 'self'", "script-src 'self' https://accounts.google.com/gsi/client https://apis.google.com") + .replace("connect-src 'self'", "connect-src 'self' https://www.googleapis.com https://accounts.google.com/gsi/")); + const ext = path === '/' ? 'html' : (path.split('.').pop() ?? ''); + return r.fulfill({ body, contentType: TYPES[ext] ?? 'application/octet-stream' }); + }); + + // 3) Faux Google Identity Services : consentement immédiat, jeton fictif en mémoire. + await context.route('https://accounts.google.com/gsi/client', (r) => { + googleCalls.push({ url: r.request().url(), method: 'GET', at: Date.now() }); + const respond = consent === 'popup-closed' ? `o.error_callback&&o.error_callback({type:'popup_closed',message:'Popup window closed'})` + : consent === 'scope-refused' ? `o.callback({access_token:'${FAKE_TOKEN}',scope:'openid',expires_in:3600})` + : `o.callback({access_token:'${FAKE_TOKEN}',scope:'${SCOPE}',expires_in:3600})`; + return r.fulfill({ contentType: 'text/javascript', body: `window.google=window.google||{};window.google.accounts={oauth2:{initTokenClient:o=>({requestAccessToken:()=>setTimeout(()=>{${respond}},50)}),hasGrantedAllScopes:(t,s)=>typeof t.scope==='string'&&t.scope.split(' ').includes(s)}};` }); + }); + + // 4) Faux Picker : choisit le dossier fictif. + await context.route('https://apis.google.com/js/api.js', (r) => { + googleCalls.push({ url: r.request().url(), method: 'GET', at: Date.now() }); + return r.fulfill({ contentType: 'text/javascript', body: ` + window.gapi={load:(name,o)=>setTimeout(()=>o.callback(),10)}; + class View{setIncludeFolders(){return this}setSelectFolderEnabled(){return this}} + class Picker{addView(){return this}setOAuthToken(){return this}setDeveloperKey(){return this}setAppId(){return this}setOrigin(){return this}setCallback(c){this.cb=c;return this}build(){return this}setVisible(v){if(v){window.recettePickerOpened=(window.recettePickerOpened||0)+1;setTimeout(()=>this.cb(${pickerAction === 'cancel' ? "{action:'cancel'}" : `{action:'picked',docs:[{id:'${FOLDER_ID}'}]}`}),50)}}} + window.google=window.google||{};window.google.picker={DocsView:View,PickerBuilder:Picker,ViewId:{FOLDERS:'folders'},Action:{PICKED:'picked',CANCEL:'cancel'}};` }); + }); + + // 5) Faux Drive v3 (métadonnées, liste, téléchargement, dossier, envoi reprenable). + await context.route('https://www.googleapis.com/**', async (r) => { + const request: Request = r.request(), u = new URL(request.url()), key = u.pathname.split('/').at(-1)!, method = request.method(); + googleCalls.push({ url: u.origin + u.pathname, method, at: Date.now() }); + const headers = { 'Access-Control-Allow-Origin': ORIGIN, 'Access-Control-Allow-Methods': 'GET,POST,PUT,OPTIONS', 'Access-Control-Allow-Headers': 'authorization,content-type,content-range,x-upload-content-type,x-upload-content-length', 'Access-Control-Expose-Headers': 'Location,Range' }; + const json = (v: unknown, status = 200) => r.fulfill({ status, headers, contentType: 'application/json', body: JSON.stringify(v) }); + if (method === 'OPTIONS') return r.fulfill({ status: 204, headers }); + if (request.headers().authorization !== 'Bearer ' + FAKE_TOKEN || u.searchParams.has('access_token')) return json({ error: 'unauthenticated' }, 401); + if (key === 'about') return json({ user: { permissionId: 'recette_account', emailAddress: FAKE_ACCOUNT } }); + if (key === 'generateIds') return json({ ids: ['recette-gen-' + (++state.next)] }); + if (method === 'POST' && u.pathname === '/drive/v3/files') { + const m = request.postDataJSON() as Record; + if (state.folders.has(String(m.id))) return json({}, 409); + state.folders.set(String(m.id), { id: m.id, name: m.name, createdTime: new Date().toISOString(), mimeType: m.mimeType, trashed: false, ownedByMe: true, appProperties: m.appProperties, capabilities: { canAddChildren: true } }); + return json({ id: m.id }); + } + if (method === 'POST') { + const m = request.postDataJSON() as Record, id = String(m.id); + if (state.files.has(id) || state.pending.has(id)) return json({}, 409); + state.pending.set(id, m); + return r.fulfill({ status: 200, headers: { ...headers, Location: 'https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&upload_id=' + id } }); + } + if (method === 'PUT') { + const id = u.searchParams.get('upload_id')!, meta = state.pending.get(id), bytes = request.postDataBuffer(); + if (!meta || !bytes) return json({}, 404); + state.files.set(id, { meta: { ...meta, size: String(bytes.length), trashed: false }, bytes }); state.pending.delete(id); + return json({ id }); + } + if (state.folders.has(key)) return json(state.folders.get(key)); + if (u.pathname === '/drive/v3/files') { + const q = u.searchParams.get('q') || ''; + if (q.includes("value='rc-folder-v1'")) return json({ incompleteSearch: false, files: [...state.folders.values()] }); + const parent = /'([^']+)' in parents/.exec(q)?.[1]; + const wanted = /value='([^']+)'/.exec(q)?.[1]; + return json({ incompleteSearch: false, files: [...state.files.values()].map((f) => f.meta).filter((m) => (!parent || (m.parents as string[]).includes(parent)) && (!wanted || (m.appProperties as Record).cv_contract === wanted)) }); + } + const file = state.files.get(key); + if (!file) return json({}, 404); + return u.searchParams.get('alt') === 'media' ? r.fulfill({ headers, contentType: 'application/json', body: file.bytes }) : json(file.meta); + }); + + return { state, reset, blocked, googleCalls, seeds: vs }; +} + +/** Émulation PWA iPhone « écran d'accueil » : display-mode standalone + navigator.standalone. */ +export const STANDALONE_INIT = `(() => { + try { + const original = window.matchMedia.bind(window); + window.matchMedia = (query) => { + const result = original(query); + if (/\\(\\s*display-mode\\s*:\\s*standalone\\s*\\)/.test(query)) { + return { matches: true, media: query, onchange: null, + addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {}, dispatchEvent() { return false; } }; + } + return result; + }; + Object.defineProperty(Navigator.prototype, 'standalone', { configurable: true, get: () => true }); + } catch (e) { /* l'émulation ne doit jamais casser la page */ } +})();`; diff --git a/tests/recette/monkey.recette.ts b/tests/recette/monkey.recette.ts new file mode 100644 index 0000000..925e076 --- /dev/null +++ b/tests/recette/monkey.recette.ts @@ -0,0 +1,81 @@ +// Recette Phase T — robustesse : session de clics aléatoires (« monkey », façon gremlins.js, écrit ici). +// Éléments visibles au hasard, textes aléatoires dans les champs, touches clavier, tapes à des +// coordonnées aléatoires. Toute navigation externe est bloquée (routes) ; les onglets ouverts sont fermés. +import { test, expect, type Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { ORIGIN } from './fake-google'; +import { inventory, rng, SAMPLE_TEXTS } from './explore'; +import { clearOrigin, isIphone, openApp, startSession } from './session'; + +const DURATION = Number(process.env.RECETTE_MONKEY_MS || 5 * 60_000); +const HASHES = ['#/', '#/drive', '#/fichiers', '#/applis', '#/historique', '#/securite', '#/reglages']; +const KEYS = ['Escape', 'Tab', 'Shift+Tab', 'Enter', 'Space', 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', '?', 'g', 'f', 'h', '/', 'Control+k']; + +test('monkey : clics aléatoires pendant ' + Math.round(DURATION / 60000) + ' min', async ({ context, page }, info) => { + test.setTimeout(DURATION + 5 * 60_000); + const { rec, fake, stop } = await startSession(context, page, info, 'monkey'); + const seed = Number(process.env.RECETTE_SEED || 20261029) + (isIphone(info) ? 1 : 0); + const rand = rng(seed), pick = (a: T[]) => a[Math.floor(rand() * a.length)]; + const packet = readFileSync('tests/fixtures/race-owner/business-61.json'); + const log: { n: number; t: number; action: string; url: string; errors: number }[] = []; + const counts: Record = {}; + let n = 0, recoveries = 0; + page.on('popup', (p: Page) => { void p.close().catch(() => {}); }); + page.on('filechooser', (c) => { void (rand() < 0.5 ? c.setFiles({ name: 'monkey.json', mimeType: 'application/json', buffer: rand() < 0.5 ? packet : Buffer.from(String(rand())) }) : c.setFiles([])).catch(() => {}); }); + page.on('download', (d) => { void d.cancel().catch(() => {}); }); + const t0 = Date.now(); + try { + await clearOrigin(page); await openApp(page, '#/'); + while (Date.now() - t0 < DURATION) { + n++; + let action = ''; + const before = rec.errors.length; + rec.context = `monkey #${n}`; + try { + if (!page.url().startsWith(ORIGIN + '/') || page.url().includes('drive-config.json')) { recoveries++; action = 'retour sur l’application'; await openApp(page, pick(HASHES)); } + else { + const r = rand(); + if (r < 0.62) { + const els = (await inventory(page)).filter((e) => !e.disabled); + if (!els.length) { action = 'aucun élément : Échap'; await page.keyboard.press('Escape'); } + else { + const e = pick(els); + action = `clic ${e.kind} « ${e.label} »`; + const loc = page.locator(`[data-recette-idx="${e.idx}"]`); + if (/^champ:(text|search|email|url|tel|password|number)$|^zone-texte$/.test(e.kind)) { const t = pick(SAMPLE_TEXTS); action = `saisie « ${t.slice(0, 30)} » dans « ${e.label} »`; await loc.fill(t, { timeout: 2000 }); } + else await loc.click({ timeout: 2000, trial: false }); + } + } else if (r < 0.75) { + const vp = page.viewportSize() ?? { width: 400, height: 800 }; + const x = Math.floor(rand() * vp.width), y = Math.floor(rand() * vp.height); + action = `tape (${x}, ${y})`; + if (isIphone(info)) await page.touchscreen.tap(x, y); else await page.mouse.click(x, y); + } else if (r < 0.88) { + const k = pick(KEYS); action = 'touche ' + k; await page.keyboard.press(k); + } else if (r < 0.95) { + const dy = Math.floor((rand() - 0.3) * 1500); action = 'défilement ' + dy; await page.mouse.wheel(0, dy).catch(() => page.evaluate((d) => scrollBy(0, d), dy)); + } else if (r < 0.98) { + const h = pick(HASHES); action = 'adresse ' + h; await page.evaluate((x) => { location.hash = x; }, h); + } else { + action = 'retour arrière'; await page.goBack({ timeout: 3000 }).catch(() => {}); + } + } + } catch (e) { action += ' (sans effet : ' + String((e as Error).message).split('\n')[0].slice(0, 80) + ')'; } + await page.waitForTimeout(120 + Math.floor(rand() * 200)); + const kind = action.split(' ')[0]; counts[kind] = (counts[kind] ?? 0) + 1; + log.push({ n, t: Date.now() - t0, action, url: page.url().replace(ORIGIN, ''), errors: rec.errors.length - before }); + if (rec.errors.length > before || n % 25 === 0) await rec.shot(page, `${n} ${action}`); + } + // L'application doit rester utilisable après la session. + rec.context = 'monkey › contrôle final'; + await openApp(page, '#/'); + await expect(page.locator('#main h1').first()).toBeVisible(); + await rec.shot(page, 'contrôle final'); + } finally { + rec.write('monkey', { browser: rec.browser, seed, durationMs: Date.now() - t0, actions: n, recoveries, counts, + googleCalls: fake.googleCalls.length, blockedOrigins: [...new Set(fake.blocked.map((b) => { try { return new URL(b.url).origin; } catch { return b.url; } }))], + errorActions: log.filter((l) => l.errors), last: log.slice(-60) }); + rec.write('monkey-errors', rec.errors); + await stop(); + } +}); diff --git a/tests/recette/parcours.recette.ts b/tests/recette/parcours.recette.ts new file mode 100644 index 0000000..7a2fb22 --- /dev/null +++ b/tests/recette/parcours.recette.ts @@ -0,0 +1,97 @@ +// Recette Phase T — parcours utilisateur complet, cliqué comme un utilisateur. +// connexion (Google simulé) → choix du dossier → liste des versions → « Vérifier et télécharger ». +import { test, expect } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { FAKE_ACCOUNT, FOLDER_ID, FOLDER_NAME } from './fake-google'; +import { clearOrigin, isIphone, openApp, settle, startSession } from './session'; + +test('parcours utilisateur : connexion Google simulée → dossier → versions → vérifier et télécharger', async ({ context, page }, info) => { + test.setTimeout(180_000); + const { rec, fake, stop } = await startSession(context, page, info, 'parcours'); + const steps: { step: string; status: 'OK' | 'KO'; ms: number; screenshot?: string; error?: string }[] = []; + const step = async (name: string, fn: () => Promise) => { + rec.context = 'parcours › ' + name; + const t0 = Date.now(); + try { + await test.step(name, fn); + await settle(page, 1500); + steps.push({ step: name, status: 'OK', ms: Date.now() - t0, screenshot: await rec.shot(page, name) }); + } catch (e) { + steps.push({ step: name, status: 'KO', ms: Date.now() - t0, screenshot: await rec.shot(page, 'ECHEC ' + name), error: String((e as Error).message).slice(0, 500) }); + throw e; + } + }; + const mobile = isIphone(info), main = page.locator('#main'); + const [v61, v120] = fake.seeds; + let downloadedHash = '', downloadName = ''; + try { + await step('Ouverture de l’application (origine HTTPS fictive)', async () => { + await clearOrigin(page); await openApp(page, '#/'); + if (mobile) { + expect(await page.evaluate(() => matchMedia('(display-mode: standalone)').matches && (navigator as unknown as { standalone?: boolean }).standalone === true), 'émulation PWA standalone').toBe(true); + await expect(page.locator('.tabbar')).toBeVisible(); + } + }); + await step('Accès à l’espace Google Drive', async () => { + if (mobile) { + await page.locator('.tabbar').getByRole('link', { name: 'Réglages' }).click(); + await page.getByRole('link', { name: /Copies chiffrées Race Control/ }).click(); + } else { + await page.locator('.sidebar').getByRole('link', { name: 'Google Drive', exact: true }).click(); + } + await expect(page.getByRole('heading', { name: 'Google Drive', exact: true })).toBeVisible(); + await expect(main.getByText('Prêt à connecter Google Drive')).toBeVisible(); + }); + await step('Connexion : préparer', async () => { + await page.getByRole('button', { name: 'Préparer la connexion Google' }).click(); + await expect(main.getByText('Connexion prête')).toBeVisible(); + }); + await step('Connexion : se connecter à Google (consentement simulé)', async () => { + await page.getByRole('button', { name: 'Se connecter à Google', exact: true }).click(); + await expect(main.getByText('Compte Google sélectionné : ' + FAKE_ACCOUNT)).toBeVisible(); + }); + await step('Connexion : confirmer le compte dédié', async () => { + await page.getByRole('button', { name: 'Confirmer ce compte dédié' }).click(); + await expect(main.getByText('Compte confirmé')).toBeVisible(); + }); + await step('Choix du dossier : retrouver les dossiers dédiés', async () => { + await page.getByRole('button', { name: 'Retrouver les dossiers dédiés' }).click(); + await expect(page.getByRole('region', { name: 'Dossiers dédiés autorisés' }).getByText(FOLDER_NAME, { exact: true })).toBeVisible(); + }); + await step('Choix du dossier : utiliser ce dossier', async () => { + await page.getByRole('region', { name: 'Dossiers dédiés autorisés' }).getByRole('button', { name: 'Utiliser ce dossier' }).click(); + await expect(main.getByText('Dossier dédié sélectionné : ' + FOLDER_ID)).toBeVisible(); + }); + await step('Liste des versions : actualiser', async () => { + await page.getByRole('button', { name: 'Actualiser les versions' }).click(); + await expect(main.getByText('2 copies distantes listées')).toBeVisible(); + for (const v of [v61, v120]) await expect(page.locator('.drive-entry').filter({ hasText: v.hash.slice(0, 12) })).toContainText('non vérifiée'); + }); + await step('Vérifier et télécharger (copie fictive 61 trajets)', async () => { + const row = page.locator('.drive-entry').filter({ hasText: v61.hash.slice(0, 12) }); + const download = page.waitForEvent('download'); + await row.getByRole('button', { name: 'Vérifier et télécharger' }).click(); + const file = await download; + downloadName = file.suggestedFilename(); + const bytes = readFileSync(await file.path()); + downloadedHash = createHash('sha256').update(bytes).digest('hex'); + expect(downloadName).toBe('race-control-' + v61.hash.slice(0, 12) + '.json'); + expect(downloadedHash, 'SHA-256 du fichier téléchargé = SHA-256 de la copie fictive').toBe(v61.hash); + await expect(row).toContainText('octets vérifiés le'); + await expect(main.getByText('Octets vérifiés et copie chiffrée gardée sur cet appareil')).toBeVisible(); + await expect(main.getByText('Octets distants vérifiés — restauration à valider dans Race Control')).toBeVisible(); + await expect(page.locator('.drive-entry').filter({ hasText: v120.hash.slice(0, 12) })).toContainText('non vérifiée'); + }); + await step('Aucun appel hors Google simulé, aucun jeton persistant', async () => { + expect(fake.blocked.filter((b) => /google|googleapis/.test(b.url)), 'appel Google réel').toEqual([]); + const storage = await page.evaluate(() => JSON.stringify({ ...localStorage, ...sessionStorage })); + expect(storage).not.toContain('fixture-token'); + }); + } finally { + rec.write('parcours', { browser: rec.browser, steps, download: { name: downloadName, sha256: downloadedHash, expected: v61.hash }, + googleCalls: fake.googleCalls.length, blocked: fake.blocked.map((b) => b.url) }); + rec.write('parcours-errors', rec.errors); + await stop(); + } +}); diff --git a/tests/recette/playwright.recette.config.ts b/tests/recette/playwright.recette.config.ts new file mode 100644 index 0000000..61fd5f8 --- /dev/null +++ b/tests/recette/playwright.recette.config.ts @@ -0,0 +1,34 @@ +// Recette Phase T — configuration Playwright SÉPARÉE (jamais exécutée par `npm run test:e2e`, +// dont le testDir est tests/e2e). Lancée uniquement par .github/workflows/recette.yml ou à la main : +// npm run build && npx playwright test -c tests/recette/playwright.recette.config.ts +// Aucun serveur : l'application construite (dist/) est servie par les routes Playwright +// sur une origine HTTPS fictive, avec Google entièrement simulé (voir fake-google.ts). +import { defineConfig, devices } from '@playwright/test'; + +const ci = !!process.env.CI; +// Hors CI uniquement : navigateur Chromium préinstallé d'une autre révision (ex. /opt/pw-browsers/chromium). +const chromiumPath = process.env.RECETTE_CHROMIUM_PATH || undefined; + +export default defineConfig({ + testDir: '.', + testMatch: /.*\.recette\.ts$/, + fullyParallel: true, + forbidOnly: ci, + retries: 0, + workers: ci ? 4 : 2, + timeout: 120_000, + expect: { timeout: 10_000 }, + outputDir: '../../test-results/recette', + reporter: [['list'], ['json', { outputFile: '../../recette-output/playwright-results.json' }], ['html', { open: 'never', outputFolder: '../../recette-output/rapport-playwright' }]], + globalSetup: './setup.ts', + globalTeardown: './report.ts', + use: { trace: 'off', acceptDownloads: true, serviceWorkers: 'block', locale: 'fr-FR', timezoneId: 'Europe/Paris' }, + projects: [ + { name: 'recette-chromium-desktop', use: { ...devices['Desktop Chrome'], viewport: { width: 1440, height: 900 }, launchOptions: chromiumPath ? { executablePath: chromiumPath } : {} } }, + { name: 'recette-webkit-iphone', use: { ...devices['iPhone 15'], browserName: 'webkit' } }, + // Contrôle local optionnel quand WebKit est absent : même gabarit iPhone, moteur Chromium (n'est PAS une preuve WebKit). + ...(process.env.RECETTE_LOCAL_IPHONE_CHROMIUM === '1' + ? [{ name: 'recette-chromium-iphone-local', use: { ...devices['iPhone 15'], browserName: 'chromium' as const, launchOptions: chromiumPath ? { executablePath: chromiumPath } : {} } }] + : []), + ], +}); diff --git a/tests/recette/recorder.ts b/tests/recette/recorder.ts new file mode 100644 index 0000000..be7b758 --- /dev/null +++ b/tests/recette/recorder.ts @@ -0,0 +1,66 @@ +// Recette Phase T — collecte des erreurs console / exceptions JS, captures d'écran et sorties. +import type { Page, TestInfo } from '@playwright/test'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export const OUT = process.env.RECETTE_OUTPUT || 'recette-output'; + +export type Verdict = 'OK' | 'KO' | 'DÉSACTIVÉ-SANS-EXPLICATION' | 'NON TESTABLE'; +export interface MatrixRow { + browser: string; screen: string; element: string; kind: string; selector: string; + result: Verdict; detail: string; screenshot?: string; ms?: number; +} +export interface ErrorRow { + browser: string; phase: string; context: string; type: 'console' | 'pageerror' | 'dialog'; + text: string; location?: string; at: string; + /** Erreur reçue pendant une capture d'écran Playwright (voir error-rules.ts). */ + pendantCapture?: boolean; +} + +/** Contexte courant (phase / écran / élément) attaché à chaque erreur capturée. */ +export class Recorder { + readonly errors: ErrorRow[] = []; + context = ''; + private shotCount = 0; + private shooting = 0; + constructor(readonly browser: string, readonly phase: string) { + mkdirSync(join(OUT, 'screenshots', browser, phase), { recursive: true }); + mkdirSync(join(OUT, 'traces'), { recursive: true }); + mkdirSync(join(OUT, 'partials'), { recursive: true }); + } + static browserName(info: TestInfo) { return info.project.name.replace(/^recette-/, ''); } + + attach(page: Page) { + page.on('console', (m) => { + if (m.type() !== 'error') return; + const l = m.location(); + this.push({ type: 'console', text: m.text(), location: l?.url ? `${l.url}:${l.lineNumber}:${l.columnNumber}` : undefined }); + }); + page.on('pageerror', (e) => this.push({ type: 'pageerror', text: `${e.name}: ${e.message}`, location: (e.stack || '').split('\n').slice(1, 3).join(' | ').trim() || undefined })); + page.on('dialog', (d) => { this.push({ type: 'dialog', text: `${d.type()} : ${d.message()}` }); void d.dismiss().catch(() => {}); }); + } + private push(e: Omit) { + this.errors.push({ browser: this.browser, phase: this.phase, context: this.context, at: new Date().toISOString(), ...e, ...(this.shooting ? { pendantCapture: true } : {}) }); + } + /** Erreurs survenues depuis l'index donné (pour attribuer une erreur à une action). */ + since(index: number) { return this.errors.slice(index); } + + async shot(page: Page, name: string): Promise { + const file = join('screenshots', this.browser, this.phase, `${String(++this.shotCount).padStart(4, '0')}-${slug(name)}.jpg`); + this.shooting++; + try { + // caret: 'initial' : sinon Playwright injecte une feuille de style refusée par la CSP stricte (cf. tests/e2e/helpers.ts). + await page.screenshot({ path: join(OUT, file), type: 'jpeg', quality: 55, caret: 'initial', timeout: 5000 }); + await page.waitForTimeout(50); // laisse arriver les messages console émis par la capture elle-même + return file; + } catch { return undefined; } finally { this.shooting--; } + } + + write(name: string, data: unknown) { + writeFileSync(join(OUT, 'partials', `${this.browser}-${name}.json`), JSON.stringify(data, null, 2)); + } +} + +export function slug(s: string) { + return s.normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[^A-Za-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60).toLowerCase() || 'x'; +} diff --git a/tests/recette/report.ts b/tests/recette/report.ts new file mode 100644 index 0000000..6ddbc37 --- /dev/null +++ b/tests/recette/report.ts @@ -0,0 +1,102 @@ +// Recette Phase T — agrégation (globalTeardown) : matrix.json, MATRICE.md, errors.json. +import { execFileSync } from 'node:child_process'; +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { OUT, type ErrorRow, type MatrixRow } from './recorder'; +import { explain } from './error-rules'; + +interface Exploration { browser: string; durationMs: number; screens: { id: string; found: number; tested: number; error?: string }[]; rows: MatrixRow[]; blockedExternal: string[] } +interface Parcours { browser: string; steps: { step: string; status: string; ms: number; screenshot?: string; error?: string }[]; download: { name: string; sha256: string; expected: string } } +interface Monkey { browser: string; seed: number; durationMs: number; actions: number; recoveries: number; blockedOrigins: string[]; errorActions: unknown[] } + +function read(file: string): T | null { + try { return JSON.parse(readFileSync(join(OUT, 'partials', file), 'utf8')) as T; } catch { return null; } +} +const pct = (n: number, d: number) => (d ? Math.round((1000 * n) / d) / 10 : 0); +const md = (s: unknown) => String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' '); + +export default async function report() { + if (!existsSync(join(OUT, 'partials'))) return; + const files = readdirSync(join(OUT, 'partials')); + const browsers = [...new Set(files.map((f) => /^(.*?)-(parcours|exploration|monkey|bugs)/.exec(f)?.[1]).filter((b): b is string => !!b))].sort(); + let sha = process.env.RECETTE_SHA || process.env.GITHUB_SHA || ''; + if (!sha) { try { sha = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); } catch { sha = 'inconnu'; } } + + const summary: Record = {}; + const rows: MatrixRow[] = []; + const errors: (ErrorRow & { explication: string; statut: 'attendue' | 'inexpliquée' })[] = []; + const parcours: Parcours[] = [], monkeys: Monkey[] = [], explorations: Exploration[] = []; + for (const b of browsers) { + const exParts = files.filter((f) => f.startsWith(b + '-exploration') && !f.endsWith('-errors.json')).map((f) => read(f)).filter((x): x is Exploration => !!x); + const ex: Exploration | null = exParts.length ? { browser: b, durationMs: Math.max(...exParts.map((x) => x.durationMs)), screens: exParts.flatMap((x) => x.screens), rows: exParts.flatMap((x) => x.rows), blockedExternal: [...new Set(exParts.flatMap((x) => x.blockedExternal))] } : null; + const pa = read(`${b}-parcours.json`), mo = read(`${b}-monkey.json`); + if (ex) { explorations.push(ex); rows.push(...ex.rows); } + if (pa) parcours.push(pa); + if (mo) monkeys.push(mo); + for (const file of files.filter((f) => f.startsWith(b + '-') && f.endsWith('-errors.json'))) { + for (const e of read(file) ?? []) { + const x = explain(e); + errors.push({ ...e, explication: x.why, statut: x.expected ? 'attendue' : 'inexpliquée' }); + } + } + const mine = ex?.rows ?? []; + const count = (r: string) => mine.filter((x) => x.result === r).length; + const tested = mine.filter((x) => x.result !== 'NON TESTABLE').length; + summary[b] = { + trouvés: mine.length, testés: tested, couverture: pct(tested, mine.length), écrans: ex?.screens.length ?? 0, + OK: count('OK'), KO: count('KO'), 'DÉSACTIVÉ-SANS-EXPLICATION': count('DÉSACTIVÉ-SANS-EXPLICATION'), 'NON TESTABLE': count('NON TESTABLE'), + parcours: pa ? (pa.steps.every((s) => s.status === 'OK') && pa.download.sha256 === pa.download.expected ? 'RÉUSSI' : 'ÉCHEC') : 'non exécuté', + monkey: mo ? { actions: mo.actions, duréeS: Math.round(mo.durationMs / 1000), graine: mo.seed, erreurs: errors.filter((e) => e.browser === b && e.phase === 'monkey' && e.statut === 'inexpliquée').length } : 'non exécuté', + erreursInexpliquées: errors.filter((e) => e.browser === b && e.statut === 'inexpliquée').length, + }; + } + + const bugs: { id: string; test: string; browser: string; reproduced: boolean; observed: string }[] = []; + for (const f of files.filter((x) => x.endsWith('-bugs.jsonl'))) { + for (const line of readFileSync(join(OUT, 'partials', f), 'utf8').split('\n').filter(Boolean)) { try { bugs.push(JSON.parse(line)); } catch { /* ligne tronquée */ } } + } + bugs.sort((a, b) => a.id.localeCompare(b.id) || a.browser.localeCompare(b.browser)); + writeFileSync(join(OUT, 'matrix.json'), JSON.stringify({ application: 'Control Vault', commit: sha, généré: new Date().toISOString(), données: 'fictives uniquement, Google entièrement simulé', navigateurs: summary, anomalies: bugs, parcours, écrans: Object.fromEntries(explorations.map((e) => [e.browser, e.screens])), lignes: rows }, null, 2)); + writeFileSync(join(OUT, 'errors.json'), JSON.stringify(errors, null, 2)); + + const L: string[] = []; + L.push('# Recette Phase T — Control Vault', '', `Commit testé : \`${sha}\` · généré le ${new Date().toISOString()}`, '', + 'Données fictives uniquement. Google (connexion, Picker, Drive) entièrement simulé par les routes Playwright ; toute autre requête est bloquée.', '', + '## Synthèse par navigateur', '', '| Navigateur | Parcours | Éléments trouvés (N) | Testés (n) | Couverture | OK | KO | Désactivé sans explication | Non testable | Monkey (actions / erreurs) | Erreurs inexpliquées |', '|---|---|---|---|---|---|---|---|---|---|---|'); + for (const b of browsers) { + const s = summary[b] as Record & { monkey: { actions: number; erreurs: number } | string }; + L.push(`| ${b} | ${s.parcours} | ${s.trouvés} | ${s.testés} | ${s.couverture} % | ${s.OK} | ${s.KO} | ${s['DÉSACTIVÉ-SANS-EXPLICATION']} | ${s['NON TESTABLE']} | ${typeof s.monkey === 'string' ? s.monkey : `${s.monkey.actions} / ${s.monkey.erreurs}`} | ${s.erreursInexpliquées} |`); + } + L.push('', '## Parcours utilisateur', ''); + for (const p of parcours) { + L.push(`### ${p.browser}`, '', '| Étape | Résultat | Durée | Capture |', '|---|---|---|---|'); + for (const s of p.steps) L.push(`| ${md(s.step)} | ${s.status}${s.error ? ' — ' + md(s.error.slice(0, 200)) : ''} | ${s.ms} ms | ${s.screenshot ?? ''} |`); + L.push('', `Téléchargement : \`${p.download.name}\` · SHA-256 obtenu \`${p.download.sha256}\` · attendu \`${p.download.expected}\` → ${p.download.sha256 && p.download.sha256 === p.download.expected ? 'identique' : 'DIFFÉRENT / absent'}`, ''); + } + L.push('## Anomalies reproduites (tests marqués test.fail, sans correctif)', '', '| ID | Navigateur | Test de reproduction | Reproduite | Observé |', '|---|---|---|---|---|'); + for (const b of bugs) L.push(`| ${b.id} | ${b.browser} | ${md(b.test)} | ${b.reproduced ? 'oui' : 'NON (corrigée ?)'} | ${md(b.observed).slice(0, 200)} |`); + if (!bugs.length) L.push('| — | — | — | — | aucun test de reproduction exécuté |'); + L.push(''); + L.push('## Éléments KO et désactivés sans explication', '', '| Navigateur | Écran | Élément | Résultat | Détail |', '|---|---|---|---|---|'); + for (const r of rows.filter((x) => x.result === 'KO' || x.result === 'DÉSACTIVÉ-SANS-EXPLICATION')) L.push(`| ${r.browser} | ${md(r.screen)} | ${md(r.element)} | ${r.result} | ${md(r.detail)} |`); + L.push('', '## Erreurs console et exceptions', '', '| Navigateur | Phase | Contexte | Type | Message | Statut | Explication |', '|---|---|---|---|---|---|---|'); + for (const e of errors) L.push(`| ${e.browser} | ${e.phase} | ${md(e.context).slice(0, 120)} | ${e.type} | ${md(e.text).slice(0, 200)} | ${e.statut} | ${md(e.explication)} |`); + if (!errors.length) L.push('| — | — | — | — | Aucune erreur capturée | — | — |'); + L.push('', '## Écrans explorés', ''); + for (const ex of explorations) { + L.push(`### ${ex.browser} (${Math.round(ex.durationMs / 1000)} s)`, '', '| Écran | Éléments trouvés | Testés | Remarque |', '|---|---|---|---|'); + for (const s of ex.screens) L.push(`| ${md(s.id)} | ${s.found} | ${s.tested} | ${md(s.error ?? '')} |`); + L.push(''); + } + L.push('## Matrice complète (écran / élément / navigateur / résultat)', '', '| Écran | Élément | Type | Navigateur | Résultat | Détail |', '|---|---|---|---|---|---|'); + for (const r of rows) L.push(`| ${md(r.screen)} | ${md(r.element)} | ${r.kind} | ${r.browser} | ${r.result} | ${md(r.detail).slice(0, 220)} |`); + L.push('', '## Non testable automatiquement — vérification manuelle', '', + '- [ ] Vraie connexion Google (compte réel, écran de consentement, choix du compte) sur l’origine de production.', + '- [ ] Vrai Google Picker et vrai dossier Google Drive (création, liste, sélection).', + '- [ ] Vrai iPhone physique, Safari et PWA ajoutée à l’écran d’accueil (l’émulation WebKit + display-mode standalone n’est pas un iPhone).', + '- [ ] Téléchargement réel sur iPhone (fichier dans l’app Fichiers) puis import dans Race Control et déchiffrement avec le vrai code.', + '- [ ] Liens externes (Race Control, Reconversion Control) : ouverture du bon site dans un nouvel onglet.', + '- [ ] Service worker / hors ligne réel (bloqué pendant cette recette).', + '- [ ] Messages affichés quand on ferme la fenêtre Google, refuse l’autorisation Drive ou annule le sélecteur (voir BUG-01 à BUG-03), sur un vrai compte.', ''); + writeFileSync(join(OUT, 'MATRICE.md'), L.join('\n')); +} diff --git a/tests/recette/session.ts b/tests/recette/session.ts new file mode 100644 index 0000000..53b429d --- /dev/null +++ b/tests/recette/session.ts @@ -0,0 +1,54 @@ +// Recette Phase T — préparation d'une session navigateur (Google simulé, PWA iPhone, trace). +import { expect, type BrowserContext, type Page, type TestInfo } from '@playwright/test'; +import { join } from 'node:path'; +import { installFakeGoogle, ORIGIN, STANDALONE_INIT } from './fake-google'; +import { OUT, Recorder } from './recorder'; + +export const isIphone = (info: TestInfo) => /iphone/.test(info.project.name); + +export async function startSession(context: BrowserContext, page: Page, info: TestInfo, phase: string) { + const rec = new Recorder(Recorder.browserName(info), phase); + if (isIphone(info)) await context.addInitScript(STANDALONE_INIT); + const fake = await installFakeGoogle(context); + // Parcours : film d'écran + instantanés DOM. Exploration / monkey : instantanés DOM seulement + // (une capture JPEG par étape est déjà enregistrée), sinon les traces dépassent le giga-octet. + await context.tracing.start({ screenshots: phase === 'parcours', snapshots: true, title: `recette ${rec.browser} ${phase}` }); + rec.attach(page); + context.on('page', (p) => { if (p !== page) { rec.attach(p); } }); + const stop = async () => { + await context.tracing.stop({ path: join(OUT, 'traces', `${rec.browser}-${phase}.zip`) }).catch(() => {}); + }; + return { rec, fake, stop }; +} + +/** Vide le stockage de l'origine fictive (page JSON sans script ni connexion IndexedDB ouverte). */ +export async function clearOrigin(page: Page) { + await page.goto(ORIGIN + '/drive-config.json'); + await page.evaluate(async () => { + try { localStorage.clear(); sessionStorage.clear(); } catch { /* indisponible */ } + try { + const names = typeof indexedDB.databases === 'function' ? (await indexedDB.databases()).map((d) => d.name).filter(Boolean) as string[] : []; + await Promise.all(names.map((n) => new Promise((done) => { const r = indexedDB.deleteDatabase(n); r.onsuccess = r.onerror = r.onblocked = () => done(); }))); + } catch { /* indisponible */ } + }); +} + +/** Ouvre l'application sur un écran et attend le premier rendu réel. */ +export async function openApp(page: Page, hash = '#/') { + await page.goto(ORIGIN + '/' + hash); + await expect(page.locator('#main .boot')).toHaveCount(0); +} + +/** Attend que le DOM se stabilise (fin des transitions et des réponses du faux Drive). */ +export async function settle(page: Page, max = 4000) { + await page.evaluate(async (limit) => { + let n = 0; const o = new MutationObserver((l) => { n += l.length; }); + o.observe(document.documentElement, { subtree: true, childList: true, attributes: true, characterData: true }); + const start = performance.now(); let last = -1, stable = 0; + while (performance.now() - start < limit) { + await new Promise((r) => setTimeout(r, 150)); + if (n === last) { if (++stable >= 2 && performance.now() - start > 300) break; } else { stable = 0; last = n; } + } + o.disconnect(); + }, max).catch(() => {}); +} diff --git a/tests/recette/setup.ts b/tests/recette/setup.ts new file mode 100644 index 0000000..e8a16fe --- /dev/null +++ b/tests/recette/setup.ts @@ -0,0 +1,7 @@ +// Recette Phase T — chaque exécution repart d'un dossier de sortie vide (pas de mélange entre exécutions). +import { rmSync } from 'node:fs'; +import { OUT } from './recorder'; + +export default async function setup() { + rmSync(OUT, { recursive: true, force: true }); +} diff --git a/tests/recette/tsconfig.json b/tests/recette/tsconfig.json new file mode 100644 index 0000000..19c7811 --- /dev/null +++ b/tests/recette/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { "types": ["node"], "noEmit": true }, + "include": ["./**/*.ts"] +}