Strengthen shell behavior tests and profile toolbox loading
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
a9b68a5712
commit
b53c7c17c8
8 changed files with 323 additions and 61 deletions
|
|
@ -37,3 +37,5 @@
|
||||||
{"id":"int-4ece504edad6e21c4829485c33215835","kind":"field_change","created_at":"2026-09-12T07:06:41.579702638Z","actor":"Shinuwa","issue_id":"sokko-g-739","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tutorial visibility now follows toolbox identity and request transitions before commit; tour keyed by toolbox resets step and start latch. Two StrictMode behavioral tests cover identity changes, one-time flag consumption, dismissal and pending requests. check:full passes with 34 E2E; targeted set-state-in-effect lint passes."}}
|
{"id":"int-4ece504edad6e21c4829485c33215835","kind":"field_change","created_at":"2026-09-12T07:06:41.579702638Z","actor":"Shinuwa","issue_id":"sokko-g-739","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Tutorial visibility now follows toolbox identity and request transitions before commit; tour keyed by toolbox resets step and start latch. Two StrictMode behavioral tests cover identity changes, one-time flag consumption, dismissal and pending requests. check:full passes with 34 E2E; targeted set-state-in-effect lint passes."}}
|
||||||
{"id":"int-c7fa4c94461f676392625658508ae231","kind":"field_change","created_at":"2026-09-12T10:15:48.480122568Z","actor":"Shinuwa","issue_id":"sokko-g-5rq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Notepad now separates page identity from focused editing, replaces the editor DOM on page/module changes, preserves selection during local echoes and applies external updates after blur without stealing focus. Removed obsolete selection helpers and timestamp-only echo detection. check:full passes with 36 E2E; global set-state-in-effect diagnostic passes."}}
|
{"id":"int-c7fa4c94461f676392625658508ae231","kind":"field_change","created_at":"2026-09-12T10:15:48.480122568Z","actor":"Shinuwa","issue_id":"sokko-g-5rq","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Notepad now separates page identity from focused editing, replaces the editor DOM on page/module changes, preserves selection during local echoes and applies external updates after blur without stealing focus. Removed obsolete selection helpers and timestamp-only echo detection. check:full passes with 36 E2E; global set-state-in-effect diagnostic passes."}}
|
||||||
{"id":"int-e79db6c1131fb3c5c57dde09917ef735","kind":"field_change","created_at":"2026-09-12T10:50:44.387707762Z","actor":"Shinuwa","issue_id":"sokko-g-8po","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Enabled app-wide StrictMode, restored recommended react-hooks/set-state-in-effect rule, documented development checks and added an effective ESLint configuration regression test. npm run check:full passed: lint, 101 Node tests, build and 36 E2E. All C10 subsets complete."}}
|
{"id":"int-e79db6c1131fb3c5c57dde09917ef735","kind":"field_change","created_at":"2026-09-12T10:50:44.387707762Z","actor":"Shinuwa","issue_id":"sokko-g-8po","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Enabled app-wide StrictMode, restored recommended react-hooks/set-state-in-effect rule, documented development checks and added an effective ESLint configuration regression test. npm run check:full passed: lint, 101 Node tests, build and 36 E2E. All C10 subsets complete."}}
|
||||||
|
{"id":"int-4d9a5305507377a0f0d42d8c0e073c0e","kind":"field_change","created_at":"2026-09-12T11:13:23.757763936Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
|
||||||
|
{"id":"int-e86990d8d18afb8dec65036e0ab7712f","kind":"field_change","created_at":"2026-09-12T11:14:17.70653268Z","actor":"Shinuwa","issue_id":"sokko-g-usb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reproducible production profiling completed and documented: npm run profile:toolboxes, four disk-backed scenarios and five genuine reloads each, 117.9 MiB identical media dataset. Raw and analysis reports in ignored .local/performance. Closed media retains about118MiB extra JS heap. check:full passes lint,101 Node tests,build,40 E2E. Initial read failure handling tracked separately as P1 sokko-g-d5u; no storage/schema changes."}}
|
||||||
|
|
|
||||||
17
README.md
17
README.md
|
|
@ -74,6 +74,23 @@ effets de bord et les nettoyages manquants. Ces contrôles supplémentaires ne
|
||||||
s'exécutent pas en production. Les règles recommandées React Hooks, dont
|
s'exécutent pas en production. Les règles recommandées React Hooks, dont
|
||||||
`react-hooks/set-state-in-effect`, sont actives dans ESLint.
|
`react-hooks/set-state-in-effect`, sont actives dans ESLint.
|
||||||
|
|
||||||
|
Pour mesurer le chargement local des toolboxes, lancer `npm run profile:toolboxes`
|
||||||
|
avec Chromium Playwright installé (`npx playwright install chromium`). Le script
|
||||||
|
construit la production, utilise temporairement `127.0.0.1:4173` (le port doit être
|
||||||
|
libre) et crée ses données dans des profils Chromium temporaires sur disque,
|
||||||
|
sans toucher aux toolboxes personnelles. Il supprime uniquement ses profils
|
||||||
|
temporaires et ferme le navigateur et le serveur à la fin.
|
||||||
|
|
||||||
|
Les quatre scénarios comparent une base vide, 40 toolboxes légères, une toolbox
|
||||||
|
avec 160 images WebP synthétiques et les mêmes images réparties sur 40 toolboxes.
|
||||||
|
Cinq rechargements après une navigation de chauffe mesurent l'affichage de la
|
||||||
|
liste après lecture IndexedDB, la durée de lecture, les tâches longues et le tas
|
||||||
|
JavaScript après GC. Le rapport brut est écrit dans
|
||||||
|
`.local/performance/toolbox-loading.json`, hors Git. Exécuter ce profil seul, sans
|
||||||
|
build ou tests concurrents. Ce n'est ni un budget CI, ni une mesure du démarrage
|
||||||
|
à cache froid, ni de la mémoire totale du navigateur ; les résultats dépendent
|
||||||
|
de la machine et ne couvrent pas le décodage des images dans une toolbox ouverte.
|
||||||
|
|
||||||
## Contenu éditable
|
## Contenu éditable
|
||||||
|
|
||||||
Les contenus maintenus à la main sont regroupés dans `website/public/data`.
|
Les contenus maintenus à la main sont regroupés dans `website/public/data`.
|
||||||
|
|
|
||||||
21
e2e/fixtures/shell-probe.js
Normal file
21
e2e/fixtures/shell-probe.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Rôle : monte les vrais overlays et textes éditoriaux sous StrictMode pour tester leur cycle de vie.
|
||||||
|
import { createElement, StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { flushSync } from "react-dom";
|
||||||
|
import { ConfirmModal, CreateToolboxModal, NotificationToast } from "../../website/src/components/ToolboxModals.jsx";
|
||||||
|
import { RichText } from "../../website/src/components/RichText.jsx";
|
||||||
|
|
||||||
|
export function mountShellProbe(element) {
|
||||||
|
const root = createRoot(element);
|
||||||
|
const closed = [];
|
||||||
|
const onClose = (value) => closed.push(value);
|
||||||
|
function render({ confirm = false, create = false, toast = null, text = "" } = {}) {
|
||||||
|
flushSync(() => root.render(createElement(StrictMode, null,
|
||||||
|
confirm && createElement(ConfirmModal, { key: "confirm", title: "Confirmation test", message: "Continuer ?", onClose }),
|
||||||
|
create && createElement(CreateToolboxModal, { key: "create", onClose }),
|
||||||
|
toast && createElement(NotificationToast, { key: "toast", ...toast, onClose }),
|
||||||
|
createElement("div", { id: "editorial-text", key: "text" }, createElement(RichText, { text }))
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
return { render, closed, unmount: () => root.unmount() };
|
||||||
|
}
|
||||||
96
e2e/shell-behavior.spec.js
Normal file
96
e2e/shell-behavior.spec.js
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// Rôle : couvre les durées des alertes, le nettoyage des overlays et le rendu éditorial sans inspecter leur source.
|
||||||
|
/* global window, document, CustomEvent */
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
|
const probeUrl = `/@fs/${fileURLToPath(new URL("./fixtures/shell-probe.js", import.meta.url))}`;
|
||||||
|
|
||||||
|
async function setup(page, probe = false) {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator(".app-shell")).toBeVisible();
|
||||||
|
if (probe) {
|
||||||
|
await page.evaluate(async (url) => {
|
||||||
|
const { mountShellProbe } = await import(url);
|
||||||
|
const element = document.createElement("div");
|
||||||
|
element.id = "shell-probe";
|
||||||
|
document.body.append(element);
|
||||||
|
window.shellProbe = mountShellProbe(element);
|
||||||
|
}, probeUrl);
|
||||||
|
}
|
||||||
|
await page.clock.install({ time: new Date("2026-09-12T12:00:00Z") });
|
||||||
|
await page.clock.pauseAt(new Date("2026-09-12T12:00:01Z"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function notify(page, message, durationMs) {
|
||||||
|
await page.evaluate((detail) => window.dispatchEvent(new CustomEvent("sokkog:notify", { detail })), { message, durationMs });
|
||||||
|
await expect(page.getByRole("status").filter({ hasText: message })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("application alerts use short clipboard and default durations and dismiss individually", async ({ page }) => {
|
||||||
|
await setup(page);
|
||||||
|
await notify(page, "Texte copié");
|
||||||
|
await notify(page, "Sauvegarde terminée");
|
||||||
|
await page.clock.runFor(4999);
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(2);
|
||||||
|
await page.clock.runFor(1);
|
||||||
|
await expect(page.getByRole("status")).toHaveText("Sauvegarde terminée");
|
||||||
|
await page.clock.runFor(114999);
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(1);
|
||||||
|
await page.clock.runFor(1);
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(0);
|
||||||
|
|
||||||
|
await notify(page, "Minimum", 0);
|
||||||
|
await notify(page, "Personnalisée", 2000);
|
||||||
|
await page.clock.runFor(999);
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(2);
|
||||||
|
await page.clock.runFor(1);
|
||||||
|
await expect(page.getByRole("status")).toHaveText("Personnalisée");
|
||||||
|
await page.getByRole("button", { name: "Fermer l'alerte" }).dispatchEvent("click");
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(0);
|
||||||
|
await page.clock.runFor(2000);
|
||||||
|
await expect(page.getByRole("status")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("toast replacement and unmount cancel obsolete expiration callbacks under StrictMode", async ({ page }) => {
|
||||||
|
await setup(page, true);
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ toast: { id: "old", message: "Ancienne", durationMs: 1000 } }));
|
||||||
|
await page.clock.runFor(500);
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ toast: { id: "new", message: "Nouvelle", durationMs: 2000 } }));
|
||||||
|
await page.clock.runFor(1999);
|
||||||
|
expect(await page.evaluate(() => window.shellProbe.closed)).toEqual([]);
|
||||||
|
await page.clock.runFor(1);
|
||||||
|
expect(await page.evaluate(() => window.shellProbe.closed)).toEqual(["new"]);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
window.shellProbe.render({ toast: { id: "unmounted", message: "Fermée" } });
|
||||||
|
window.shellProbe.unmount();
|
||||||
|
});
|
||||||
|
await page.clock.runFor(120000);
|
||||||
|
expect(await page.evaluate(() => window.shellProbe.closed)).toEqual(["new"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overlapping modals keep scroll locked until the last unmount under StrictMode", async ({ page }) => {
|
||||||
|
await setup(page, true);
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ confirm: true, create: true }));
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(2);
|
||||||
|
await expect(page.locator("body")).toHaveCSS("overflow-y", "hidden");
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ create: true }));
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(1);
|
||||||
|
await expect(page.locator("body")).toHaveCSS("overflow-y", "hidden");
|
||||||
|
await page.evaluate(() => window.shellProbe.unmount());
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||||
|
await expect(page.locator("body")).not.toHaveCSS("overflow-y", "hidden");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editorial text preserves line breaks and italics but never interprets arbitrary HTML", async ({ page }) => {
|
||||||
|
await setup(page, true);
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ text: 'Première\n<i>Seconde</i>\n<img src=x onerror="alert(1)">' }));
|
||||||
|
const text = page.locator("#editorial-text");
|
||||||
|
await expect(text.locator("br")).toHaveCount(2);
|
||||||
|
await expect(text.locator("i")).toHaveText("Seconde");
|
||||||
|
await expect(text.locator("img, script")).toHaveCount(0);
|
||||||
|
await expect(text).toContainText('<img src=x onerror="alert(1)">');
|
||||||
|
await page.evaluate(() => window.shellProbe.render({ text: "Remplacé" }));
|
||||||
|
await expect(text).toHaveText("Remplacé");
|
||||||
|
await expect(text.locator("br, i")).toHaveCount(0);
|
||||||
|
await page.evaluate(() => window.shellProbe.unmount());
|
||||||
|
});
|
||||||
|
|
@ -13,7 +13,8 @@
|
||||||
"check": "node --check server.mjs && node --check vite.config.js && npm run lint && npm run lists:index && npm test && npm run build",
|
"check": "node --check server.mjs && node --check vite.config.js && npm run lint && npm run lists:index && npm test && npm run build",
|
||||||
"check:full": "npm run check && npm run test:e2e",
|
"check:full": "npm run check && npm run test:e2e",
|
||||||
"test": "node --test",
|
"test": "node --test",
|
||||||
"test:e2e": "playwright test"
|
"test:e2e": "playwright test",
|
||||||
|
"profile:toolboxes": "npm run build && node scripts/profile-toolbox-loading.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
|
|
||||||
184
scripts/profile-toolbox-loading.mjs
Normal file
184
scripts/profile-toolbox-loading.mjs
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
// Rôle : mesure le démarrage du build de production avec des toolboxes synthétiques dans un navigateur isolé.
|
||||||
|
/* global window, document, indexedDB, IDBObjectStore, MutationObserver, requestAnimationFrame */
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { cpus, platform } from "node:os";
|
||||||
|
import { chromium } from "@playwright/test";
|
||||||
|
import { preview } from "vite";
|
||||||
|
|
||||||
|
const repetitions = 5;
|
||||||
|
const scenarios = [
|
||||||
|
{ name: "empty", toolboxes: 0, imagesPerToolbox: 0 },
|
||||||
|
{ name: "40-light", toolboxes: 40, imagesPerToolbox: 0 },
|
||||||
|
{ name: "1-heavy", toolboxes: 1, imagesPerToolbox: 160 },
|
||||||
|
{ name: "40-media", toolboxes: 40, imagesPerToolbox: 4 }
|
||||||
|
];
|
||||||
|
const requested = process.argv.slice(2);
|
||||||
|
for (const name of requested) assert.ok(scenarios.some((scenario) => scenario.name === name), `Unknown scenario: ${name}`);
|
||||||
|
|
||||||
|
async function seed(page, scenario) {
|
||||||
|
await page.route("**/__profile-seed", (route) => route.fulfill({ contentType: "text/html", body: "<!doctype html><title>Profile seed</title>" }));
|
||||||
|
await page.goto("/__profile-seed");
|
||||||
|
return page.evaluate(async ({ toolboxes: count, imagesPerToolbox }) => {
|
||||||
|
// Le PRNG fixe le contenu et la taille des images, indépendamment du regroupement en toolboxes.
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 1024;
|
||||||
|
canvas.height = 768;
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
const pixels = context.createImageData(canvas.width, canvas.height);
|
||||||
|
let random = 123456;
|
||||||
|
const boxes = [];
|
||||||
|
const modules = [];
|
||||||
|
let mediaBytes = 0;
|
||||||
|
for (let box = 0; box < count; box += 1) {
|
||||||
|
const id = `profile-${box}`;
|
||||||
|
const images = [];
|
||||||
|
for (let image = 0; image < imagesPerToolbox; image += 1) {
|
||||||
|
for (let pixel = 0; pixel < pixels.data.length; pixel += 4) {
|
||||||
|
random = (Math.imul(random, 1664525) + 1013904223) >>> 0;
|
||||||
|
pixels.data[pixel] = random & 255;
|
||||||
|
pixels.data[pixel + 1] = (random >>> 8) & 255;
|
||||||
|
pixels.data[pixel + 2] = (random >>> 16) & 255;
|
||||||
|
pixels.data[pixel + 3] = 255;
|
||||||
|
}
|
||||||
|
context.putImageData(pixels, 0, 0);
|
||||||
|
const dataUrl = canvas.toDataURL("image/webp", 0.85);
|
||||||
|
mediaBytes += dataUrl.length;
|
||||||
|
images.push({ id: `image-${image}`, label: `Image ${image}`, dataUrl });
|
||||||
|
}
|
||||||
|
boxes.push({
|
||||||
|
id, name: `Profile ${box}`, updatedAt: "2026-09-12T12:00:00.000Z",
|
||||||
|
modules: [{ id: "memos", type: "memos", title: "Mémos" }, { id: "images", type: "images", title: "Images" }]
|
||||||
|
});
|
||||||
|
modules.push({ key: `${id}:memos`, data: { items: [{ id: "memo", text: "Profil de chargement" }] } });
|
||||||
|
if (images.length) modules.push({ key: `${id}:images`, data: { images } });
|
||||||
|
}
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open("sokkog", 1);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
request.result.createObjectStore("kv");
|
||||||
|
request.result.createObjectStore("modules", { keyPath: "key" });
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const db = request.result;
|
||||||
|
const transaction = db.transaction(["kv", "modules"], "readwrite");
|
||||||
|
transaction.objectStore("kv").put(boxes, "toolboxes");
|
||||||
|
for (const module of modules) transaction.objectStore("modules").put(module);
|
||||||
|
transaction.oncomplete = () => { db.close(); resolve(); };
|
||||||
|
transaction.onabort = () => { db.close(); reject(transaction.error); };
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return { mediaBytes, moduleCount: modules.length, imageCount: count * imagesPerToolbox };
|
||||||
|
}, scenario);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function instrument(page, expectedCards) {
|
||||||
|
await page.addInitScript((count) => {
|
||||||
|
const metrics = { timeOrigin: performance.timeOrigin, reads: [], longTasks: [], readyMs: null };
|
||||||
|
window.toolboxProfile = metrics;
|
||||||
|
const observer = new MutationObserver(checkReady);
|
||||||
|
observer.observe(document, { childList: true, subtree: true });
|
||||||
|
let pendingPaint = false;
|
||||||
|
function checkReady() {
|
||||||
|
if (pendingPaint || !metrics.reads.some((read) => read.transactionMs != null)) return;
|
||||||
|
if (!document.querySelector(".toolbox-page") || document.querySelectorAll(".toolbox-card").length !== count) return;
|
||||||
|
pendingPaint = true;
|
||||||
|
observer.disconnect();
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => { metrics.readyMs = performance.now(); }));
|
||||||
|
}
|
||||||
|
const getAll = IDBObjectStore.prototype.getAll;
|
||||||
|
IDBObjectStore.prototype.getAll = function (...args) {
|
||||||
|
const start = performance.now();
|
||||||
|
const request = getAll.apply(this, args);
|
||||||
|
if (this.name === "modules") {
|
||||||
|
const read = { startMs: start };
|
||||||
|
metrics.reads.push(read);
|
||||||
|
request.addEventListener("success", () => {
|
||||||
|
read.requestMs = performance.now() - start;
|
||||||
|
read.entries = request.result.length;
|
||||||
|
});
|
||||||
|
request.addEventListener("error", () => { read.error = request.error?.message; });
|
||||||
|
this.transaction.addEventListener("abort", () => { read.error = this.transaction.error?.message; });
|
||||||
|
this.transaction.addEventListener("complete", () => {
|
||||||
|
read.transactionMs = performance.now() - start;
|
||||||
|
checkReady();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
};
|
||||||
|
new PerformanceObserver((list) => {
|
||||||
|
for (const entry of list.getEntries()) metrics.longTasks.push({ startMs: entry.startTime, durationMs: entry.duration });
|
||||||
|
}).observe({ type: "longtask", buffered: true });
|
||||||
|
}, expectedCards);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await preview({ configFile: false, root: "website", preview: { host: "127.0.0.1", port: 4173, strictPort: true } });
|
||||||
|
try {
|
||||||
|
await mkdir(".local/performance", { recursive: true });
|
||||||
|
const report = { date: new Date().toISOString(), platform: platform(), cpu: cpus()[0]?.model, repetitions, scenarios: [] };
|
||||||
|
for (const scenario of scenarios.filter((scenario) => !requested.length || requested.includes(scenario.name))) {
|
||||||
|
const profilePath = await mkdtemp(".local/performance/profile-");
|
||||||
|
let context;
|
||||||
|
try {
|
||||||
|
context = await chromium.launchPersistentContext(profilePath, { headless: true, baseURL: "http://127.0.0.1:4173", viewport: { width: 1440, height: 1000 } });
|
||||||
|
report.browser = context.browser().version();
|
||||||
|
const seedPage = await context.newPage();
|
||||||
|
const dataset = await seed(seedPage, scenario);
|
||||||
|
console.log(`Seeded ${scenario.name}: ${dataset.imageCount} images`);
|
||||||
|
await seedPage.close();
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await instrument(page, scenario.toolboxes);
|
||||||
|
const cdp = await context.newCDPSession(page);
|
||||||
|
await cdp.send("Performance.enable");
|
||||||
|
const samples = [];
|
||||||
|
let previousTimeOrigin;
|
||||||
|
// Une navigation de chauffe exclut le premier chargement des bundles des cinq mesures.
|
||||||
|
for (let iteration = 0; iteration <= repetitions; iteration += 1) {
|
||||||
|
if (iteration === 0) await page.goto("/#/toolboxes");
|
||||||
|
else await page.reload();
|
||||||
|
try {
|
||||||
|
await page.waitForFunction(() => window.toolboxProfile?.readyMs != null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(scenario.name, iteration, errors, JSON.stringify(await page.evaluate(() => ({
|
||||||
|
metrics: window.toolboxProfile, cards: document.querySelectorAll(".toolbox-card").length,
|
||||||
|
text: document.body.innerText
|
||||||
|
})), null, 2));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const sample = await page.evaluate(() => ({ ...window.toolboxProfile, cards: document.querySelectorAll(".toolbox-card").length }));
|
||||||
|
assert.notEqual(sample.timeOrigin, previousTimeOrigin, "Every sample must load a new document");
|
||||||
|
previousTimeOrigin = sample.timeOrigin;
|
||||||
|
assert.equal(sample.reads.length, 1, "Production must perform one initial modules read");
|
||||||
|
assert.equal(sample.reads[0].entries, dataset.moduleCount);
|
||||||
|
assert.equal(sample.cards, scenario.toolboxes);
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
await cdp.send("HeapProfiler.collectGarbage");
|
||||||
|
const { metrics } = await cdp.send("Performance.getMetrics");
|
||||||
|
sample.jsHeapBytes = metrics.find((metric) => metric.name === "JSHeapUsedSize").value;
|
||||||
|
sample.longTasks = sample.longTasks.filter((task) => task.startMs < sample.readyMs);
|
||||||
|
if (iteration > 0) samples.push(sample);
|
||||||
|
}
|
||||||
|
const median = (values) => [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)];
|
||||||
|
const summary = {
|
||||||
|
readyMedianMs: median(samples.map((sample) => sample.readyMs)),
|
||||||
|
readyMaxMs: Math.max(...samples.map((sample) => sample.readyMs)),
|
||||||
|
readMedianMs: median(samples.map((sample) => sample.reads[0].transactionMs)),
|
||||||
|
heapMedianMiB: median(samples.map((sample) => sample.jsHeapBytes / 1024 ** 2)),
|
||||||
|
longestTaskMs: Math.max(0, ...samples.flatMap((sample) => sample.longTasks.map((task) => task.durationMs)))
|
||||||
|
};
|
||||||
|
report.scenarios.push({ ...scenario, ...dataset, summary, samples });
|
||||||
|
console.log(scenario.name, JSON.stringify({ mediaMiB: dataset.mediaBytes / 1024 ** 2, ...summary }));
|
||||||
|
} finally {
|
||||||
|
await context?.close();
|
||||||
|
await rm(profilePath, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await writeFile(".local/performance/toolbox-loading.json", `${JSON.stringify(report, null, 2)}\n`);
|
||||||
|
console.log("Report: .local/performance/toolbox-loading.json");
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve, reject) => server.httpServer.close((error) => error ? reject(error) : resolve()));
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
// Rôle : vérifie les invariants statiques du shell, du routing et des overlays.
|
// Rôle : conserve les conventions éditoriales et les assets du shell non couverts par les parcours navigateur.
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
|
@ -6,67 +6,32 @@ import assert from "node:assert/strict";
|
||||||
test("vite entrypoint and app shell are wired", async () => {
|
test("vite entrypoint and app shell are wired", async () => {
|
||||||
const html = await readFile("website/index.html", "utf8");
|
const html = await readFile("website/index.html", "utf8");
|
||||||
const source = await readFile("website/src/main.jsx", "utf8");
|
const source = await readFile("website/src/main.jsx", "utf8");
|
||||||
const appDataHook = await readFile("website/src/hooks/useAppData.js", "utf8");
|
|
||||||
const gameFiltersHook = await readFile("website/src/hooks/useGameFilters.js", "utf8");
|
const gameFiltersHook = await readFile("website/src/hooks/useGameFilters.js", "utf8");
|
||||||
const shell = await readFile("website/src/components/Shell.jsx", "utf8");
|
const shell = await readFile("website/src/components/Shell.jsx", "utf8");
|
||||||
const overlays = await readFile("website/src/components/AppOverlays.jsx", "utf8");
|
const overlays = await readFile("website/src/components/AppOverlays.jsx", "utf8");
|
||||||
const modals = await readFile("website/src/components/ToolboxModals.jsx", "utf8");
|
|
||||||
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
|
const imageViewer = await readFile("website/src/components/ImageViewer.jsx", "utf8");
|
||||||
const richText = await readFile("website/src/components/RichText.jsx", "utf8");
|
|
||||||
const homePage = await readFile("website/src/pages/HomePage.jsx", "utf8");
|
const homePage = await readFile("website/src/pages/HomePage.jsx", "utf8");
|
||||||
const homeOnboarding = await readFile("website/src/pages/HomeOnboardingSection.jsx", "utf8");
|
const homeOnboarding = await readFile("website/src/pages/HomeOnboardingSection.jsx", "utf8");
|
||||||
const aboutPage = await readFile("website/src/pages/AboutPage.jsx", "utf8");
|
const aboutPage = await readFile("website/src/pages/AboutPage.jsx", "utf8");
|
||||||
const libraryPage = await readFile("website/src/pages/LibraryPage.jsx", "utf8");
|
|
||||||
const moduleRegistry = await readFile("website/src/features/toolboxes/toolboxModuleRegistry.js", "utf8");
|
|
||||||
const hashRouter = await readFile("website/src/router/hashRouter.js", "utf8");
|
|
||||||
const routeContent = await readFile("website/src/router/RouteContent.jsx", "utf8");
|
const routeContent = await readFile("website/src/router/RouteContent.jsx", "utf8");
|
||||||
const bodyScrollLock = await readFile("website/src/utils/bodyScrollLock.js", "utf8");
|
|
||||||
|
|
||||||
assert.match(html, /<div id="app"><\/div>/);
|
assert.match(html, /<div id="app"><\/div>/);
|
||||||
assert.match(html, /\/favicon\.ico/);
|
assert.match(html, /\/favicon\.ico/);
|
||||||
assert.match(html, /\/src\/main\.jsx/);
|
assert.match(html, /\/src\/main\.jsx/);
|
||||||
assert.match(source, /styles\/main\.scss/);
|
assert.match(source, /styles\/main\.scss/);
|
||||||
assert.match(source, /components\/Shell\.jsx/);
|
|
||||||
assert.match(source, /components\/AppOverlays\.jsx/);
|
|
||||||
assert.match(source, /router\/RouteContent\.jsx/);
|
|
||||||
assert.match(source, /hooks\/useAppData\.js/);
|
|
||||||
assert.match(source, /hooks\/useGameFilters\.js/);
|
assert.match(source, /hooks\/useGameFilters\.js/);
|
||||||
assert.doesNotMatch(source, /DEFAULT_SITE_CONTENT/);
|
assert.doesNotMatch(source, /DEFAULT_SITE_CONTENT/);
|
||||||
assert.match(appDataHook, /export function useAppData/);
|
|
||||||
assert.match(appDataHook, /\/data\/site\.json/);
|
|
||||||
assert.match(appDataHook, /setSiteContentError/);
|
|
||||||
assert.match(gameFiltersHook, /export function useGameFilters/);
|
assert.match(gameFiltersHook, /export function useGameFilters/);
|
||||||
assert.match(hashRouter, /export function useHashRoute/);
|
|
||||||
assert.match(hashRouter, /export function navigate/);
|
|
||||||
assert.match(routeContent, /export function RouteContent/);
|
|
||||||
assert.match(routeContent, /pages\/HomePage\.jsx/);
|
|
||||||
assert.match(routeContent, /pages\/AboutPage\.jsx/);
|
assert.match(routeContent, /pages\/AboutPage\.jsx/);
|
||||||
assert.match(routeContent, /pages\/LibraryPage\.jsx/);
|
|
||||||
assert.match(routeContent, /route === "\/library"/);
|
|
||||||
assert.match(routeContent, /features\/games\/GameRoute\.jsx/);
|
assert.match(routeContent, /features\/games\/GameRoute\.jsx/);
|
||||||
assert.match(shell, /export function Shell/);
|
|
||||||
assert.doesNotMatch(shell, /content\.navigation\.library/);
|
assert.doesNotMatch(shell, /content\.navigation\.library/);
|
||||||
assert.match(shell, /sidebar-about-link/);
|
assert.match(shell, /sidebar-about-link/);
|
||||||
assert.match(shell, /dragon-sidebar\.png/);
|
assert.match(shell, /dragon-sidebar\.png/);
|
||||||
assert.match(shell, /ImportButton/);
|
assert.match(shell, /ImportButton/);
|
||||||
assert.match(overlays, /export function AppOverlays/);
|
|
||||||
assert.match(overlays, /\.\/ToolboxModals\.jsx/);
|
|
||||||
assert.match(overlays, /\.\/ImageViewer\.jsx/);
|
assert.match(overlays, /\.\/ImageViewer\.jsx/);
|
||||||
assert.match(overlays, /uid\("marker"\)/);
|
assert.match(overlays, /uid\("marker"\)/);
|
||||||
assert.match(modals, /export function ConfirmModal/);
|
|
||||||
assert.match(modals, /export function CreateToolboxModal/);
|
|
||||||
assert.match(modals, /export function LinkToolboxModal/);
|
|
||||||
assert.match(modals, /export function NotificationToast/);
|
|
||||||
assert.match(modals, /durationMs = 120000/);
|
|
||||||
assert.match(modals, /setTimeout\(\(\) => onClose\(id\), durationMs\)/);
|
|
||||||
assert.match(source, /SHORT_NOTIFICATION_DURATION_MS = 5000/);
|
|
||||||
assert.match(source, /getNotificationDuration/);
|
|
||||||
assert.match(modals, /useModalScrollLock/);
|
|
||||||
assert.match(imageViewer, /export function ImageViewer/);
|
assert.match(imageViewer, /export function ImageViewer/);
|
||||||
assert.match(imageViewer, /function openImageInNewTab/);
|
assert.match(imageViewer, /function openImageInNewTab/);
|
||||||
assert.match(richText, /value\.split\("\\n"\)/);
|
|
||||||
assert.match(richText, /<br key=/);
|
|
||||||
assert.match(homePage, /export function HomePage/);
|
|
||||||
assert.match(homePage, /HomeOnboardingSection/);
|
assert.match(homePage, /HomeOnboardingSection/);
|
||||||
assert.match(homePage, /import\.meta\.env\.DEV \|\| toolboxes\.length === 0/);
|
assert.match(homePage, /import\.meta\.env\.DEV \|\| toolboxes\.length === 0/);
|
||||||
assert.match(homePage, /setCreateModal\(\{ tutorial: true \}\)/);
|
assert.match(homePage, /setCreateModal\(\{ tutorial: true \}\)/);
|
||||||
|
|
@ -82,16 +47,6 @@ test("vite entrypoint and app shell are wired", async () => {
|
||||||
assert.match(aboutPage, /siteContent\.home\.onboarding/);
|
assert.match(aboutPage, /siteContent\.home\.onboarding/);
|
||||||
assert.match(aboutPage, /module-icon-\$\{group\.icon \|\| "notepad"\}/);
|
assert.match(aboutPage, /module-icon-\$\{group\.icon \|\| "notepad"\}/);
|
||||||
assert.match(aboutPage, /about-tool-examples/);
|
assert.match(aboutPage, /about-tool-examples/);
|
||||||
assert.match(libraryPage, /export function LibraryPage/);
|
|
||||||
assert.match(libraryPage, /LIBRARY_TOOLBOX_ID/);
|
|
||||||
assert.match(libraryPage, /toolboxModuleComponents\.jsx/);
|
|
||||||
assert.match(moduleRegistry, /export const TOOLBOX_MODULE_LOADERS/);
|
|
||||||
assert.match(moduleRegistry, /taskPlanner: \(\) => import\("\.\/modules\/TaskPlannerModule\.jsx"\)/);
|
|
||||||
assert.match(moduleRegistry, /equipmentPlanner: \(\) => import\("\.\/modules\/EquipmentPlannerModule\.jsx"\)/);
|
|
||||||
assert.match(libraryPage, /normalizeEquipmentPlannerData/);
|
|
||||||
assert.match(libraryPage, /summarizeEquipmentPlannerData/);
|
|
||||||
assert.match(bodyScrollLock, /modalLocks/);
|
|
||||||
assert.match(bodyScrollLock, /is-modal-open/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("project documentation tracks storage and feature updates", async () => {
|
test("project documentation tracks storage and feature updates", async () => {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ test("toolbox storage, cards and pages are wired", async () => {
|
||||||
const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8");
|
const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8");
|
||||||
const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8");
|
const reorderHook = await readFile("website/src/hooks/usePointerReorder.js", "utf8");
|
||||||
const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8");
|
const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8");
|
||||||
const inlineEditHook = await readFile("website/src/hooks/useInlineEdit.js", "utf8");
|
|
||||||
const draftFormHook = await readFile("website/src/hooks/useDraftForm.js", "utf8");
|
const draftFormHook = await readFile("website/src/hooks/useDraftForm.js", "utf8");
|
||||||
|
|
||||||
assert.match(`${indexedToolboxesHook}\n${toolboxPages}`, /indexedDbStorage\.js/);
|
assert.match(`${indexedToolboxesHook}\n${toolboxPages}`, /indexedDbStorage\.js/);
|
||||||
|
|
@ -124,15 +123,9 @@ test("toolbox storage, cards and pages are wired", async () => {
|
||||||
assert.match(toolboxPages, /setDrawerGameId\(""\)/);
|
assert.match(toolboxPages, /setDrawerGameId\(""\)/);
|
||||||
assert.match(toolboxPages, /moduleColumns/);
|
assert.match(toolboxPages, /moduleColumns/);
|
||||||
assert.match(toolboxPages, /normalizeCombosData/);
|
assert.match(toolboxPages, /normalizeCombosData/);
|
||||||
assert.match(toolboxPages, /function ToolboxView/);
|
|
||||||
assert.match(toolboxPages, /GuidedTooltipTour/);
|
|
||||||
assert.match(toolboxPages, /data-toolbox-tutorial-target/);
|
|
||||||
assert.match(guidedTooltipTour, /document\.querySelector/);
|
|
||||||
assert.match(guidedTooltipTour, /--tutorial-arrow-left/);
|
assert.match(guidedTooltipTour, /--tutorial-arrow-left/);
|
||||||
assert.match(guidedTooltipTour, /--tutorial-arrow-top/);
|
assert.match(guidedTooltipTour, /--tutorial-arrow-top/);
|
||||||
assert.match(guidedTooltipTour, /data-placement/);
|
assert.match(guidedTooltipTour, /data-placement/);
|
||||||
assert.match(toolboxPages, /toolbox\.tutorial === true/);
|
|
||||||
assert.match(toolboxPages, /tutorial: false/);
|
|
||||||
assert.doesNotMatch(toolboxPages, /usePointerReorder/);
|
assert.doesNotMatch(toolboxPages, /usePointerReorder/);
|
||||||
assert.match(storageQuota, /export function StorageQuota/);
|
assert.match(storageQuota, /export function StorageQuota/);
|
||||||
assert.match(storageQuota, /role="progressbar"/);
|
assert.match(storageQuota, /role="progressbar"/);
|
||||||
|
|
@ -148,20 +141,13 @@ test("toolbox storage, cards and pages are wired", async () => {
|
||||||
assert.match(groupedReorderHook, /export function applyGroupedReorderOperation/);
|
assert.match(groupedReorderHook, /export function applyGroupedReorderOperation/);
|
||||||
assert.match(groupedReorderHook, /data-reorder-orientation/);
|
assert.match(groupedReorderHook, /data-reorder-orientation/);
|
||||||
assert.match(groupedReorderHook, /operation\.sourceParentId === operation\.targetParentId/);
|
assert.match(groupedReorderHook, /operation\.sourceParentId === operation\.targetParentId/);
|
||||||
assert.match(inlineEditHook, /export function useInlineEdit/);
|
|
||||||
assert.match(inlineEditHook, /skipCommitRef/);
|
|
||||||
assert.match(inlineEditHook, /blurOnEscape/);
|
|
||||||
assert.match(inlineEditHook, /commitOnEnter/);
|
|
||||||
assert.match(inlineEditHook, /getContentEditableProps/);
|
|
||||||
assert.match(draftFormHook, /export function useDraftForm/);
|
assert.match(draftFormHook, /export function useDraftForm/);
|
||||||
assert.match(draftFormHook, /getFieldProps/);
|
assert.match(draftFormHook, /getFieldProps/);
|
||||||
assert.match(draftFormHook, /handleSubmit/);
|
assert.match(draftFormHook, /handleSubmit/);
|
||||||
assert.match(draftFormHook, /preventDefault/);
|
assert.match(draftFormHook, /preventDefault/);
|
||||||
assert.match(toolboxCoverEditor, /createImageBitmap/);
|
|
||||||
assert.match(toolboxCoverEditor, /image\/webp/);
|
assert.match(toolboxCoverEditor, /image\/webp/);
|
||||||
assert.match(toolboxCoverEditor, /OUTPUT_WIDTH = 620/);
|
assert.match(toolboxCoverEditor, /OUTPUT_WIDTH = 620/);
|
||||||
assert.match(toolboxCoverEditor, /OUTPUT_HEIGHT = 248/);
|
assert.match(toolboxCoverEditor, /OUTPUT_HEIGHT = 248/);
|
||||||
assert.match(toolboxCoverEditor, /getCoverCropSourceRect/);
|
|
||||||
assert.doesNotMatch(toolboxCoverEditor, /react-easy-crop|react-image-crop/);
|
assert.doesNotMatch(toolboxCoverEditor, /react-easy-crop|react-image-crop/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue