import { test, expect } from '@playwright/test'; // Helpers only touch synthetic data in fresh browser contexts. async function store(page, name, operation, value = null) { return page.evaluate(({ name, operation, value }) => new Promise((resolve, reject) => { const request = indexedDB.open('TimeLogDB'); request.onerror = () => reject(request.error); request.onsuccess = () => { const database = request.result; const tx = database.transaction(name, operation === 'getAll' ? 'readonly' : 'readwrite'); const table = tx.objectStore(name); const query = operation === 'getAll' ? table.getAll() : operation === 'put' ? table.put(value) : table.clear(); let result; query.onsuccess = () => { result = query.result; }; tx.oncomplete = () => { database.close(); resolve(result); }; tx.onabort = () => { database.close(); reject(tx.error); }; }; }), { name, operation, value }); } async function ready(page) { await page.goto('/#/sync'); await expect(page.getByRole('heading', { name: 'Synchronisation et appareils' })).toBeVisible(); await store(page, 'settings', 'put', { key: 'app', value: { autoStartDay: false } }); // A real clock is necessary for signed HTTP requests. } async function create(page) { await ready(page); await page.getByLabel('Nom de cet appareil', { exact: true }).fill('Téléphone test'); await page.getByRole('button', { name: 'Préparer la synchronisation', exact: true }).click(); await page.getByText('Afficher le document pour le copier', { exact: true }).click(); const recovery = await page.getByLabel('Contenu du document de secours').inputValue(); await page.getByRole('checkbox', { name: 'J’ai conservé une copie du document à part' }).check(); await page.getByRole('button', { name: 'Activer la synchronisation', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Appareils autorisés', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: 'Synchroniser maintenant', exact: true })).toBeEnabled(); await expect(page.locator('fieldset')).not.toContainText('Opération impossible'); return recovery; } async function sync(page) { await page.getByRole('button', { name: 'Synchroniser maintenant', exact: true }).click(); await expect(page.getByRole('button', { name: 'Synchroniser maintenant', exact: true })).toBeEnabled(); await expect(page.getByText('Modifications envoyées', { exact: true })).toBeVisible(); } async function pair(a, b) { await a.getByRole('button', { name: 'Ajouter un appareil', exact: true }).click(); await expect(a.locator('.code')).toBeVisible(); const code = await a.locator('.code').innerText(); await ready(b); await b.getByLabel('Code d’association', { exact: true }).fill(code); await b.getByRole('button', { name: 'Demander la liaison', exact: true }).click(); await expect(b.locator('.fingerprint')).toBeVisible(); await expect(a.locator('.fingerprint')).toBeVisible({ timeout: 15000 }); expect(await a.locator('.fingerprint').innerText()).toBe(await b.locator('.fingerprint').innerText()); await a.getByLabel('Nom du nouvel appareil', { exact: true }).fill('Ordinateur test'); await a.getByRole('checkbox', { name: 'Les deux empreintes sont identiques' }).check(); await a.getByRole('button', { name: 'Autoriser cet appareil', exact: true }).click(); await expect(b.getByRole('heading', { name: 'Appareils autorisés', exact: true })).toBeVisible({ timeout: 15000 }); await sync(b); } async function addDay(page, task) { const date = '2026-10-08', start = Date.parse(`${date}T08:00:00Z`), end = start + 3600000; await store(page, 'days', 'put', { date, status: 'closed', startedTs: start, endedTs: end, plannedStartTs: start, plannedEndTs: end, lunchType: null, restaurant: null, closePromptedTs: null }); await store(page, 'entries', 'put', { date, seq: 0, kind: 'work', task, client: 'Client synthétique', startTs: start, endTs: end }); } async function editTask(page, task) { const entry = (await store(page, 'entries', 'getAll'))[0]; await store(page, 'entries', 'put', { ...entry, task }); } test('association, modification hors ligne, conflit, rôle et révocation entre deux appareils', async ({ page, browser }) => { test.setTimeout(90000); const errors = []; page.on('pageerror', error => errors.push(error.message)); const otherContext = await browser.newContext({ baseURL: 'http://127.0.0.1:4183', viewport: { width: 320, height: 640 } }); const other = await otherContext.newPage(); other.on('pageerror', error => errors.push(error.message)); try { await create(page); await addDay(page, 'Version initiale'); await sync(page); await pair(page, other); expect((await store(other, 'entries', 'getAll'))[0].task).toBe('Version initiale'); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); expect(await other.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await other.evaluate(() => navigator.serviceWorker.ready.then(() => true)); await page.context().setOffline(true); await otherContext.setOffline(true); await other.reload(); await expect(other.getByRole('heading', { name: 'Appareils autorisés', exact: true })).toBeVisible(); await editTask(page, 'Modification téléphone'); await editTask(other, 'Modification ordinateur'); await page.context().setOffline(false); await sync(page); await otherContext.setOffline(false); await sync(other); await sync(page); await expect(page.getByRole('heading', { name: 'Versions à départager (1)', exact: true })).toBeVisible(); await expect(page.locator('.conflict')).toContainText('Modification téléphone'); await expect(page.locator('.conflict')).toContainText('Modification ordinateur'); const remote = page.locator('.version').filter({ hasText: 'Modification ordinateur' }); await remote.getByRole('button', { name: 'Conserver cette version', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Versions à départager (0)', exact: true })).toBeVisible(); await sync(other); expect((await store(page, 'entries', 'getAll'))[0].task).toBe('Modification ordinateur'); expect((await store(other, 'entries', 'getAll'))[0].task).toBe('Modification ordinateur'); await other.getByRole('button', { name: 'Utiliser cet appareil pour les automatismes', exact: true }).click(); await expect(other.getByRole('button', { name: 'Utiliser cet appareil pour les automatismes', exact: true })).toHaveCount(0); await sync(page); await expect(page.getByRole('button', { name: 'Utiliser cet appareil pour les automatismes', exact: true })).toBeVisible(); await page.getByRole('button', { name: 'Retirer cet appareil…', exact: true }).click(); await page.getByRole('button', { name: 'Confirmer le retrait', exact: true }).click(); await expect(page.getByRole('button', { name: 'Synchroniser maintenant', exact: true })).toBeEnabled(); await other.getByRole('button', { name: 'Synchroniser maintenant', exact: true }).click(); await expect(other.getByText('Cet appareil a été révoqué.', { exact: true })).toBeVisible(); expect((await store(other, 'entries', 'getAll'))[0].task).toBe('Modification ordinateur'); expect(errors).toEqual([]); } finally { await otherContext.close(); } }); test('secours après perte des appareils et annulation de l’invitation en quittant l’écran', async ({ page, browser }) => { test.setTimeout(60000); const recovery = await create(page); await addDay(page, 'Historique récupérable'); await sync(page); await page.getByRole('button', { name: 'Ajouter un appareil', exact: true }).click(); await expect(page.locator('.code')).toBeVisible(); const code = await page.locator('.code').innerText(); await page.getByRole('button', { name: 'Retour aux données', exact: true }).click(); const context = await browser.newContext({ baseURL: 'http://127.0.0.1:4183' }); const replacement = await context.newPage(); try { await ready(replacement); await replacement.getByLabel('Code d’association', { exact: true }).fill(code); await replacement.getByRole('button', { name: 'Demander la liaison', exact: true }).click(); await expect(replacement.getByRole('alert')).toContainText('expirée'); await expect(page.getByRole('dialog')).toHaveCount(0); await expect(page.getByText('Une demande est arrivée.', { exact: false })).toHaveCount(0); await replacement.getByLabel('Ou coller le document').fill(recovery); await replacement.getByRole('button', { name: 'Récupérer l’accès', exact: true }).click(); await expect(replacement.getByRole('heading', { name: 'Appareils autorisés', exact: true })).toBeVisible(); await sync(replacement); expect((await store(replacement, 'entries', 'getAll'))[0].task).toBe('Historique récupérable'); const config = (await store(replacement, 'syncState', 'getAll')).find(r => r.key === 'config').value; expect(config.ring.recovery.privateKey).toBeUndefined(); expect(config.ring.recovery.wrapPrivate).toBeUndefined(); } finally { await context.close(); } });