186 lines
9.9 KiB
JavaScript
186 lines
9.9 KiB
JavaScript
// 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(); }));
|
|
}
|
|
for (const method of ["getAll", "getAllKeys", "get"]) {
|
|
const original = IDBObjectStore.prototype[method];
|
|
IDBObjectStore.prototype[method] = function (...args) {
|
|
const start = performance.now();
|
|
const request = original.apply(this, args);
|
|
if (this.name === "modules") {
|
|
const read = { method, key: args[0], startMs: start };
|
|
metrics.reads.push(read);
|
|
request.addEventListener("success", () => {
|
|
read.requestMs = performance.now() - start;
|
|
read.entries = method === "getAllKeys" ? 0 : method === "get" ? Number(request.result != null) : 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.reduce((sum, read) => sum + read.entries, 0), scenario.toolboxes, "Only memo values should load before opening a toolbox");
|
|
assert.ok(sample.reads.every((read) => read.key == null || !String(read.key).endsWith(":images")), "Closed image modules must stay on disk");
|
|
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) => Math.max(...sample.reads.map((read) => read.startMs + read.transactionMs)) - Math.min(...sample.reads.map((read) => read.startMs)))),
|
|
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()));
|
|
}
|