Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions .github/workflows/recette.yml
Original file line number Diff line number Diff line change
@@ -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
108 changes: 108 additions & 0 deletions tests/recette/bugs.recette.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// Recette Phase T — reproductions des anomalies trouvées (AUCUN correctif ici).
// Chaque test décrit le comportement ATTENDU et est marqué test.fail() : il « réussit » tant que
// l'anomalie existe et deviendra « unexpectedly passed » (rouge) le jour où elle sera corrigée,
// pour rappeler de retirer le marqueur. Ces tests ne tournent que dans le workflow « Recette Phase T »
// (configuration séparée) : ils n'affectent jamais « Vérification complète ».
import { test, expect, type Page } from '@playwright/test';
import { appendFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { installFakeGoogle, STANDALONE_INIT, type FakeOptions } from './fake-google';
import { OUT, Recorder } from './recorder';
import { isIphone, openApp, settle } from './session';

function note(info: import('@playwright/test').TestInfo, id: string, observed: string, reproduced: boolean) {
mkdirSync(join(OUT, 'partials'), { recursive: true });
appendFileSync(join(OUT, 'partials', `${Recorder.browserName(info)}-bugs.jsonl`), JSON.stringify({ id, test: info.title, browser: Recorder.browserName(info), reproduced, observed }) + '\n');
}

async function drive(page: Page, context: import('@playwright/test').BrowserContext, info: import('@playwright/test').TestInfo, options: FakeOptions = {}) {
if (isIphone(info)) await context.addInitScript(STANDALONE_INIT);
await installFakeGoogle(context, options);
await openApp(page, '#/drive');
await page.getByRole('button', { name: 'Préparer la connexion Google' }).click(); await settle(page);
await page.getByRole('button', { name: 'Se connecter à Google', exact: true }).click(); await settle(page);
}
const status = (page: Page) => page.locator('#main p[role=status]').first().innerText();
const phase = (page: Page) => page.locator('#main .panel-body > p').filter({ hasText: /^(En attente|Envoi en cours|Envoyé|Octets distants|Conflit|Synchronisation bloquée|Erreur — action nécessaire)/ }).first().innerText();

test('BUG-01 connexion : fermer (ou bloquer) la fenêtre de consentement Google affiche « Session Google expirée »', async ({ context, page }, info) => {
test.fail(true, 'Anomalie connue (recette Phase T) : message trompeur, aucune session n’a jamais existé.');
await drive(page, context, info, { consent: 'popup-closed' });
const msg = await status(page), ph = await phase(page);
note(info, 'BUG-01', `${msg} / ${ph}`, /expirée/.test(msg) || ph === 'Synchronisation bloquée');
expect(msg, 'un abandon volontaire ne doit pas être présenté comme une session expirée').not.toMatch(/expirée/);
expect(ph).not.toBe('Synchronisation bloquée');
});

test('BUG-02 connexion : refuser l’autorisation Google Drive affiche « Session Google expirée » au lieu d’expliquer la permission manquante', async ({ context, page }, info) => {
test.fail(true, 'Anomalie connue (recette Phase T) : message trompeur, la cause (permission drive.file refusée) n’est pas dite.');
await drive(page, context, info, { consent: 'scope-refused' });
const msg = await status(page);
note(info, 'BUG-02', msg, /expirée/.test(msg) || !/autoris|permission/i.test(msg));
expect(msg).not.toMatch(/expirée/);
expect(msg).toMatch(/autoris|permission/i);
});

test('BUG-03 dossier : annuler le sélecteur Google Drive affiche « Erreur — action nécessaire » et « Opération non confirmée »', async ({ context, page }, info) => {
test.fail(true, 'Anomalie connue (recette Phase T) : une annulation volontaire est présentée comme une erreur.');
await drive(page, context, info, { picker: 'cancel' });
await page.getByRole('button', { name: 'Confirmer ce compte dédié' }).click(); await settle(page);
await page.getByRole('button', { name: 'Choisir le dossier Google Drive' }).click(); await settle(page);
const msg = await status(page), ph = await phase(page);
note(info, 'BUG-03', `${msg} / ${ph}`, ph === 'Erreur — action nécessaire' || /non confirmée/.test(msg));
expect(ph, 'annuler le choix du dossier n’est pas une erreur').not.toBe('Erreur — action nécessaire');
expect(msg).not.toMatch(/Opération non confirmée/);
});

test('BUG-04 cohérence : sur l’origine connectable, l’Accueil annonce « Google Drive — Connexion vérifiée » sans aucun compte connecté', async ({ context, page }, info) => {
test.fail(true, 'Anomalie connue (recette Phase T) : état de démonstration présenté comme réel à côté du vrai parcours Google Drive (à arbitrer).');
if (isIphone(info)) await context.addInitScript(STANDALONE_INIT);
await installFakeGoogle(context);
await openApp(page, '#/drive');
await expect(page.locator('#main p[role=status]').first()).toContainText('Prêt à connecter Google Drive');
await openApp(page, '#/');
const tile = (await page.locator('#main').getByRole('link', { name: /Google Drive/ }).first().innerText()).replace(/\s+/g, ' ');
note(info, 'BUG-04', tile, /Connexion vérifiée/.test(tile));
expect(tile, 'aucun compte Google n’est connecté dans cette session').not.toMatch(/Connexion vérifiée/);
});

test('BUG-05 accessibilité : « Restaurer cette version » est désactivé sans description accessible de la condition (case de confirmation)', async ({ context, page }, info) => {
test.fail(true, 'Anomalie mineure connue (recette Phase T) : bouton désactivé sans aria-describedby ni title.');
if (isIphone(info)) await context.addInitScript(STANDALONE_INIT);
await installFakeGoogle(context);
await openApp(page, '#/applis/race-control');
await page.getByRole('button', { name: /Ouvrir la restauration/ }).first().click();
const go = page.getByRole('button', { name: 'Restaurer cette version' });
await expect(go).toBeDisabled();
const described = await go.evaluate((b) => {
const ids = (b.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
return ids.map((id) => document.getElementById(id)?.textContent?.trim() || '').join(' ') || b.getAttribute('title') || '';
});
note(info, 'BUG-05', described ? `description : ${described}` : 'désactivé, ni aria-describedby ni title (la case « Je remplace l’état actuel… » n’est pas reliée au bouton)', !described);
expect(described, 'la raison de la désactivation doit être exposée au bouton').not.toBe('');
});

test('BUG-06 sauvegarde : « Annuler » devient désactivé pendant le contrôle de l’empreinte sans aucune explication', async ({ context, page }, info) => {
test.fail(true, 'Anomalie mineure connue (recette Phase T) : le code annonce « on retire le bouton » mais le laisse désactivé, sans raison affichée.');
if (isIphone(info)) await context.addInitScript(STANDALONE_INIT);
await installFakeGoogle(context);
await openApp(page, '#/applis');
// L'état est bref : un observateur enregistre l'instant où « Annuler » passe à disabled et ce qui l'explique alors.
await page.evaluate(() => {
const w = window as unknown as { __bug06?: { why: string } };
new MutationObserver((list) => {
for (const m of list) {
const b = m.target as HTMLElement;
if (w.__bug06 || m.attributeName !== 'disabled' || b.tagName !== 'BUTTON' || b.textContent?.trim() !== 'Annuler' || !b.hasAttribute('disabled')) continue;
const ids = (b.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
w.__bug06 = { why: ids.map((id) => document.getElementById(id)?.textContent?.trim() || '').join(' ') || b.getAttribute('title') || '' };
}
}).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['disabled'] });
});
await page.locator('#main').getByRole('button', { name: 'Sauvegarder', exact: true }).first().click();
const seen = await expect.poll(() => page.evaluate(() => !!(window as unknown as { __bug06?: unknown }).__bug06), { timeout: 20_000 }).toBe(true).then(() => true, () => false);
if (!seen) { note(info, 'BUG-06', 'état transitoire non observé pendant cette exécution', false); test.skip(true, 'état transitoire non observé'); return; }
const why = await page.evaluate(() => (window as unknown as { __bug06: { why: string } }).__bug06.why);
note(info, 'BUG-06', why ? `description : ${why}` : 'bouton « Annuler » désactivé, ni aria-describedby, ni title, ni texte expliquant que l’envoi ne peut plus être annulé', !why);
expect(why, 'la raison de la désactivation doit être exposée (ou le bouton retiré)').not.toBe('');
});
30 changes: 30 additions & 0 deletions tests/recette/error-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Recette Phase T — explication des erreurs console connues. Tout le reste est « inexpliquée ».
import type { ErrorRow } from './recorder';

type E = Pick<ErrorRow, 'text' | 'location' | 'type'> & Partial<Pick<ErrorRow, 'browser' | 'pendantCapture'>>;
const RULES: { test: (e: E) => boolean; why: string }[] = [
{
// Playwright (WebKit) insère <style>body {}</style> avant chaque capture d'écran pour synchroniser les animations
// (inPagePrepareForScreenshots / shouldToggleStyleSheetToSyncAnimations). La CSP stricte de l'application
// (style-src 'self') la refuse et WebKit le journalise. Absent sous Chromium, une erreur par capture.
test: (e) => e.type === 'console' && /webkit/.test(e.browser ?? '') && e.pendantCapture === true &&
/^Refused to apply a stylesheet because its hash, its nonce, or 'unsafe-inline' does not appear in the style-src directive/.test(e.text),
why: 'Artefact du banc : feuille de style vide injectée par Playwright WebKit avant chaque capture d’écran, refusée (à juste titre) par la CSP style-src \'self\' de l’application.',
},
{
// OpaqueDrive.createFolder / uploadPacket relisent d'abord l'identifiant réservé (generateIds) :
// le 404 « pas encore créé » est le cas nominal, traité par l'application. Le navigateur le journalise quand même.
test: (e) => e.type === 'console' && /status of 404/.test(e.text) && /^https:\/\/www\.googleapis\.com\/drive\/v3\/files\/recette-gen-\d+/.test(e.location ?? ''),
why: 'Relecture d’un identifiant Drive réservé avant sa création (contrat « réserver puis relire ») : 404 nominal, géré par l’application, journalisé par le navigateur.',
},
{
test: (e) => e.type === 'console' && (/ERR_BLOCKED_BY_CLIENT|blockedbyclient/i.test(e.text) ||
(/Failed to load resource/.test(e.text) && !!e.location && !e.location.startsWith('https://vault.example.com/') && !e.location.startsWith('https://www.googleapis.com/'))),
why: 'Requête vers une origine externe interrompue volontairement par le banc de recette (aucun réseau réel).',
},
];

export function explain(e: E): { why: string; expected: boolean } {
const rule = RULES.find((r) => r.test(e));
return rule ? { why: rule.why, expected: true } : { why: 'Non expliquée par le banc : à analyser (voir contexte, capture et trace).', expected: false };
}
Loading
Loading