Defer media loading and strengthen persistence tests
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
09a2d4b71a
commit
d0e1fd73b1
19 changed files with 459 additions and 71 deletions
179
e2e/deferred-media.spec.js
Normal file
179
e2e/deferred-media.spec.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
// Rôle : vérifie les lectures à la demande, les exports complets et la conservation des médias non chargés.
|
||||
/* global window, document, IDBObjectStore, DOMException */
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
async function setup(page) {
|
||||
await page.route("**/__media-seed", (route) => route.fulfill({ contentType: "text/html", body: "<!doctype html><title>Seed</title>" }));
|
||||
await page.goto("/__media-seed");
|
||||
const seeded = await page.evaluate(async () => {
|
||||
const db = await import("/src/utils/indexedDbStorage.js");
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = 32;
|
||||
const context = canvas.getContext("2d");
|
||||
context.fillStyle = "red";
|
||||
context.fillRect(0, 0, 32, 32);
|
||||
const red = canvas.toDataURL();
|
||||
context.fillStyle = "green";
|
||||
context.fillRect(0, 0, 32, 32);
|
||||
const green = canvas.toDataURL();
|
||||
const targetAt = Date.now() + 60000;
|
||||
const toolboxes = [
|
||||
{ id: "a", name: "Alpha", updatedAt: "2026-09-12T12:00:00Z", modules: [
|
||||
{ id: "images", type: "images", title: "Images" },
|
||||
{ id: "map", type: "imageAnnotation", title: "Carte" },
|
||||
{ id: "timer", type: "timer", title: "Timer" }
|
||||
] },
|
||||
{ id: "b", name: "Beta", updatedAt: "2026-09-12T12:00:00Z", modules: [{ id: "images", type: "images", title: "Images" }] }
|
||||
];
|
||||
const modules = {
|
||||
"a:images": { images: [{ id: "red", label: "Rouge", dataUrl: red }] },
|
||||
"a:map": { image: green, markers: [{ id: "marker", x: 25, y: 50, label: "Entrée" }], drawings: { strokes: [{ id: "stroke", color: "#f6c453", width: 4, points: [{ x: 10, y: 10 }, { x: 80, y: 80 }] }] } },
|
||||
"a:timer": { countdowns: [{ id: "alert", type: "duration", label: "Alerte fermée", targetAt, durationMs: 60000, alertMode: "site" }] },
|
||||
"b:images": { images: [{ id: "green", label: "Vert", dataUrl: green }] }
|
||||
};
|
||||
await db.setToolboxSnapshot(toolboxes, { mhwilds: "a" }, modules);
|
||||
return { toolboxes, modules, targetAt };
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
window.moduleReads = [];
|
||||
window.failedMediaKey = "";
|
||||
const get = IDBObjectStore.prototype.get;
|
||||
IDBObjectStore.prototype.get = function (key) {
|
||||
if (this.name === "modules") {
|
||||
window.moduleReads.push(key);
|
||||
if (key === window.failedMediaKey) throw new DOMException("Média indisponible", "UnknownError");
|
||||
}
|
||||
return get.call(this, key);
|
||||
};
|
||||
});
|
||||
await page.goto("/#/toolboxes");
|
||||
await expect(page.locator(".toolbox-card")).toHaveCount(2);
|
||||
return seeded;
|
||||
}
|
||||
|
||||
async function disk(page) {
|
||||
return page.evaluate(async () => {
|
||||
const db = await import("/src/utils/indexedDbStorage.js");
|
||||
return { toolboxes: await db.getToolboxes(), modules: Object.fromEntries((await db.getAllModuleData()).map((entry) => [entry.key, entry.data])) };
|
||||
});
|
||||
}
|
||||
|
||||
async function exportJson(page, buttonName = "Exporter tout") {
|
||||
const download = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: buttonName, exact: true }).click();
|
||||
const stream = await (await download).createReadStream();
|
||||
const chunks = [];
|
||||
for await (const chunk of stream) chunks.push(chunk);
|
||||
return JSON.parse(Buffer.concat(chunks).toString());
|
||||
}
|
||||
|
||||
test("closed media stays unread while site-wide timer alerts still fire", async ({ page }) => {
|
||||
const seeded = await setup(page);
|
||||
expect(await page.evaluate(() => window.moduleReads)).not.toEqual([]);
|
||||
expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true);
|
||||
await page.clock.install({ time: new Date(seeded.targetAt - 3000) });
|
||||
await page.clock.pauseAt(new Date(seeded.targetAt - 2000));
|
||||
await page.clock.runFor(3000);
|
||||
await expect(page.getByRole("status").filter({ hasText: "Alerte fermée" })).toBeVisible();
|
||||
expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true);
|
||||
});
|
||||
|
||||
test("creation, import and deletion preserve unread media and global exports include it", async ({ page }) => {
|
||||
const seeded = await setup(page);
|
||||
await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Nom", exact: true }).fill("New");
|
||||
await page.getByRole("button", { name: "Créer", exact: true }).click();
|
||||
await expect.poll(async () => (await disk(page)).toolboxes.length).toBe(3);
|
||||
expect((await disk(page)).modules).toMatchObject(seeded.modules);
|
||||
expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true);
|
||||
await page.goto("/#/toolboxes");
|
||||
await page.getByText("Importer une toolbox", { exact: true }).locator("input").setInputFiles({
|
||||
name: "import.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify({ toolbox: { id: "source", name: "Imported", modules: [] }, modules: {} }))
|
||||
});
|
||||
await expect(page.locator(".toolbox-card")).toHaveCount(4);
|
||||
expect((await disk(page)).modules).toMatchObject(seeded.modules);
|
||||
expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true);
|
||||
const exported = await exportJson(page);
|
||||
for (const key of ["a:images", "a:map", "b:images"]) expect(exported.modules[key]).toEqual(seeded.modules[key]);
|
||||
await page.getByRole("button", { name: "Supprimer Beta", exact: true }).click();
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Supprimer", exact: true }).click();
|
||||
await expect.poll(async () => (await disk(page)).modules["b:images"]).toBeUndefined();
|
||||
expect((await disk(page)).modules["a:images"]).toEqual(seeded.modules["a:images"]);
|
||||
expect((await disk(page)).modules["a:map"]).toEqual(seeded.modules["a:map"]);
|
||||
});
|
||||
|
||||
test("single exports include deferred media without reading other toolboxes or retaining it", async ({ page }) => {
|
||||
const seeded = await setup(page);
|
||||
const exported = await exportJson(page, "Exporter Alpha");
|
||||
const imageModule = exported.toolbox.modules.find((module) => module.type === "images");
|
||||
const mapModule = exported.toolbox.modules.find((module) => module.type === "imageAnnotation");
|
||||
expect(exported.modules[imageModule.id].images[0].dataUrl).toBe(seeded.modules["a:images"].images[0].dataUrl);
|
||||
expect(exported.modules[mapModule.id].image).toBe(seeded.modules["a:map"].image);
|
||||
expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false);
|
||||
const reads = await page.evaluate(() => window.moduleReads.filter((key) => key === "a:images").length);
|
||||
await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click();
|
||||
await expect(page.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
|
||||
expect(await page.evaluate(() => window.moduleReads.filter((key) => key === "a:images").length)).toBe(reads + 1);
|
||||
});
|
||||
|
||||
test("opening and editing one toolbox only reads its media and reuses it on return", async ({ page }) => {
|
||||
const seeded = await setup(page);
|
||||
await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click();
|
||||
const image = page.locator('[data-module-type="images"] .image-preview img');
|
||||
await expect(image).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
|
||||
await expect.poll(() => image.evaluate((element) => element.naturalWidth)).toBe(32);
|
||||
await expect(page.locator(".annotation-marker")).toHaveCount(1);
|
||||
expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false);
|
||||
const initialReads = await page.evaluate(() => window.moduleReads.length);
|
||||
await page.getByRole("button", { name: "Afficher l'ajout de Images", exact: true }).click();
|
||||
await page.getByRole("textbox", { name: "Libellé de l'image", exact: true }).fill("Edited");
|
||||
await expect.poll(async () => (await disk(page)).modules["a:images"].images[0].label).toBe("Edited");
|
||||
await page.goto("/#/toolboxes");
|
||||
await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click();
|
||||
await expect(page.getByText("Edited", { exact: true })).toBeVisible();
|
||||
expect(await page.evaluate(() => window.moduleReads.length)).toBe(initialReads);
|
||||
});
|
||||
|
||||
test("the game drawer loads only the linked toolbox media", async ({ page }) => {
|
||||
const seeded = await setup(page);
|
||||
await page.goto("/#/games/mhwilds");
|
||||
await page.getByRole("button", { name: "Ouvrir la toolbox Alpha", exact: true }).click();
|
||||
const drawer = page.locator("#toolbox-drawer .drawer-panel");
|
||||
await expect(drawer.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
|
||||
await expect(drawer.locator(".annotation-marker")).toHaveCount(1);
|
||||
expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false);
|
||||
});
|
||||
|
||||
test("media read errors block only the affected tool and retry without overwriting it", async ({ page }) => {
|
||||
const seeded = await setup(page);
|
||||
await page.evaluate(() => { window.failedMediaKey = "a:images"; });
|
||||
await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click();
|
||||
const module = page.locator('[data-module-type="images"]');
|
||||
await expect(module.getByRole("alert")).toHaveText("Média indisponible");
|
||||
await page.getByRole("button", { name: "Afficher l'ajout de Images", exact: true }).click();
|
||||
await expect(module.locator('input[type="file"], .image-preview')).toHaveCount(0);
|
||||
await expect(page.locator(".annotation-marker")).toHaveCount(1);
|
||||
expect((await disk(page)).modules["a:images"]).toEqual(seeded.modules["a:images"]);
|
||||
await module.getByRole("button", { name: "Réessayer" }).click();
|
||||
await expect(module.getByRole("alert")).toHaveText("Média indisponible");
|
||||
await page.evaluate(() => { window.failedMediaKey = ""; });
|
||||
await module.getByRole("button", { name: "Réessayer" }).click();
|
||||
await expect(module.locator(".image-preview img")).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
|
||||
});
|
||||
|
||||
test("failed media exports produce no partial download and can be retried", async ({ page }) => {
|
||||
const seeded = await setup(page);
|
||||
const downloads = [];
|
||||
page.on("download", (download) => downloads.push(download));
|
||||
await page.evaluate(() => { window.failedMediaKey = "b:images"; });
|
||||
await page.getByRole("button", { name: "Exporter tout", exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "Erreur de stockage local" });
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(downloads).toHaveLength(0);
|
||||
await dialog.getByRole("button", { name: "Compris" }).click();
|
||||
await page.evaluate(() => { window.failedMediaKey = ""; });
|
||||
const exported = await exportJson(page);
|
||||
expect(exported.modules["b:images"]).toEqual(seeded.modules["b:images"]);
|
||||
expect(downloads).toHaveLength(1);
|
||||
});
|
||||
|
|
@ -27,3 +27,22 @@ test("a synchronous snapshot error aborts all its IndexedDB writes", async ({ pa
|
|||
modules: [{ key: "saved:note", data: { text: "Keep" } }]
|
||||
});
|
||||
});
|
||||
|
||||
test("partial snapshots preserve deferred values and roll back deletes on failure", async ({ page }) => {
|
||||
await resetSokkoStorage(page);
|
||||
const result = await page.evaluate(async () => {
|
||||
const db = await import("/src/utils/indexedDbStorage.js");
|
||||
await db.setToolboxSnapshot([{ id: "saved", modules: [] }], {}, { media: { image: "Keep" }, removed: { text: "Delete" } });
|
||||
let failed = false;
|
||||
try {
|
||||
await db.setToolboxSnapshot([{ id: "broken" }], {}, { broken: { invalid: () => {} } }, ["media"]);
|
||||
} catch { failed = true; }
|
||||
const afterFailure = await db.getAllModuleData();
|
||||
await db.setToolboxSnapshot([{ id: "saved", modules: [] }], {}, { note: { text: "New" } }, ["media"]);
|
||||
return { failed, afterFailure, afterSuccess: await db.getAllModuleData() };
|
||||
});
|
||||
expect(result).toEqual({ failed: true,
|
||||
afterFailure: [{ key: "media", data: { image: "Keep" } }, { key: "removed", data: { text: "Delete" } }],
|
||||
afterSuccess: [{ key: "media", data: { image: "Keep" } }, { key: "note", data: { text: "New" } }]
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -120,7 +120,7 @@ test("commands reject writes, creation, imports and exports while the store is n
|
|||
created: actions.createToolbox("New"),
|
||||
imported: await actions.importToolbox({ text: forbidden }),
|
||||
importedAll: await actions.importAllToolboxes({ text: forbidden }),
|
||||
exported: actions.exportToolbox("saved"), exportedAll: actions.exportAllToolboxes(),
|
||||
exported: await actions.exportToolbox("saved"), exportedAll: await actions.exportAllToolboxes(),
|
||||
notices: window.storageProbe.notices
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue