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
13 changes: 9 additions & 4 deletions src/flows/sheets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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));
Expand Down
22 changes: 18 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -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,
),
Expand Down Expand Up @@ -133,6 +137,10 @@ function parse(): { route: Route; params: Record<string, string>; 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?.();
Expand Down Expand Up @@ -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 },
Expand All @@ -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 },
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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());
Expand Down
22 changes: 20 additions & 2 deletions src/services/connected-drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof promise>;
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;
Expand Down
10 changes: 6 additions & 4 deletions src/services/google-drive-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' })); }
});
Expand Down
2 changes: 1 addition & 1 deletion src/views/drive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
Expand Down
3 changes: 2 additions & 1 deletion tests/e2e/connected-drive.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading