Improved import security, added browser tests
Some checks failed
Tests et déploiement / deploy (push) Has been cancelled
Tests et déploiement / validate (push) Has been cancelled

This commit is contained in:
2026-10-08 21:34:58 +02:00
parent 5cf19bb8ac
commit b0aa5acf4b
28 changed files with 793 additions and 99 deletions

249
tests/e2e/parcours.spec.js Normal file
View File

@@ -0,0 +1,249 @@
import { test, expect } from '@playwright/test';
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.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(); };
};
}));
}
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('.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('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.getByTestId('stats-worked')).toHaveText('9h00');
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('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()]);
await expect(page.getByRole('heading',{name:'En cours',exact:true})).toBeVisible();
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();
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:'Réglages',exact:true})).toBeVisible();
await page.getByRole('button',{name:'Récap',exact:true}).click();
await expect(page.locator('.total')).toContainText('8h30');
});
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:'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('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);
});