Protect toolbox data after IndexedDB startup failures
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s
This commit is contained in:
parent
b53c7c17c8
commit
09a2d4b71a
13 changed files with 242 additions and 5 deletions
143
e2e/storage-startup.spec.js
Normal file
143
e2e/storage-startup.spec.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// Rôle : vérifie qu'une panne IndexedDB ne peut pas transformer les données existantes en un état vide modifiable.
|
||||
/* global window, document, IDBFactory, IDBObjectStore, DOMException, Buffer */
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||
|
||||
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/storage-startup-probe.js", import.meta.url))}`;
|
||||
const original = {
|
||||
toolboxes: [{ id: "saved", name: "Conservée", updatedAt: "2026-09-12T12:00:00.000Z", modules: [{ id: "note", type: "memos", title: "Mémos" }] }],
|
||||
links: { mhwilds: "saved" },
|
||||
modules: [{ key: "saved:note", data: { items: [{ id: "memo", text: "Ne pas perdre" }] } }]
|
||||
};
|
||||
|
||||
async function setup(page, fault) {
|
||||
await resetSokkoStorage(page);
|
||||
await page.evaluate(async (data) => {
|
||||
const db = await import("/src/utils/indexedDbStorage.js");
|
||||
await db.setToolboxSnapshot(data.toolboxes, data.links, Object.fromEntries(data.modules.map((entry) => [entry.key, entry.data])));
|
||||
}, original);
|
||||
await page.addInitScript((fault) => {
|
||||
window.storageFault = fault;
|
||||
const open = IDBFactory.prototype.open;
|
||||
IDBFactory.prototype.open = function (...args) {
|
||||
if (window.storageFault === "open") throw new DOMException("Ouverture indisponible", "UnknownError");
|
||||
return open.apply(this, args);
|
||||
};
|
||||
const getAll = IDBObjectStore.prototype.getAll;
|
||||
IDBObjectStore.prototype.getAll = function (...args) {
|
||||
if (window.storageFault === "read" && this.name === "modules") throw new DOMException("Lecture indisponible", "UnknownError");
|
||||
return getAll.apply(this, args);
|
||||
};
|
||||
const put = IDBObjectStore.prototype.put;
|
||||
IDBObjectStore.prototype.put = function (...args) {
|
||||
if (window.storageFault === "write") throw new DOMException("Écriture indisponible", "UnknownError");
|
||||
return put.apply(this, args);
|
||||
};
|
||||
}, fault);
|
||||
await page.goto("/#/toolboxes");
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
async function snapshot(page) {
|
||||
return page.evaluate(async () => {
|
||||
const fault = window.storageFault;
|
||||
window.storageFault = "";
|
||||
try {
|
||||
const db = await import("/src/utils/indexedDbStorage.js");
|
||||
return { toolboxes: await db.getToolboxes(), links: await db.getLinks(), modules: await db.getAllModuleData() };
|
||||
} finally {
|
||||
window.storageFault = fault;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const fault of ["open", "read"]) {
|
||||
test(`initial ${fault} failure blocks the interface and retries without changing persisted data`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize(fault === "read" ? { width: 390, height: 844 } : { width: 1440, height: 1000 });
|
||||
await setup(page, fault);
|
||||
await expect(page.getByRole("heading", { name: "Données locales indisponibles" })).toBeVisible();
|
||||
await expect(page.getByRole("alert")).toHaveText(fault === "open" ? "Ouverture indisponible" : "Lecture indisponible");
|
||||
await expect(page.getByRole("button", { name: /Nouvelle toolbox|Exporter tout|Importer/ })).toHaveCount(0);
|
||||
await expect(page.getByText("Aucune toolbox", { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText("Quota local atteint")).toHaveCount(0);
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
|
||||
await page.screenshot({ path: testInfo.outputPath("storage-unavailable.png"), fullPage: true });
|
||||
await page.getByRole("button", { name: "Réessayer" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Données locales indisponibles" })).toBeVisible();
|
||||
await page.evaluate(() => { window.location.hash = "/toolbox/saved"; });
|
||||
await expect(page.getByRole("heading", { name: "Toolbox introuvable" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "Données locales indisponibles" })).toBeVisible();
|
||||
await page.evaluate(() => { window.storageFault = ""; });
|
||||
await page.getByRole("button", { name: "Réessayer" }).click();
|
||||
await expect(page.locator('[data-module-type="memos"]')).toBeVisible();
|
||||
await expect(page.getByText("Ne pas perdre", { exact: true }).first()).toBeVisible();
|
||||
expect(await snapshot(page)).toEqual(original);
|
||||
|
||||
await page.evaluate(() => { window.location.hash = "/toolboxes"; });
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Exporter tout", exact: true }).click();
|
||||
const download = await downloadPromise;
|
||||
const stream = await download.createReadStream();
|
||||
const chunks = [];
|
||||
for await (const chunk of stream) chunks.push(chunk);
|
||||
const exported = JSON.parse(Buffer.concat(chunks).toString());
|
||||
expect(exported.toolboxes[0].id).toBe("saved");
|
||||
expect(exported.modules["saved:note"].items[0].text).toBe("Ne pas perdre");
|
||||
expect(await snapshot(page)).toEqual(original);
|
||||
|
||||
await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Nom", exact: true }).fill("Après reprise");
|
||||
await page.getByRole("button", { name: "Créer", exact: true }).click();
|
||||
await expect.poll(async () => (await snapshot(page)).toolboxes.length).toBe(2);
|
||||
const saved = await snapshot(page);
|
||||
expect(saved.toolboxes.find((toolbox) => toolbox.id === "saved").name).toBe("Conservée");
|
||||
expect(saved.modules.find((entry) => entry.key === "saved:note")).toEqual(original.modules[0]);
|
||||
expect(saved.links).toEqual(original.links);
|
||||
});
|
||||
}
|
||||
|
||||
test("commands reject writes, creation, imports and exports while the store is not initialized", async ({ page }) => {
|
||||
await setup(page, "read");
|
||||
await page.evaluate(async (url) => {
|
||||
const { mountStorageStartupProbe } = await import(url);
|
||||
const element = document.createElement("div");
|
||||
document.body.append(element);
|
||||
window.storageProbe = mountStorageStartupProbe(element);
|
||||
}, probeUrl);
|
||||
await expect.poll(() => page.evaluate(() => window.storageProbe.store.loadError)).toBe("Lecture indisponible");
|
||||
const results = await page.evaluate(async () => {
|
||||
const { store, actions } = window.storageProbe;
|
||||
const forbidden = () => { throw new Error("Incomplete state must not be consumed"); };
|
||||
return {
|
||||
ready: store.ready,
|
||||
writes: await Promise.all([
|
||||
store.setToolboxes(forbidden), store.setLinks(forbidden), store.setToastPosition("left"),
|
||||
store.updateModuleData("saved", "note", {}), store.removeModuleData("saved", "note"),
|
||||
store.removeToolboxModuleData({ id: "saved", modules: [{ id: "note" }] }),
|
||||
store.persistToolboxSnapshot(forbidden)
|
||||
]),
|
||||
created: actions.createToolbox("New"),
|
||||
imported: await actions.importToolbox({ text: forbidden }),
|
||||
importedAll: await actions.importAllToolboxes({ text: forbidden }),
|
||||
exported: actions.exportToolbox("saved"), exportedAll: actions.exportAllToolboxes(),
|
||||
notices: window.storageProbe.notices
|
||||
};
|
||||
});
|
||||
expect(results).toEqual({ ready: false, writes: Array(7).fill(false), created: null, imported: null, importedAll: false, exported: false, exportedAll: false, notices: [] });
|
||||
expect(await snapshot(page)).toEqual(original);
|
||||
await page.evaluate(() => window.storageProbe.unmount());
|
||||
});
|
||||
|
||||
test("a later write failure uses an accurate title and preserves the confirmed toolbox", async ({ page }) => {
|
||||
await setup(page, "");
|
||||
await expect(page.locator(".toolbox-card")).toHaveCount(1);
|
||||
await page.evaluate(() => { window.storageFault = "write"; });
|
||||
await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Nom", exact: true }).fill("Not saved");
|
||||
await page.getByRole("button", { name: "Créer", exact: true }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Erreur de stockage local" })).toBeVisible();
|
||||
await expect(page.getByText("Quota local atteint")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Compris", exact: true }).click();
|
||||
expect(await snapshot(page)).toEqual(original);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue