Make app data loading safe under StrictMode
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
This commit is contained in:
parent
b470a34b2a
commit
99d916f6db
6 changed files with 207 additions and 32 deletions
119
e2e/app-data.spec.js
Normal file
119
e2e/app-data.spec.js
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Rôle : couvre chargement différé, cache de navigation, erreurs et annulations des données publiques.
|
||||
/* global window, document */
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
|
||||
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/app-data-probe.js", import.meta.url))}`;
|
||||
const siteUrl = "/data/site.json";
|
||||
const gamesUrl = "/data/games.json";
|
||||
const diabloUrl = "/data/diablo4/affixes_types.json";
|
||||
const mhwBase = "/data/mhwilds/";
|
||||
|
||||
async function setup(page, route = "/") {
|
||||
await page.route("**/__app-data-probe", (request) => request.fulfill({
|
||||
contentType: "text/html", body: '<!doctype html><div id="probe"></div>'
|
||||
}));
|
||||
await page.goto("/__app-data-probe");
|
||||
await page.evaluate(async ({ url, route }) => {
|
||||
const { mountAppDataProbe } = await import(url);
|
||||
window.dataProbe = mountAppDataProbe(document.getElementById("probe"), route);
|
||||
}, { url: probeUrl, route });
|
||||
}
|
||||
|
||||
async function state(page) {
|
||||
return JSON.parse(await page.locator("#data-state").textContent());
|
||||
}
|
||||
|
||||
async function respond(page, url, payload, status = 200, aborted = false) {
|
||||
await page.evaluate(({ url, payload, status, aborted }) => {
|
||||
window.dataProbe.respond(url, payload, status, aborted);
|
||||
}, { url, payload, status, aborted });
|
||||
}
|
||||
|
||||
test("game data loads only on demand and remains cached through navigation", async ({ page }) => {
|
||||
await setup(page);
|
||||
expect((await state(page)).mhwilds.loading).toBe(false);
|
||||
expect(await page.evaluate(() => window.dataProbe.requests().map((request) => request.url)))
|
||||
.toEqual([siteUrl, gamesUrl, siteUrl, gamesUrl]);
|
||||
await respond(page, siteUrl, { title: "Current" });
|
||||
await respond(page, gamesUrl, { games: [{ id: "mhwilds" }] });
|
||||
await expect.poll(async () => (await state(page)).siteContent?.title).toBe("Current");
|
||||
await respond(page, siteUrl, { title: "Obsolete" }, 200, true);
|
||||
await respond(page, gamesUrl, {}, 503, true);
|
||||
|
||||
const snapshots = await page.evaluate(() => {
|
||||
window.dataProbe.navigate("/games/mhwilds");
|
||||
return window.dataProbe.snapshots;
|
||||
});
|
||||
expect(snapshots.length).toBeGreaterThan(0);
|
||||
for (const snapshot of snapshots) {
|
||||
expect(snapshot.mhwilds.loading).toBe(true);
|
||||
expect(snapshot.mhwilds.loaded).toBe(false);
|
||||
expect(snapshot.siteContent.title).toBe("Current");
|
||||
expect(snapshot.gamesError).toBe("");
|
||||
}
|
||||
await respond(page, `${mhwBase}monsters.json`, { monsters: [{ id: "rey-dau" }] });
|
||||
await respond(page, `${mhwBase}endemic_life.json`, {});
|
||||
await respond(page, `${mhwBase}lists/index.json`, { lists: [{ id: "farm", file: "farm.json" }] });
|
||||
await respond(page, `${mhwBase}i18n/fr.json`, { hello: "Bonjour" });
|
||||
await expect.poll(() => page.evaluate(() => window.dataProbe.requests().some((request) => request.url.endsWith("/farm.json")))).toBe(true);
|
||||
await page.evaluate(() => {
|
||||
window.dataProbe.navigate("/games/diablo4");
|
||||
window.dataProbe.navigate("/");
|
||||
});
|
||||
await respond(page, `${mhwBase}lists/farm.json`, { titre: "Farm", items: [{ nom: "Potion", quantite: 2 }] });
|
||||
await respond(page, diabloUrl, { affixes: [{ id: "damage" }], categories: [{ id: "attack" }] });
|
||||
await expect.poll(async () => (await state(page)).mhwilds.loaded).toBe(true);
|
||||
await expect.poll(async () => (await state(page)).diablo4.loaded).toBe(true);
|
||||
const loaded = await state(page);
|
||||
expect(loaded.mhwilds.lists[0].categories[0].items[0].quantity).toBe(2);
|
||||
expect(loaded.diablo4.categoryMap.attack).toEqual({ id: "attack" });
|
||||
const count = await page.evaluate(() => window.dataProbe.requests().length);
|
||||
await page.evaluate(() => {
|
||||
window.dataProbe.navigate("/games/mhwilds/monsters");
|
||||
window.dataProbe.navigate("/games/diablo4");
|
||||
});
|
||||
expect(await page.evaluate(() => window.dataProbe.requests().length)).toBe(count);
|
||||
expect((await state(page)).mhwilds.loading).toBe(false);
|
||||
await page.evaluate(() => window.dataProbe.unmount());
|
||||
expect(await page.evaluate(() => window.dataProbe.requests().every((request) => request.aborted))).toBe(true);
|
||||
});
|
||||
|
||||
test("StrictMode discards obsolete game failures and preserves current editorial errors", async ({ page }) => {
|
||||
await setup(page, "/games/diablo4");
|
||||
const requests = await page.evaluate(() => window.dataProbe.requests().filter((request) => request.url.includes("diablo4")));
|
||||
expect(requests.map((request) => request.aborted)).toEqual([true, false]);
|
||||
expect((await state(page)).diablo4.loading).toBe(true);
|
||||
await respond(page, diabloUrl, { affixes: [{ id: "current" }] });
|
||||
await expect.poll(async () => (await state(page)).diablo4.loaded).toBe(true);
|
||||
await respond(page, diabloUrl, {}, 503, true);
|
||||
await respond(page, siteUrl, {}, 503);
|
||||
await respond(page, gamesUrl, { games: [] });
|
||||
await expect.poll(async () => (await state(page)).siteContentError).toBe("Impossible de charger le contenu du site.");
|
||||
expect((await state(page)).diablo4.error).toBe("");
|
||||
expect((await state(page)).diablo4.affixes).toEqual([{ id: "current" }]);
|
||||
await page.evaluate(() => window.dataProbe.unmount());
|
||||
});
|
||||
|
||||
test("failed game loading stops and pending list requests are aborted on unmount", async ({ page }) => {
|
||||
await setup(page, "/games/diablo4");
|
||||
await respond(page, diabloUrl, {}, 503);
|
||||
await expect.poll(async () => (await state(page)).diablo4.error).toBe("Impossible de charger les données Diablo IV.");
|
||||
expect((await state(page)).diablo4).toMatchObject({ loaded: true, loading: false });
|
||||
await page.evaluate(() => {
|
||||
window.dataProbe.navigate("/");
|
||||
window.dataProbe.navigate("/games/diablo4");
|
||||
window.dataProbe.navigate("/games/mhwilds");
|
||||
});
|
||||
expect(await page.evaluate(() => window.dataProbe.requests().filter((request) => request.url.includes("diablo4")).length)).toBe(2);
|
||||
await respond(page, `${mhwBase}monsters.json`, {});
|
||||
await respond(page, `${mhwBase}endemic_life.json`, {});
|
||||
await respond(page, `${mhwBase}lists/index.json`, { lists: [{ file: "pending.json" }] });
|
||||
await respond(page, `${mhwBase}i18n/fr.json`, {});
|
||||
await expect.poll(() => page.evaluate(() => window.dataProbe.requests().some((request) => request.url.endsWith("/pending.json")))).toBe(true);
|
||||
await page.evaluate(() => window.dataProbe.unmount());
|
||||
expect(await page.evaluate(() => window.dataProbe.requests().every((request) => request.aborted))).toBe(true);
|
||||
await respond(page, `${mhwBase}lists/pending.json`, {}, 503, true);
|
||||
await respond(page, siteUrl, {}, 503, true);
|
||||
await respond(page, gamesUrl, {}, 503, true);
|
||||
});
|
||||
44
e2e/fixtures/app-data-probe.js
Normal file
44
e2e/fixtures/app-data-probe.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Rôle : observe le chargement réel de l'application avec des réponses réseau contrôlées sous StrictMode.
|
||||
import { createElement, StrictMode, useLayoutEffect } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useAppData } from "../../website/src/hooks/useAppData.js";
|
||||
|
||||
export function mountAppDataProbe(element, initialRoute) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const requests = [];
|
||||
const snapshots = [];
|
||||
const root = createRoot(element);
|
||||
globalThis.fetch = (url, { signal } = {}) => new Promise((resolve) => {
|
||||
// Le double ignore volontairement abort pour tester aussi les réponses déjà en cours.
|
||||
requests.push({ url, signal, resolve, settled: false });
|
||||
});
|
||||
|
||||
function Probe({ route }) {
|
||||
const data = useAppData(route);
|
||||
useLayoutEffect(() => { snapshots.push(data); });
|
||||
return createElement("pre", { id: "data-state" }, JSON.stringify(data));
|
||||
}
|
||||
|
||||
function navigate(route) {
|
||||
snapshots.length = 0;
|
||||
flushSync(() => root.render(createElement(StrictMode, null, createElement(Probe, { route }))));
|
||||
}
|
||||
navigate(initialRoute);
|
||||
return {
|
||||
snapshots,
|
||||
requests: () => requests.map(({ url, signal, settled }) => ({ url, aborted: signal?.aborted, settled })),
|
||||
respond(url, payload, status = 200, aborted = false) {
|
||||
for (const request of requests) {
|
||||
if (request.url !== url || request.settled || request.signal?.aborted !== aborted) continue;
|
||||
request.settled = true;
|
||||
request.resolve({ ok: status >= 200 && status < 300, json: async () => payload });
|
||||
}
|
||||
},
|
||||
navigate,
|
||||
unmount() {
|
||||
flushSync(() => root.unmount());
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue