Added optionnal online storage and cross-client sync
All checks were successful
Tests et déploiement / validate (push) Successful in 2m44s
Tests et déploiement / deploy (push) Successful in 51s

This commit is contained in:
2026-10-10 16:01:06 +02:00
parent 277b9c887e
commit 5686ee5072
33 changed files with 2466 additions and 25 deletions

133
tests/e2e/sync.spec.js Normal file
View File

@@ -0,0 +1,133 @@
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(); }
});