From 514445046d80176ae1d1851f3b72890e86160a77 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 14:35:14 +0000 Subject: [PATCH 1/5] =?UTF-8?q?test(recette):=20Phase=20T=20=E2=80=94=20re?= =?UTF-8?q?cette=20testeur=20Control=20Vault=20(Chromium=20+=20WebKit=20iP?= =?UTF-8?q?hone)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Banc de recette séparé (tests/recette/, configuration Playwright dédiée, jamais lancé par `npm run test:e2e`) et workflow « Recette Phase T » : - parcours utilisateur cliqué : connexion Google simulée → dossier dédié → liste des versions → « Vérifier et télécharger » (SHA-256 du fichier reçu comparé à la copie chiffrée fictive) ; - exploration systématique de tous les boutons, liens et champs visibles de chaque écran, état réinitialisé avant chaque élément, matrice OK / KO / DÉSACTIVÉ-SANS-EXPLICATION / NON TESTABLE et couverture n/N ; - session « monkey » de 5 min par navigateur (graine reproductible) ; - erreurs console / exceptions capturées et expliquées, captures par étape, traces Playwright, sorties recette-output/ (matrix.json, MATRICE.md, errors.json, screenshots/, traces/) ; - reproductions BUG-01 à BUG-05 marquées test.fail(), sans correctif. Données fictives uniquement ; Google, Picker et Drive entièrement simulés, toute autre requête bloquée. Aucun changement dans src/. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E5jwZntqAvJaRThsuEjgja --- .github/workflows/recette.yml | 87 +++++++++++ tests/recette/bugs.recette.ts | 83 ++++++++++ tests/recette/error-rules.ts | 21 +++ tests/recette/exploration.recette.ts | 173 +++++++++++++++++++++ tests/recette/explore.ts | 125 +++++++++++++++ tests/recette/fake-google.ts | 163 +++++++++++++++++++ tests/recette/monkey.recette.ts | 81 ++++++++++ tests/recette/parcours.recette.ts | 97 ++++++++++++ tests/recette/playwright.recette.config.ts | 34 ++++ tests/recette/recorder.ts | 61 ++++++++ tests/recette/report.ts | 102 ++++++++++++ tests/recette/session.ts | 54 +++++++ tests/recette/setup.ts | 7 + tests/recette/tsconfig.json | 5 + 14 files changed, 1093 insertions(+) create mode 100644 .github/workflows/recette.yml create mode 100644 tests/recette/bugs.recette.ts create mode 100644 tests/recette/error-rules.ts create mode 100644 tests/recette/exploration.recette.ts create mode 100644 tests/recette/explore.ts create mode 100644 tests/recette/fake-google.ts create mode 100644 tests/recette/monkey.recette.ts create mode 100644 tests/recette/parcours.recette.ts create mode 100644 tests/recette/playwright.recette.config.ts create mode 100644 tests/recette/recorder.ts create mode 100644 tests/recette/report.ts create mode 100644 tests/recette/session.ts create mode 100644 tests/recette/setup.ts create mode 100644 tests/recette/tsconfig.json 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..bd3a032 --- /dev/null +++ b/tests/recette/bugs.recette.ts @@ -0,0 +1,83 @@ +// 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(''); +}); diff --git a/tests/recette/error-rules.ts b/tests/recette/error-rules.ts new file mode 100644 index 0000000..e5b9184 --- /dev/null +++ b/tests/recette/error-rules.ts @@ -0,0 +1,21 @@ +// Recette Phase T — explication des erreurs console connues. Tout le reste est « inexpliquée ». +import type { ErrorRow } from './recorder'; + +const RULES: { test: (e: Pick) => boolean; why: string }[] = [ + { + // 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: Pick): { 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..6abc399 --- /dev/null +++ b/tests/recette/recorder.ts @@ -0,0 +1,61 @@ +// 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; +} + +/** Contexte courant (phase / écran / élément) attaché à chaque erreur capturée. */ +export class Recorder { + readonly errors: ErrorRow[] = []; + context = ''; + private shotCount = 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 }); + } + /** 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`); + 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 }); + return file; + } catch { return undefined; } + } + + 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"] +} From 245304c9e19d570e54bc6dc636b2f28c7f59ae12 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:01:40 +0000 Subject: [PATCH 2/5] test(recette): explique l'artefact CSP des captures WebKit, ajoute BUG-06 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - erreurs console reçues pendant une capture d'écran marquées (pendantCapture) ; sous WebKit, Playwright insère avant chaque capture (synchronisation des animations) et la CSP style-src 'self' la refuse : classée « attendue », artefact du banc et non de l'application ; - BUG-06 : « Annuler » de la sauvegarde désactivé sans explication pendant le contrôle de l'empreinte (test.fail, aucun correctif). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E5jwZntqAvJaRThsuEjgja --- tests/recette/bugs.recette.ts | 17 +++++++++++++++++ tests/recette/error-rules.ts | 13 +++++++++++-- tests/recette/recorder.ts | 9 +++++++-- 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/tests/recette/bugs.recette.ts b/tests/recette/bugs.recette.ts index bd3a032..f64809b 100644 --- a/tests/recette/bugs.recette.ts +++ b/tests/recette/bugs.recette.ts @@ -81,3 +81,20 @@ test('BUG-05 accessibilité : « Restaurer cette version » est désactivé sans 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'); + await page.locator('#main').getByRole('button', { name: 'Sauvegarder', exact: true }).first().click(); + const cancel = page.locator('dialog[open]').getByRole('button', { name: 'Annuler', exact: true }); + const seen = await expect(cancel).toBeDisabled({ timeout: 20_000 }).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 cancel.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-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 index e5b9184..4a5dacc 100644 --- a/tests/recette/error-rules.ts +++ b/tests/recette/error-rules.ts @@ -1,7 +1,16 @@ // Recette Phase T — explication des erreurs console connues. Tout le reste est « inexpliquée ». import type { ErrorRow } from './recorder'; -const RULES: { test: (e: Pick) => boolean; why: string }[] = [ +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. @@ -15,7 +24,7 @@ const RULES: { test: (e: Pick) => boolea }, ]; -export function explain(e: Pick): { why: string; expected: boolean } { +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/recorder.ts b/tests/recette/recorder.ts index 6abc399..be7b758 100644 --- a/tests/recette/recorder.ts +++ b/tests/recette/recorder.ts @@ -13,6 +13,8 @@ export interface MatrixRow { 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. */ @@ -20,6 +22,7 @@ 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 }); @@ -37,18 +40,20 @@ export class Recorder { 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.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; } + } catch { return undefined; } finally { this.shooting--; } } write(name: string, data: unknown) { From bddb22f8aefc782288a463ddbb927cfbb6b92ad2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 15:26:37 +0000 Subject: [PATCH 3/5] =?UTF-8?q?test(recette):=20BUG-06=20observ=C3=A9=20pa?= =?UTF-8?q?r=20MutationObserver=20(=C3=A9tat=20bref)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'état « Annuler désactivé » dure trop peu pour un sondage : un observateur dans la page enregistre l'instant exact et la description éventuelle. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E5jwZntqAvJaRThsuEjgja --- tests/recette/bugs.recette.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/tests/recette/bugs.recette.ts b/tests/recette/bugs.recette.ts index f64809b..9802612 100644 --- a/tests/recette/bugs.recette.ts +++ b/tests/recette/bugs.recette.ts @@ -87,14 +87,22 @@ test('BUG-06 sauvegarde : « Annuler » devient désactivé pendant le contrôle 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 cancel = page.locator('dialog[open]').getByRole('button', { name: 'Annuler', exact: true }); - const seen = await expect(cancel).toBeDisabled({ timeout: 20_000 }).then(() => true, () => false); + 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 cancel.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') || ''; - }); + 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(''); }); From ba98f848ccdfa77b526574cfc0356f76e4f39ef1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:03:10 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix(cv):=20messages=20de=20connexion/dossie?= =?UTF-8?q?r=20exacts=20et=20accessibilit=C3=A9=20(BUG-01=20=C3=A0=2006)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Corrige les anomalies relevées par la recette Phase T (PR #34), sans toucher à la cryptographie, au format, au stockage, à la CSP ni au service worker : - BUG-01 : fenêtre Google fermée/bloquée (error_callback) → code « popup », message « Connexion Google annulée ou fenêtre bloquée… », état neutre ; - BUG-02 : autorisation Drive refusée ou décochée → code « scope », message expliquant que l'accès n'a pas été accordé ; plus jamais « session expirée » ni « synchronisation bloquée » sans session préalable ; - BUG-03 : sélecteur annulé → « Aucun dossier choisi. », pas d'erreur ; - BUG-04 : sur l'origine où Google Drive est configuré, seuls l'écran Google Drive et sa navigation sont affichés (autres routes redirigées, recherche rapide et raccourcis désactivés) ; premier rendu après lecture de la configuration pour ne jamais montrer d'état de démonstration ; - BUG-05 : « Restaurer cette version » décrit par un texte visible (aria-describedby) tant qu'il est désactivé ; - BUG-06 : « Annuler » retiré à l'étape de contrôle, comme le commentaire l'annonçait, au lieu d'être laissé désactivé sans explication. Tests : tests/e2e/messages-exacts.spec.ts (rouges sur main, verts ici) ; connected-drive.spec.ts suit la redirection de Réglages vers Google Drive. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E5jwZntqAvJaRThsuEjgja --- src/flows/sheets.ts | 13 ++- src/main.ts | 22 ++++- src/services/connected-drive.ts | 22 ++++- src/services/google-drive-session.js | 10 ++- src/views/drive.ts | 2 +- tests/e2e/connected-drive.spec.ts | 3 +- tests/e2e/messages-exacts.spec.ts | 115 +++++++++++++++++++++++++++ 7 files changed, 171 insertions(+), 16 deletions(-) create mode 100644 tests/e2e/messages-exacts.spec.ts diff --git a/src/flows/sheets.ts b/src/flows/sheets.ts index 418a56a..28bbaad 100644 --- a/src/flows/sheets.ts +++ b/src/flows/sheets.ts @@ -174,7 +174,7 @@ function runBackupInSheet(sheet: SheetHandle, appId: AppId) { runBackup(appId, (u) => { steps.update(u); // L'envoi ne s'annule plus une fois le fichier écrit : on retire le bouton à l'étape de contrôle. - if (u.index >= 4) cancel.setAttribute('disabled', ''); + if (u.index >= 4 && cancel.isConnected) setFoot(sheet); }, ctrl.signal) .then(({ version, durationMs }) => { finished = true; @@ -325,8 +325,12 @@ export function openRestore(versionId: string) { .concat((p.diff?.fields ?? []).filter((f) => f !== 'revision').map((f) => h('li', { dataset: { health: 'unknown' } }, icon('refresh'), `Réglage « ${f} » différent`))); const ack = h('input', { type: 'checkbox', class: 'check', id: 'restore-ack' }); - const go = h('button', { class: 'btn btn-primary', type: 'button', disabled: true }, icon('restore'), 'Restaurer cette version'); - ack.addEventListener('change', () => (go.disabled = !ack.checked || !!p.diff?.identical)); + // Condition de désactivation exposée par un texte visible (aria-describedby). + const go = h('button', { class: 'btn btn-primary', type: 'button', disabled: true, 'aria-describedby': 'restore-go-hint' }, icon('restore'), 'Restaurer cette version'); + ack.addEventListener('change', () => { + go.disabled = !ack.checked || !!p.diff?.identical; + if (go.disabled) go.setAttribute('aria-describedby', 'restore-go-hint'); else go.removeAttribute('aria-describedby'); + }); replace( sheet.body, @@ -339,7 +343,7 @@ export function openRestore(versionId: string) { ), h('h3', { class: 'section-title' }, 'Ce qui changera'), p.diff?.identical - ? h('p', { class: 'muted' }, 'Cette version est identique à l’état actuel. Il n’y a rien à restaurer.') + ? h('p', { class: 'muted', id: 'restore-go-hint' }, 'Cette version est identique à l’état actuel. Il n’y a rien à restaurer.') : h('ul', { class: 'changes' }, changes), p.losses.length ? h( @@ -373,6 +377,7 @@ export function openRestore(versionId: string) { ack, h('span', null, `Je remplace l’état actuel par la version du ${formatDate(version.createdAt)}.`), ), + !p.diff?.identical && h('p', { class: 'faint small', id: 'restore-go-hint' }, 'Coche cette confirmation pour activer « Restaurer cette version ».'), ); setFoot(sheet, h('button', { class: 'btn', type: 'button', onclick: () => sheet.close() }, 'Annuler'), go); go.addEventListener('click', () => runRestoreInSheet(sheet, version, p)); diff --git a/src/main.ts b/src/main.ts index d4e8cf1..eb6180e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -59,7 +59,11 @@ const viewEl = h('main', { id: 'main', class: 'view', tabindex: '-1' }); const banners = h('div', { class: 'banners', role: 'region', 'aria-label': 'État du système' }); const topTitle = h('span', { class: 'topbar-title' }); const topStatus = h('a', { class: 'top-status mobile-only', href: '#/', 'aria-label': 'État global' }); +// Origine où Google Drive est réellement configuré (production isolée) : aucun écran ni état de +// démonstration n'y est affiché. La démonstration reste réservée aux builds sans configuration Google. +const realOnly = () => connectedDrive.state.configured; const searchMobile = h('button', { class: 'icon-btn mobile-only', type: 'button', 'aria-label': 'Recherche rapide', onclick: () => openPalette(navigate) }, icon('search')); +const searchDesktop = h('button', { class: 'search-trigger', type: 'button', onclick: () => openPalette(navigate), 'aria-label': 'Recherche rapide' }, icon('search'), h('span', null, 'Rechercher'), h('kbd', null, /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘ K' : 'Ctrl K')); const sideNav = h('nav', { class: 'side-nav', 'aria-label': 'Navigation principale' }); const tabbar = h('nav', { class: 'tabbar', 'aria-label': 'Navigation principale' }); const sideFoot = h('div', { class: 'side-foot' }); @@ -74,7 +78,7 @@ const shell = h( 'aside', { class: 'sidebar' }, h('div', { class: 'brand' }, logo(32), h('span', { class: 'brand-name' }, 'Control Vault', h('span', { class: 'brand-sub' }, 'Stockage et sauvegardes'))), - h('button', { class: 'search-trigger', type: 'button', onclick: () => openPalette(navigate), 'aria-label': 'Recherche rapide' }, icon('search'), h('span', null, 'Rechercher'), h('kbd', null, /Mac|iPhone|iPad/.test(navigator.platform) ? '⌘ K' : 'Ctrl K')), + searchDesktop, sideNav, sideFoot, ), @@ -133,6 +137,10 @@ function parse(): { route: Route; params: Record; path: string } function render() { const { route, params, path } = parse(); + if (realOnly() && route.key !== 'drive') { + location.replace('#/drive'); + return; + } if (current) { scrolls.set(current.hash, window.scrollY); current.view.destroy?.(); @@ -170,9 +178,11 @@ function paintNav(s: State) { const st = globalStatus(s); const failures = s.events.filter((e) => e.result === 'failed' && !e.resolved).length; const health = st.level === 'ok' ? 'verified' : st.level === 'action' ? 'failed' : st.level === 'attention' ? 'stale' : 'unknown'; + const nav = realOnly() ? NAV.filter((n) => n.id === 'drive') : NAV; + searchMobile.hidden = searchDesktop.hidden = realOnly(); replace( sideNav, - NAV.map((n) => + nav.map((n) => h( 'a', { class: 'side-link', href: n.href, 'aria-current': current?.nav === n.id ? 'page' : undefined, dataset: n.id === 'home' ? { health } : undefined }, @@ -184,7 +194,7 @@ function paintNav(s: State) { ); replace( tabbar, - NAV.filter((n) => n.mobile).map((n) => + nav.filter((n) => n.mobile || realOnly()).map((n) => h( 'a', { class: 'tab', href: n.href, 'aria-current': current?.nav === n.id || (n.id === 'settings' && current?.nav === 'security') ? 'page' : undefined, dataset: n.id === 'home' ? { health } : undefined }, @@ -245,6 +255,7 @@ function showShortcuts() { let gPending = 0; document.addEventListener('keydown', (e) => { + if (realOnly()) return; // raccourcis et recherche rapide : écrans de démonstration uniquement const typing = e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || (e.target as HTMLElement)?.isContentEditable; if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); @@ -290,9 +301,12 @@ applySettings(store.get()); viewEl.append(h('div', { class: 'boot', 'aria-busy': 'true', 'aria-label': 'Ouverture du coffre' }, h('div', { class: 'sk', style: 'width: 180px; height: 180px; border-radius: 50%; margin: 24px auto' }))); connectedDrive.subscribe(() => { if (current?.nav === 'drive') { paintNav(store.get()); paintBanners(store.get()); } }); -void connectedDrive.init(); +// Premier rendu seulement après lecture de la configuration : jamais d'état de démonstration affiché +// avant de savoir si l'origine est réellement connectée à Google Drive. +const driveReady = connectedDrive.init(); window.addEventListener('pagehide', () => connectedDrive.disconnect()); boot() + .then(() => driveReady) .then(() => { render(); paintBanners(store.get()); diff --git a/src/services/connected-drive.ts b/src/services/connected-drive.ts index 7a55a9b..e1df167 100644 --- a/src/services/connected-drive.ts +++ b/src/services/connected-drive.ts @@ -62,7 +62,18 @@ export class ConnectedDrive { if (!this.session || this.state.busy) return Promise.resolve(); this.epoch++; this.selection = null; this.change({ selected: false, account: null, accountConfirmed: false, folder: null, dedicatedFolders: [], versions: [], receipts: [], phase: 'WAITING' }); const promise = this.session.connect(); - return this.action(async epoch => { await promise; this.check(epoch); this.change({ connected: true, account: this.session?.identity?.label || null, accountConfirmed: false, message: 'Compte affiché : confirme explicitement ce compte dédié avant tout accès aux fichiers.' }); }); + return this.action(async epoch => { + // Échec de connexion sans session préalable : jamais « session expirée » ni « synchronisation bloquée ». + try { await promise; } catch (e) { + if (epoch !== this.epoch) return; + const code = (e as { code?: string }).code; + this.change({ connected: false, phase: 'WAITING', lastOperationAt: Date.now(), message: code === 'popup' + ? 'Connexion Google annulée ou fenêtre bloquée : réessaie (autorise les fenêtres pour ce site).' + : code === 'scope' ? 'L’accès au dossier Google Drive n’a pas été accordé : aucun fichier n’est lisible. Reconnecte-toi et autorise l’accès demandé.' + : 'Connexion Google non aboutie : aucun compte n’est connecté. Réessaie.' }); + return; + } + this.check(epoch); this.change({ connected: true, account: this.session?.identity?.label || null, accountConfirmed: false, message: 'Compte affiché : confirme explicitement ce compte dédié avant tout accès aux fichiers.' }); }); } confirmAccount() { if (!this.session?.identity || this.state.busy) return; @@ -95,7 +106,14 @@ export class ConnectedDrive { if (!this.state.accountConfirmed) return Promise.resolve(); this.epoch++; this.selection = null; this.change({ selected: false, folder: null, versions: [], receipts: [], phase: 'WAITING' }); const promise = this.session.chooseFolder(); - return this.action(async epoch => { const selection = await promise; this.check(epoch); this.selection = selection; this.change({ selected: true, folder: selection.folder, versions: [], receipts: [], lastOperationAt: Date.now(), message: 'Dossier sélectionné. Actualise les versions pour les lire.' }); }); + return this.action(async epoch => { + let selection: Awaited; + try { selection = await promise; } catch (e) { + // Sélecteur fermé par l'utilisateur : état neutre, pas une erreur. + if (epoch === this.epoch && (e as { code?: string }).code === 'cancelled') { this.change({ phase: 'WAITING', message: 'Aucun dossier choisi.' }); return; } + throw e; + } + this.check(epoch); this.selection = selection; this.change({ selected: true, folder: selection.folder, versions: [], receipts: [], lastOperationAt: Date.now(), message: 'Dossier sélectionné. Actualise les versions pour les lire.' }); }); } disconnect() { this.epoch++; this.session?.disconnect(); this.selection = null; diff --git a/src/services/google-drive-session.js b/src/services/google-drive-session.js index 8aa7ba6..787c492 100644 --- a/src/services/google-drive-session.js +++ b/src/services/google-drive-session.js @@ -76,16 +76,18 @@ const GoogleDriveSession = (() => { callback: async response => { if (request !== pending || request.epoch !== epoch) return; try { - if (response.error || typeof response.access_token !== 'string' || - !host.google.accounts.oauth2.hasGrantedAllScopes(response, SCOPE) || response.scope?.split(/\s+/).filter(Boolean).join(' ') !== SCOPE) return fail('auth'); + // Refus explicite ou autorisation Drive décochée : « scope », distinct d'une session expirée. + if (response.error || typeof response.access_token !== 'string') return fail(response.error === 'access_denied' ? 'scope' : 'auth'); + if (!host.google.accounts.oauth2.hasGrantedAllScopes(response, SCOPE) || response.scope?.split(/\s+/).filter(Boolean).join(' ') !== SCOPE) return fail('scope'); const seconds = Number(response.expires_in); provider.connect(response.access_token, seconds, [SCOPE]); token = response.access_token; until = Date.now() + (seconds - 30) * 1000; const selected = await provider.identity(); if (request !== pending || request.epoch !== epoch) return; identity = Object.freeze({ ...selected }); pending = null; request.resolve(); - } catch { if (request !== pending || request.epoch !== epoch) return; pending = null; disconnect(); request.reject(Object.assign(new Error('auth'), { code: 'auth' })); } - }, error_callback: () => { if (request !== pending || request.epoch !== epoch) return; pending = null; disconnect(); request.reject(Object.assign(new Error('auth'), { code: 'auth' })); } }); + } catch (e) { if (request !== pending || request.epoch !== epoch) return; pending = null; disconnect(); const code = e?.code === 'scope' ? 'scope' : 'auth'; request.reject(Object.assign(new Error(code), { code })); } + }, // Fenêtre Google fermée, bloquée ou impossible à ouvrir : aucun jeton n'a jamais été reçu. + error_callback: () => { if (request !== pending || request.epoch !== epoch) return; pending = null; disconnect(); request.reject(Object.assign(new Error('popup'), { code: 'popup' })); } }); try { client.requestAccessToken({ prompt: 'select_account' }); } catch { pending = null; disconnect(); reject(Object.assign(new Error('auth'), { code: 'auth' })); } }); diff --git a/src/views/drive.ts b/src/views/drive.ts index 7cfdd1b..1147918 100644 --- a/src/views/drive.ts +++ b/src/views/drive.ts @@ -15,7 +15,7 @@ export function driveView(): View { h('section', { class: 'panel' }, h('div', { class: 'panel-body stack' }, h('p', { role: 'status', 'aria-live': 'polite' }, s.message), h('p', { class: 'muted' }, 'Race Control chiffre et déchiffre sur chaque appareil. Control Vault conserve les fichiers opaques : il ne demande jamais ton code et ne peut pas authentifier leur contenu.'), - h('p', { class: 'muted' }, 'Choisis le même compte et le même dossier sur le PC et l’iPhone. La synchronisation de Race Control fonctionne aussi lorsque Control Vault est fermé. Les autres écrans restent des démonstrations.'), + h('p', { class: 'muted' }, 'Choisis le même compte et le même dossier sur le PC et l’iPhone. La synchronisation de Race Control fonctionne aussi lorsque Control Vault est fermé. ' + (s.configured ? 'Aucun écran de démonstration n’est proposé sur cette origine.' : 'Les autres écrans restent des démonstrations.')), s.account ? h('p', null, 'Compte Google sélectionné : ' + s.account) : null, s.folder ? h('p', { class: 'wrap' }, 'Dossier dédié sélectionné : ' + s.folder) : null, h('p', null, ({ WAITING: 'En attente', SENDING: 'Envoi en cours', UPLOADED: 'Envoyé — relecture requise', VERIFIED_BYTES: 'Octets distants vérifiés — restauration à valider dans Race Control', CONFLICT: 'Conflit', SYNC_BLOCKED: 'Synchronisation bloquée', ERROR: 'Erreur — action nécessaire' })[s.phase]), diff --git a/tests/e2e/connected-drive.spec.ts b/tests/e2e/connected-drive.spec.ts index 374bc97..42e6a5b 100644 --- a/tests/e2e/connected-drive.spec.ts +++ b/tests/e2e/connected-drive.spec.ts @@ -45,9 +45,10 @@ for (const direct of [false, true]) test('application complète, Google simulé const file = 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); }); + // Origine connectée : les écrans de démonstration (ici Réglages) redirigent vers Google Drive. await page.goto(origin + '/#/reglages'); - await page.getByRole('link', { name: /Copies chiffrées Race Control/ }).click(); await expect(page.getByRole('heading', { name: 'Google Drive', exact: true })).toBeVisible(); + await expect(page).toHaveURL(origin + '/#/drive'); const recoveryLink=page.getByRole('link',{name:'Récupérer sur un appareil neuf dans Race Control'}); await expect(recoveryLink).toHaveAttribute('href','https://race.example.com/recovery.html'); await expect(recoveryLink).toHaveAttribute('rel','noopener noreferrer'); diff --git a/tests/e2e/messages-exacts.spec.ts b/tests/e2e/messages-exacts.spec.ts new file mode 100644 index 0000000..b62384b --- /dev/null +++ b/tests/e2e/messages-exacts.spec.ts @@ -0,0 +1,115 @@ +// Messages exacts de connexion / dossier et accessibilité (recette Phase T, BUG-01 à BUG-06). +// Google entièrement simulé, données fictives, aucune requête externe. +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { open } from './helpers'; + +const origin = 'https://vault.example.com'; +const scope = 'https://www.googleapis.com/auth/drive.file'; +const config = { approved: true, clientId: ['123456', 'fake.apps.googleusercontent.com'].join('-'), apiKey: 'AIza' + 'a'.repeat(35), appId: '123456', raceOrigin: 'https://race.example.com', vaultOrigin: origin }; + +// Origine HTTPS configurée (comme la production isolée), GIS / Picker / Drive simulés. +async function production(context: BrowserContext, opts: { consent?: 'granted' | 'popup-closed' | 'scope-refused'; picker?: 'picked' | 'cancel' } = {}) { + await context.route('**/*', r => r.abort()); + 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.abort(); + let body = readFileSync(target); + if (path === '/') 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/")); + await r.fulfill({ body, contentType: path.endsWith('.js') ? 'text/javascript' : path.endsWith('.css') ? 'text/css' : path.endsWith('.woff2') ? 'font/woff2' : path.endsWith('.json') || path.endsWith('.webmanifest') ? 'application/json' : path.endsWith('.svg') ? 'image/svg+xml' : path.endsWith('.png') ? 'image/png' : 'text/html' }); + }); + const answer = opts.consent === 'popup-closed' ? "o.error_callback({type:'popup_closed'})" + : opts.consent === 'scope-refused' ? "o.callback({access_token:'fixture-token',scope:'openid',expires_in:3600})" + : `o.callback({access_token:'fixture-token',scope:'${scope}',expires_in:3600})`; + await context.route('https://accounts.google.com/gsi/client', r => r.fulfill({ contentType: 'text/javascript', + body: `window.google={accounts:{oauth2:{initTokenClient:o=>({requestAccessToken:()=>setTimeout(()=>${answer},20)}),hasGrantedAllScopes:(t,s)=>String(t.scope).split(' ').includes(s)}}};` })); + await context.route('https://apis.google.com/js/api.js', r => r.fulfill({ contentType: 'text/javascript', body: ` + window.gapi={load:(n,o)=>o.callback()}; + 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)setTimeout(()=>this.cb(${opts.picker === 'cancel' ? "{action:'cancel'}" : "{action:'picked',docs:[{id:'folder-A'}]}"}),20)}} + google.picker={DocsView:View,PickerBuilder:Picker,ViewId:{FOLDERS:'folders'},Action:{PICKED:'picked',CANCEL:'cancel'}};` })); + await context.route('https://www.googleapis.com/**', async r => { + const u = new URL(r.request().url()), key = u.pathname.split('/').at(-1); + const headers = { 'Access-Control-Allow-Origin': origin, 'Access-Control-Allow-Headers': 'authorization,content-type' }; + if (r.request().method() === 'OPTIONS') return r.fulfill({ status: 204, headers }); + const json = (v: unknown) => r.fulfill({ headers, contentType: 'application/json', body: JSON.stringify(v) }); + if (key === 'about') return json({ user: { permissionId: 'synthetic_account', emailAddress: 'fixture-backup@example.invalid' } }); + if (key === 'folder-A') return json({ id: key, mimeType: 'application/vnd.google-apps.folder', trashed: false, ownedByMe: true, appProperties: { cv_contract: 'rc-folder-v1' }, capabilities: { canAddChildren: true } }); + return r.fulfill({ status: 404, headers, body: '{}' }); + }); +} +const status = (page: Page) => page.locator('#main p[role=status]').first(); +async function connect(page: Page) { + await page.goto(origin + '/#/drive'); + await page.getByRole('button', { name: 'Préparer la connexion Google' }).click(); + await page.getByRole('button', { name: 'Se connecter à Google', exact: true }).click(); +} + +test('BUG-01 fenêtre Google fermée ou bloquée : message exact, aucune « session expirée »', async ({ context, page }) => { + await production(context, { consent: 'popup-closed' }); + await connect(page); + await expect(status(page)).toHaveText('Connexion Google annulée ou fenêtre bloquée : réessaie (autorise les fenêtres pour ce site).'); + await expect(page.locator('#main .panel').first()).not.toContainText(/expirée|Synchronisation bloquée/); + await expect(page.getByRole('button', { name: 'Se connecter à Google', exact: true })).toBeEnabled(); +}); + +test('BUG-02 autorisation Google Drive refusée : l’accès non accordé est expliqué', async ({ context, page }) => { + await production(context, { consent: 'scope-refused' }); + await connect(page); + await expect(status(page)).toContainText('L’accès au dossier Google Drive n’a pas été accordé'); + await expect(page.locator('#main .panel').first()).not.toContainText(/expirée|Synchronisation bloquée/); + await expect(page.getByText('Compte Google sélectionné')).toHaveCount(0); +}); + +test('BUG-03 sélecteur Google annulé : état neutre « Aucun dossier choisi »', async ({ context, page }) => { + await production(context, { picker: 'cancel' }); + await connect(page); + await page.getByRole('button', { name: 'Confirmer ce compte dédié' }).click(); + await page.getByRole('button', { name: 'Choisir le dossier Google Drive' }).click(); + await expect(status(page)).toHaveText('Aucun dossier choisi.'); + await expect(page.locator('#main .panel').first()).not.toContainText(/Erreur — action nécessaire|Opération non confirmée/); + await expect(page.getByRole('button', { name: 'Choisir le dossier Google Drive' })).toBeEnabled(); +}); + +test('BUG-04 origine de production : aucun état ni sauvegarde de démonstration', async ({ context, page }) => { + await production(context); + for (const hash of ['#/', '#/historique', '#/applis/race-control', '#/securite', '#/reglages']) { + await page.goto(origin + '/' + hash); + await expect(page.getByRole('heading', { name: 'Google Drive', exact: true })).toBeVisible(); + await expect(page).toHaveURL(origin + '/#/drive'); + await expect(page.locator('body')).not.toContainText(/Connexion vérifiée|Sauvegardée hier|Réussie et vérifiée|Compte de démonstration/); + } + await expect(page.getByRole('button', { name: 'Recherche rapide' })).toHaveCount(0); +}); + +test('BUG-05 « Restaurer cette version » désactivé : condition décrite par un texte visible', async ({ page }) => { + await open(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 id = await go.getAttribute('aria-describedby'); + expect(id, 'aria-describedby').toBeTruthy(); + await expect(page.locator('#' + id)).toBeVisible(); + await expect(page.locator('#' + id)).toContainText('Coche'); + await expect(go).toHaveAccessibleDescription(/Coche/); +}); + +test('BUG-06 sauvegarde : « Annuler » n’est jamais laissé désactivé sans explication', async ({ page }) => { + await open(page, '#/applis'); + await page.evaluate(() => { + const w = window as unknown as { annulerDesactive?: boolean }; + new MutationObserver(list => { + for (const m of list) { + const b = m.target as HTMLElement; + if (b.tagName === 'BUTTON' && b.textContent?.trim() === 'Annuler' && b.hasAttribute('disabled') && b.isConnected && !b.getAttribute('aria-describedby')) w.annulerDesactive = true; + } + }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['disabled'] }); + }); + await page.locator('#main').getByRole('button', { name: 'Sauvegarder', exact: true }).first().click(); + await expect(page.getByRole('heading', { name: 'Sauvegarde terminée et vérifiée' })).toBeVisible({ timeout: 20000 }); + expect(await page.evaluate(() => !!(window as unknown as { annulerDesactive?: boolean }).annulerDesactive)).toBe(false); +}); From 6badcba1ff101c1b3c5b699ebed3cffe5be1041c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 16:09:38 +0000 Subject: [PATCH 5/5] =?UTF-8?q?test(recette):=20recette=20=C2=AB=20apr?= =?UTF-8?q?=C3=A8s=20=C2=BB=20sur=20fix/recette-messages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BUG-01 à BUG-06 : marqueurs test.fail() retirés (anomalies corrigées), les tests doivent désormais passer ; - écrans de démonstration explorés en mode démo explicite (origine sans configuration Google), écran Google Drive sur l'origine connectée ; - parcours mobile : onglet Google Drive direct quand il existe. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E5jwZntqAvJaRThsuEjgja --- tests/recette/bugs.recette.ts | 28 ++++++++++++---------------- tests/recette/exploration.recette.ts | 4 +++- tests/recette/fake-google.ts | 4 +++- tests/recette/parcours.recette.ts | 9 +++++++-- tests/recette/session.ts | 6 +++--- 5 files changed, 28 insertions(+), 23 deletions(-) diff --git a/tests/recette/bugs.recette.ts b/tests/recette/bugs.recette.ts index 9802612..1b8cf10 100644 --- a/tests/recette/bugs.recette.ts +++ b/tests/recette/bugs.recette.ts @@ -1,8 +1,7 @@ -// 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 ». +// Recette Phase T — tests des anomalies BUG-01 à BUG-06 (comportement ATTENDU). +// Branche « après » (test/recette-apres) : le correctif fix/recette-messages est fusionné, les marqueurs +// test.fail() sont donc retirés et ces tests doivent passer. Ils 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'; @@ -26,7 +25,6 @@ const status = (page: Page) => page.locator('#main p[role=status]').first().inne 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'); @@ -35,7 +33,6 @@ test('BUG-01 connexion : fermer (ou bloquer) la fenêtre de consentement Google }); 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)); @@ -44,7 +41,6 @@ test('BUG-02 connexion : refuser l’autorisation Google Drive affiche « Sessio }); 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); @@ -55,21 +51,21 @@ test('BUG-03 dossier : annuler le sélecteur Google Drive affiche « Erreur — }); 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, ' '); + // Après correctif, l'Accueil de démonstration est redirigé vers Google Drive : la tuile peut ne plus exister. + const tiles = page.locator('#main').getByRole('link', { name: /Google Drive/ }); + const tile = (await tiles.count()) ? (await tiles.first().innerText()).replace(/\s+/g, ' ') : `aucune tuile (URL ${new URL(page.url()).hash})`; 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 installFakeGoogle(context, { demo: true }); // écrans de démonstration : mode démo explicite 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' }); @@ -83,9 +79,8 @@ test('BUG-05 accessibilité : « Restaurer cette version » est désactivé sans }); 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 installFakeGoogle(context, { demo: true }); // écrans de démonstration : mode démo explicite 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(() => { @@ -100,8 +95,9 @@ test('BUG-06 sauvegarde : « Annuler » devient désactivé pendant le contrôle }).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; } + await expect(page.getByRole('heading', { name: 'Sauvegarde terminée et vérifiée' })).toBeVisible({ timeout: 20_000 }); + const seen = await page.evaluate(() => !!(window as unknown as { __bug06?: unknown }).__bug06); + if (!seen) { note(info, 'BUG-06', 'sauvegarde terminée : « Annuler » jamais laissé désactivé (retiré à l’étape de contrôle)', false); 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/exploration.recette.ts b/tests/recette/exploration.recette.ts index a9a0962..5b4b4d3 100644 --- a/tests/recette/exploration.recette.ts +++ b/tests/recette/exploration.recette.ts @@ -42,7 +42,9 @@ async function replay(page: Page, s: Screen) { 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); + // Après correctif BUG-04, les écrans de démonstration n'existent qu'en mode démo explicite (origine sans + // configuration Google) : ils y sont explorés ; l'écran Google Drive reste exploré sur l'origine connectée. + const { rec, fake, stop } = await startSession(context, page, info, 'exploration-' + group.name, { demo: group.name !== 'google-drive' }); const rows: MatrixRow[] = []; const screens: (Screen & { found: number; tested: number; error?: string })[] = []; const queue: Screen[] = [...group.screens]; diff --git a/tests/recette/fake-google.ts b/tests/recette/fake-google.ts index 792ca96..f1b8d29 100644 --- a/tests/recette/fake-google.ts +++ b/tests/recette/fake-google.ts @@ -48,6 +48,8 @@ export interface FakeOptions { consent?: 'granted' | 'popup-closed' | 'scope-refused'; /** Réponse du faux Google Picker : dossier choisi ou annulation par l'utilisateur. */ picker?: 'picked' | 'cancel'; + /** Origine de démonstration : drive-config.json vaut null (comme le build sans Google). */ + demo?: boolean; } export async function installFakeGoogle(context: BrowserContext, options: FakeOptions = {}) { const consent = options.consent ?? 'granted', pickerAction = options.picker ?? 'picked'; @@ -71,7 +73,7 @@ export async function installFakeGoogle(context: BrowserContext, options: FakeOp // 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) }); + if (path === '/drive-config.json') return r.fulfill({ contentType: 'application/json', body: options.demo ? 'null' : 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; diff --git a/tests/recette/parcours.recette.ts b/tests/recette/parcours.recette.ts index 7a2fb22..698d2e1 100644 --- a/tests/recette/parcours.recette.ts +++ b/tests/recette/parcours.recette.ts @@ -35,8 +35,13 @@ test('parcours utilisateur : connexion Google simulée → dossier → versions }); 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(); + // Après correctif BUG-04, l'origine connectée n'affiche que l'onglet Google Drive. + const direct = page.locator('.tabbar').getByRole('link', { name: 'Google Drive' }); + if (await direct.count()) await direct.click(); + else { + 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(); } diff --git a/tests/recette/session.ts b/tests/recette/session.ts index 53b429d..e5d124d 100644 --- a/tests/recette/session.ts +++ b/tests/recette/session.ts @@ -1,15 +1,15 @@ // 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 { installFakeGoogle, ORIGIN, STANDALONE_INIT, type FakeOptions } 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) { +export async function startSession(context: BrowserContext, page: Page, info: TestInfo, phase: string, options: FakeOptions = {}) { const rec = new Recorder(Recorder.browserName(info), phase); if (isIphone(info)) await context.addInitScript(STANDALONE_INIT); - const fake = await installFakeGoogle(context); + const fake = await installFakeGoogle(context, options); // 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}` });