import { test, expect } from '@playwright/test'; import { readFile } from 'node:fs/promises'; const date = '2026-10-08'; const at = (time, key = date) => Date.parse(`${key}T${time}:00+02:00`); const now = at('18:00'); function fixture({ pause = true, dates = [date], lastKind = 'work' } = {}) { const days = dates.map(key => ({ date:key, status:'closed', startedTs:at('08:00',key), endedTs:at('17:00',key), plannedStartTs:at('08:00',key), plannedEndTs:at('17:00',key), lunchType:'panier' })); const entry = (key, seq, kind, start, end) => ({ date:key, seq, kind, task:kind === 'break' ? 'Pause' : kind === 'lunch' ? 'Pause déjeuner' : 'Tâche de test', client:kind === 'work' ? 'Client de test' : null, startTs:at(start,key),endTs:at(end,key) }); const entries = dates.flatMap(key => pause ? [entry(key,0,'work','08:00','12:00'),entry(key,1,'break','12:00','12:30'),entry(key,2,'work','12:30','17:00')] : lastKind === 'work' ? [entry(key,0,'work','08:00','17:00')] : [entry(key,0,'work','08:00','16:00'),entry(key,1,lastKind,'16:00','17:00')]); return { format:'timelog-export',version:2,exportedAt:new Date(now).toISOString(),settings:{autoStartDay:false,autoEndDay:false,autoLunch:false,retentionDays:30,roundingMinutes:15},days,entries }; } async function importFixture(page, payload = fixture()) { await page.clock.install({ time:now }); await page.goto('/#/settings'); await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible(); await page.getByRole('button',{name:'Gérer les données et sauvegardes'}).click(); await expect(page.getByRole('heading',{name:'Données et sauvegardes',exact:true})).toBeVisible(); await page.locator('input[type=file]').setInputFiles({ name:'fixture-synthetique.json',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(payload)) }); await page.getByRole('button',{name:'Remplacer tout',exact:true}).click(); await page.getByRole('dialog',{name:'Remplacer les données'}).getByRole('button',{name:'Confirmer',exact:true}).click(); await expect(page.getByRole('status').filter({hasText:'Données remplacées'})).toBeVisible(); await page.getByRole('button',{name:'Récap',exact:true}).click(); } async function entries(page) { return page.evaluate(() => new Promise((resolve,reject) => { const request = indexedDB.open('TimeLogDB'); request.onerror = () => reject(new Error('Cannot open test database')); request.onsuccess = () => { const db = request.result; const query = db.transaction('entries').objectStore('entries').getAll(); query.onsuccess = () => { resolve(query.result.sort((a,b)=>a.date.localeCompare(b.date)||a.seq-b.seq)); db.close(); }; query.onerror = () => { reject(new Error('Cannot read test entries')); db.close(); }; }; })); } async function storedData(page) { return page.evaluate(() => new Promise((resolve,reject) => { const request = indexedDB.open('TimeLogDB'); request.onerror = () => reject(request.error); request.onsuccess = () => { const db = request.result; const stores = ['days','entries','settings']; const transaction = db.transaction(stores); const snapshot = {}; for (const store of stores) { const query = transaction.objectStore(store).getAll(); query.onsuccess = () => { snapshot[store] = query.result; }; } transaction.oncomplete = () => { db.close(); resolve(snapshot); }; transaction.onabort = () => { db.close(); reject(transaction.error); }; }; })); } test('consultation, correction, recalcul, copie et statistiques à 320 px', async ({page,context}) => { await context.grantPermissions(['clipboard-read','clipboard-write']); await importFixture(page); await expect(page.locator('.total')).toContainText('8h30'); await expect(page.locator('.total')).toContainText('Total des heures de présence'); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await expect(page.locator('.screen')).not.toContainText('0h30'); await page.getByRole('button',{name:'Copier le récapitulatif'}).click(); const copied = await page.evaluate(() => navigator.clipboard.readText()); expect(copied).toContain('Total des heures de présence : 8h30'); expect(copied).not.toContain('Pause'); await page.getByRole('button',{name:'Voir les détails'}).click(); await expect(page.locator('.timeline button')).toHaveCount(3); await expect(page.locator('.timeline button').nth(1)).toBeDisabled(); await expect(page.getByRole('dialog')).toHaveCount(0); await page.getByRole('button',{name:'Corriger cette journée'}).click(); await page.locator('.timeline button').nth(1).click(); const dialog = page.getByRole('dialog',{name:'Modifier le poste'}); await expect(dialog.getByRole('button',{name:'Fermer',exact:true})).toBeFocused(); await page.keyboard.press('Shift+Tab'); await expect(dialog.getByRole('button',{name:'Supprimer ce poste'})).toBeFocused(); await page.keyboard.press('Tab'); await expect(dialog.getByRole('button',{name:'Fermer',exact:true})).toBeFocused(); await page.keyboard.press('Escape'); await expect(page.locator('.timeline button').nth(1)).toBeFocused(); await page.locator('.timeline button').nth(1).click(); await dialog.getByRole('button',{name:'Supprimer ce poste'}).click(); await dialog.getByRole('button',{name:'Confirmer la suppression'}).click(); await expect(page.locator('.timeline button')).toHaveCount(2); await page.getByRole('button',{name:'Retour au récapitulatif'}).click(); await expect(page.locator('.total')).toContainText('9h00'); await page.getByRole('button',{name:'Réglages',exact:true}).click(); await page.getByRole('button',{name:'Consulter les statistiques'}).click(); await expect(page.getByText('Heures de présence',{exact:true})).toBeVisible(); await expect(page.getByTestId('stats-worked')).toHaveText('9h00'); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); // Wider or larger fonts must also fit the backup reminder on CI and mobile. await expect(page.locator('.update')).toContainText('Sauvegarde à vérifier'); await page.addStyleTag({content:'.update { font-family: monospace; font-size: 24px; }'}); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); }); test('date conservée, rechargement et Retour depuis les trois nouvelles pages', async ({page}) => { await importFixture(page,fixture({dates:['2026-10-07',date]})); await page.getByLabel('Journée à consulter').selectOption('2026-10-07'); await page.getByRole('button',{name:'Voir les détails'}).click(); await page.reload(); await expect(page.getByRole('heading',{name:/7 octobre/})).toBeVisible(); await page.getByRole('button',{name:'Retour au récapitulatif'}).click(); await expect(page.getByLabel('Journée à consulter')).toHaveValue('2026-10-07'); await page.getByRole('button',{name:'Réglages',exact:true}).click(); await page.getByRole('button',{name:'Gérer les automatismes'}).click(); await expect(page.getByRole('checkbox')).toHaveCount(6); await page.goBack(); await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible(); await page.getByRole('button',{name:'Gérer les jours non travaillés'}).click(); await expect(page.getByLabel('Date à bloquer')).toBeVisible(); await page.goBack(); await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible(); }); test('écran données dédié, conservation persistée et accès depuis le rappel', async ({page}) => { await importFixture(page,fixture({dates:['2025-01-01',date]})); await page.getByRole('button',{name:'Réglages',exact:true}).click(); await expect(page.getByRole('button',{name:'Exporter les données',exact:true})).toHaveCount(0); await expect(page.getByLabel('Conserver (jours)')).toHaveCount(0); await page.getByRole('button',{name:'Gérer les données et sauvegardes'}).click(); await expect(page.getByRole('heading',{name:'Données et sauvegardes',exact:true})).toBeVisible(); await expect(page.locator('nav').getByRole('button',{name:'Réglages',exact:true})).toHaveAttribute('aria-current','true'); const retention = page.getByLabel('Conserver (jours)'); await expect(retention).toHaveValue('30'); await retention.fill('60'); await retention.press('Tab'); await expect.poll(() => page.evaluate(() => new Promise((resolve,reject) => { const request = indexedDB.open('TimeLogDB'); request.onerror = () => reject(request.error); request.onsuccess = () => { const db = request.result; const query = db.transaction('settings').objectStore('settings').get('app'); query.onsuccess = () => { resolve(query.result.value.retentionDays); db.close(); }; query.onerror = () => { reject(query.error); db.close(); }; }; }))).toBe(60); await page.reload(); await expect(retention).toHaveValue('60'); expect(await entries(page)).toHaveLength(6); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); await page.getByRole('button',{name:'Retour aux réglages'}).click(); await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible(); await page.getByRole('button',{name:'Gérer les données et sauvegardes'}).click(); await page.goBack(); await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible(); await page.getByRole('button',{name:'Récap',exact:true}).click(); await page.getByRole('button',{name:'Sauvegarder',exact:true}).click(); await expect(page.getByRole('heading',{name:'Données et sauvegardes',exact:true})).toBeVisible(); }); test('échec de conservation restaure le champ et les données, puis permet de réessayer', async ({page}) => { await importFixture(page,fixture({dates:['2025-01-01',date]})); await page.getByRole('button',{name:'Sauvegarder',exact:true}).click(); const input = page.getByLabel('Conserver (jours)'); await expect(input).toHaveValue('30'); const before = await storedData(page); await page.evaluate(() => { const originalPut = IDBObjectStore.prototype.put; IDBObjectStore.prototype.put = function(record,...args) { if (this.name === 'settings' && record.key === 'app') { IDBObjectStore.prototype.put = originalPut; throw new Error('Échec de conservation simulé'); } return originalPut.call(this,record,...args); }; }); await input.fill('60'); await input.press('Tab'); await expect(page.getByRole('alert')).toContainText('Enregistrement impossible'); await expect(input).toHaveValue('30'); expect(await storedData(page)).toEqual(before); await page.reload(); await expect(input).toHaveValue('30'); await input.fill('60'); await input.press('Tab'); await expect.poll(async () => (await storedData(page)).settings.find(row => row.key === 'app').value.retentionDays).toBe(60); await expect(page.getByRole('alert')).toHaveCount(0); const after = await storedData(page); expect(after.days).toEqual(before.days); expect(after.entries).toEqual(before.entries); }); test('reprise annulée puis concurrente entre deux onglets, choix pause par défaut', async ({page,context}) => { await importFixture(page,fixture({pause:false})); const before = await entries(page); await page.getByRole('button',{name:'Reprendre la journée',exact:true}).click(); let dialog = page.getByRole('dialog',{name:'Reprendre la journée',exact:true}); await expect(dialog.getByRole('radio',{name:'Créer une pause jusqu’à maintenant'})).toBeChecked(); await dialog.getByRole('button',{name:'Annuler'}).click(); expect(await entries(page)).toEqual(before); await expect(page.getByRole('button',{name:'Reprendre la journée',exact:true})).toBeFocused(); const other = await context.newPage(); await other.clock.install({time:now}); await other.goto('/#/today'); await Promise.all([page.getByRole('button',{name:'Reprendre la journée',exact:true}).click(),other.getByRole('button',{name:'Reprendre la journée',exact:true}).click()]); await Promise.all([page.getByRole('button',{name:'Confirmer la reprise'}).dblclick(),other.getByRole('button',{name:'Confirmer la reprise'}).click()]); // Either tab may win the transaction; the resumed entries must remain unique. await expect.poll(async () => await page.getByRole('heading',{name:'En cours',exact:true}).isVisible() || await other.getByRole('heading',{name:'En cours',exact:true}).isVisible() ).toBe(true); const rows = await entries(page); expect(rows.map(row=>row.kind)).toEqual(['work','break','work']); expect(rows[0].endTs).toBe(at('17:00')); expect(rows[1].startTs).toBe(at('17:00')); expect(Math.abs(rows[1].endTs-now)).toBeLessThan(60_000); }); test('choix continu et reprise immédiate d’une pause sur Journée', async ({page}) => { await importFixture(page,fixture({pause:false})); await page.getByRole('button',{name:'Journée',exact:true}).click(); await page.getByRole('button',{name:'Reprendre la journée',exact:true}).click(); await page.getByRole('radio',{name:'Continuer depuis la fin enregistrée'}).check(); await page.getByRole('button',{name:'Confirmer la reprise'}).click(); await expect(page.getByRole('heading',{name:'En cours',exact:true})).toBeVisible(); expect(await entries(page)).toHaveLength(1); await page.getByRole('button',{name:'Commencer une pause'}).click(); await expect(page.getByRole('heading',{name:'Pause en cours'})).toBeVisible(); await page.getByRole('button',{name:'Reprendre le travail'}).click(); const rows = await entries(page); expect(rows.map(row=>row.kind)).toEqual(['work','break','work']); expect(rows.at(-1)).toMatchObject({task:'Tâche de test',client:'Client de test',endTs:null}); }); for (const kind of ['lunch','break']) test(`reprise après ${kind} garde sa nature`, async ({page}) => { await importFixture(page,fixture({pause:false,lastKind:kind})); await page.getByRole('button',{name:'Reprendre la journée',exact:true}).click(); await page.getByRole('radio',{name:kind === 'lunch' ? 'Prolonger le repas depuis la fin enregistrée' : 'Prolonger la pause depuis la fin enregistrée'}).check(); await page.getByRole('button',{name:'Confirmer la reprise'}).click(); await expect(page.getByRole('heading',{name:'En cours',exact:true})).toBeVisible(); const rows = await entries(page); expect(rows[1].kind).toBe(kind); expect(rows.at(-1).kind).toBe('work'); }); test('export relu et vérifié, PWA relancée hors ligne', async ({page,context}) => { await importFixture(page); await page.getByRole('button',{name:'Réglages',exact:true}).click(); await page.getByRole('button',{name:'Gérer les données et sauvegardes'}).click(); const downloading = page.waitForEvent('download'); await page.getByRole('button',{name:'Exporter les données',exact:true}).click(); const download = await downloading; await page.getByRole('button',{name:'Vérifier le fichier exporté…'}).click(); await page.locator('input[type=file]').setInputFiles(await download.path()); await expect(page.getByRole('status').filter({hasText:/^Sauvegarde vérifiée :/})).toBeVisible(); await page.evaluate(async () => { await navigator.serviceWorker.ready; }); await page.reload(); await expect.poll(() => page.evaluate(() => Boolean(navigator.serviceWorker.controller))).toBe(true); await context.setOffline(true); await page.reload(); await expect(page.getByRole('heading',{name:'Données et sauvegardes',exact:true})).toBeVisible(); await page.getByRole('button',{name:'Récap',exact:true}).click(); await expect(page.locator('.total')).toContainText('8h30'); }); test('export modifié refusé sans écriture, puis vérification du fichier original', async ({page}) => { await importFixture(page); await page.getByRole('button',{name:'Sauvegarder',exact:true}).click(); const downloading = page.waitForEvent('download'); await page.getByRole('button',{name:'Exporter les données',exact:true}).click(); const download = await downloading; const original = await readFile(await download.path()); const modified = JSON.parse(original.toString('utf8')); modified.entries[0].task = 'Tâche modifiée dans le fichier'; await expect(page.getByRole('status').filter({hasText:'Export préparé'})).toBeVisible(); const before = await storedData(page); await page.getByRole('button',{name:'Vérifier le fichier exporté…'}).click(); await page.locator('input[type=file]').setInputFiles({ name:'export-modifie.json',mimeType:'application/json',buffer:Buffer.from(JSON.stringify(modified)), }); await expect(page.getByRole('alert')).toContainText('Ce fichier ne correspond pas au dernier export préparé'); expect(await storedData(page)).toEqual(before); await expect(page.getByRole('status').filter({hasText:/^Sauvegarde vérifiée :/})).toHaveCount(0); await page.getByRole('button',{name:'Vérifier le fichier exporté…'}).click(); await page.locator('input[type=file]').setInputFiles(await download.path()); await expect(page.getByRole('status').filter({hasText:/^Sauvegarde vérifiée :/})).toBeVisible(); await expect(page.getByRole('alert')).toHaveCount(0); const after = await storedData(page); expect(after.days).toEqual(before.days); expect(after.entries).toEqual(before.entries); expect(after.settings.find(row => row.key === 'backup-status').value.verifiedExportedAt) .toBe(before.settings.find(row => row.key === 'backup-status').value.exportedAt); }); test('échec IndexedDB visible et reprise avec Réessayer', async ({page}) => { await page.addInitScript(() => { const original = indexedDB.open.bind(indexedDB); indexedDB.open = () => { throw new Error('IndexedDB indisponible pour le test'); }; window.enableTestStorage = () => (indexedDB.open = original); }); await page.goto('/#/settings'); await expect(page.getByRole('alert')).toContainText('Accès aux données impossible'); await page.evaluate(() => window.enableTestStorage()); await page.getByRole('button',{name:'Réessayer',exact:true}).click(); await expect(page.getByRole('heading',{name:'Réglages',exact:true})).toBeVisible(); }); test('purge confirmée et restauration de sa sauvegarde récupérable', async ({page}) => { await importFixture(page,fixture({dates:['2025-01-01',date]})); await page.getByRole('button',{name:'Réglages',exact:true}).click(); await page.getByRole('button',{name:'Gérer les données et sauvegardes'}).click(); await page.getByRole('button',{name:'Purger maintenant'}).click(); const dialog = page.getByRole('dialog',{name:'Confirmer la purge'}); await expect(dialog).toContainText('2025-01-01'); await dialog.getByRole('button',{name:'Annuler'}).click(); expect(await entries(page)).toHaveLength(6); await page.getByRole('button',{name:'Purger maintenant'}).click(); await dialog.getByRole('button',{name:'Confirmer la purge'}).click(); await expect(dialog).toHaveCount(0); expect(await entries(page)).toHaveLength(3); await page.getByRole('button',{name:'Récupérer la sauvegarde avant purge'}).click(); await page.getByRole('button',{name:'Fusionner',exact:true}).click(); await expect(page.getByRole('status').filter({hasText:'1 journée ajoutée'})).toBeVisible(); expect(await entries(page)).toHaveLength(6); }); test('échec de purge conserve les journées et la sauvegarde précédente, puis permet de réessayer', async ({page}) => { await importFixture(page,fixture({dates:['2025-01-01',date]})); await page.getByRole('button',{name:'Sauvegarder',exact:true}).click(); const dialog = page.getByRole('dialog',{name:'Confirmer la purge'}); await page.getByRole('button',{name:'Purger maintenant'}).click(); await dialog.getByRole('button',{name:'Confirmer la purge'}).click(); await expect(dialog).toHaveCount(0); await page.getByRole('button',{name:'Récupérer la sauvegarde avant purge'}).click(); await page.getByRole('button',{name:'Fusionner',exact:true}).click(); await expect(page.getByRole('status').filter({hasText:'1 journée ajoutée'})).toBeVisible(); const before = await storedData(page); expect(before.days).toHaveLength(2); expect(before.entries).toHaveLength(6); expect(before.settings.find(row => row.key === 'before-purge')).toBeDefined(); // Make the next backup differ even if the restored records are identical. await page.clock.runFor(1000); await page.getByRole('button',{name:'Purger maintenant'}).click(); await page.evaluate(() => { const originalDelete = IDBObjectStore.prototype.delete; IDBObjectStore.prototype.delete = function(...args) { if (this.name === 'days') { IDBObjectStore.prototype.delete = originalDelete; // Entries and the new backup have already been written in this transaction. throw new Error('Échec de purge simulé'); } return originalDelete.apply(this,args); }; }); await dialog.getByRole('button',{name:'Confirmer la purge'}).click(); await expect(dialog.getByRole('alert')).toContainText('Échec de purge simulé'); expect(await storedData(page)).toEqual(before); await expect(dialog.getByRole('button',{name:'Confirmer la purge'})).toBeEnabled(); await dialog.getByRole('button',{name:'Confirmer la purge'}).click(); await expect(dialog).toHaveCount(0); const after = await storedData(page); expect(after.days).toEqual(before.days.filter(day => day.date === date)); expect(after.entries).toEqual(before.entries.filter(entry => entry.date === date)); const recovered = after.settings.find(row => row.key === 'before-purge').value; expect(recovered.days.map(day => day.date).sort()).toEqual(['2025-01-01',date]); expect(recovered.entries).toHaveLength(6); expect(recovered.exportedAt).not.toBe(before.settings.find(row => row.key === 'before-purge').value.exportedAt); }); test('dialogue horaire imbriqué garde le focus et le rend à la correction', async ({page}) => { await importFixture(page); await page.getByRole('button',{name:'Voir les détails'}).click(); await page.getByRole('button',{name:'Corriger cette journée'}).click(); await page.locator('.timeline button').nth(1).click(); const correction = page.getByRole('dialog',{name:'Modifier le poste'}); await correction.getByRole('button',{name:'Début du poste : 12:00'}).click(); const clock = page.getByRole('dialog',{name:'Début du poste',exact:true}); await expect(clock.getByRole('button',{name:'Fermer',exact:true})).toBeFocused(); await page.keyboard.press('Shift+Tab'); await expect(clock.getByRole('button',{name:'Valider',exact:true})).toBeFocused(); await page.keyboard.press('Escape'); await expect(clock).toHaveCount(0); await expect(correction.getByRole('button',{name:'Début du poste : 12:00'})).toBeFocused(); await page.keyboard.press('Escape'); await expect(correction).toHaveCount(0); await expect(page.locator('.timeline button').nth(1)).toBeFocused(); }); test('pause ajoutée dans un intervalle vide sans combler ses portions restantes', async ({page}) => { const payload = fixture({pause:false}); payload.entries = [ {...payload.entries[0],endTs:at('11:00')}, {...payload.entries[0],seq:1,startTs:at('13:00')}, ]; await importFixture(page,payload); await page.getByRole('button',{name:'Voir les détails'}).click(); await page.getByRole('button',{name:'Corriger cette journée'}).click(); await page.getByRole('button',{name:'Ajouter un poste oublié'}).click(); const dialog = page.getByRole('dialog',{name:'Ajouter un poste',exact:true}); await dialog.getByLabel('Plage à corriger').selectOption({label:'11:00–13:00 · Intervalle sans poste'}); await dialog.getByRole('button',{name:'Pause',exact:true}).click(); await dialog.getByRole('button',{name:'Début du poste : 11:00'}).click(); let clock = page.getByRole('dialog',{name:'Début du poste',exact:true}); await clock.getByLabel('Heures',{exact:true}).selectOption('12'); await clock.getByRole('button',{name:'Valider'}).click(); await dialog.getByRole('button',{name:'Fin du poste : 13:00'}).click(); clock = page.getByRole('dialog',{name:'Fin du poste',exact:true}); await clock.getByLabel('Heures',{exact:true}).selectOption('12'); await clock.getByLabel('Minutes',{exact:true}).selectOption('30'); await clock.getByRole('button',{name:'Valider'}).click(); await dialog.getByRole('button',{name:'Enregistrer',exact:true}).click(); await expect(dialog).toHaveCount(0); const rows = await entries(page); expect(rows.map(row=>row.kind)).toEqual(['work','break','work']); expect(rows[0].endTs).toBe(at('11:00')); expect(rows[2].startTs).toBe(at('13:00')); expect(rows[1]).toMatchObject({startTs:at('12:00'),endTs:at('12:30'),client:null}); }); test('réglage horaire restauré dans le champ après échec de sauvegarde', async ({page}) => { await importFixture(page); await page.getByRole('button',{name:'Réglages',exact:true}).click(); const input = page.getByLabel('Début de journée',{exact:true}); const original = await input.inputValue(); await page.evaluate(() => { const originalPut = IDBObjectStore.prototype.put; IDBObjectStore.prototype.put = function(record,...args) { if (this.name === 'settings' && record.key === 'app') { IDBObjectStore.prototype.put = originalPut; throw new Error('Échec d’écriture simulé'); } return originalPut.call(this,record,...args); }; }); await input.fill('09:00'); await input.press('Tab'); await expect(page.locator('.error.card')).toContainText('Enregistrement impossible'); await expect(input).toHaveValue(original); });