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); +});