// 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: "Profile seed" })); 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())); }