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
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()));
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue