gestion_table_tournante_libre/test/construction.long.test.js
Mathieu Benoit e59878d650 [IMP] build: chunk size warning at 1000 kB, a failing test beyond it
Vite warned on every build because its 500 kB threshold targets pages
fetched over a network; this page is read from the local disk once, and
one chunk holds the whole application with the generation worker inlined.
The threshold now sits at 1000 kB, so it only flags abnormal growth, and
the build test fails when any chunk exceeds it, naming the file and size.
Checked: the build is 658 kB with no warning; a 600 kB threshold turns the
test red.

--- FR ---

[IMP] construction : seuil d'avertissement à 1000 ko, épreuve au-delà

Vite avertissait à chaque construction, car son seuil de 500 ko vise les
pages chargées par le réseau ; cette page se lit sur le disque local, une
fois, et un seul morceau porte l'application entière, travailleur de
génération incrusté compris. Le seuil passe à 1000 ko et ne signale plus
qu'une croissance anormale ; l'épreuve de construction tombe quand un
morceau le dépasse, en nommant le fichier et sa taille. Vérifié : 658 ko
sans avertissement ; un seuil à 600 ko rend l'épreuve rouge.

Assisted-by: Claude Opus 5.5
2026-10-08 03:08:48 -04:00

405 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
// Le paquet livré n'emporte rien de l'outillage d'épreuve (§ 14.9, § 14.15) :
// ni fichier de test, ni le lanceur, ni donnée d'épreuve, ni paquet d'un outil
// d'épreuve, ni le client du serveur de développement ; et il ne charge rien
// du réseau. Le test construit la page par l'API de Vite, avec la
// configuration et le mode de la livraison, dans un répertoire temporaire ;
// il relit les modules que contient le paquet, les noms d'origine de chaque
// actif, puis le chemin et le contenu de chaque fichier écrit. Le paquet du
// travailleur de génération, que « ?worker&inline » construit à part et
// incruste en chaîne dans le module principal, n'apparaît pas dans les
// moduleIds de celui-ci : un greffon posé par l'option worker.plugins relève
// les modules et le code de ses morceaux, qui passent par les mêmes relevés.
import assert from 'node:assert/strict';
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { extname, join, relative, resolve, sep } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { build } from 'vite';
import { describe, test } from './lanceur.js';
const RACINE = fileURLToPath(new URL('..', import.meta.url));
// Paquets qui ne servent qu'aux épreuves.
const OUTILS_EPREUVE =
/[\\/]node_modules[\\/](?:vitest|@vitest|playwright|playwright-core|fast-check)[\\/]/;
// Vrai quand un module vient de l'outillage d'épreuve : l'arbre test/
// (lanceur, données d'épreuve), un fichier *.test.js rangé à côté de son
// module, un paquet d'épreuve, ou un module de Node que Vite remplace pour le
// navigateur, comme node:assert.
function moduleDEpreuve(id) {
return (
id.startsWith(join(RACINE, 'test') + sep) ||
/\.test\.[cm]?js(?:\?|$)/.test(id) ||
OUTILS_EPREUVE.test(id) ||
id.includes('__vite-browser-external')
);
}
// Traces de l'outillage dans le chemin ou le contenu d'un fichier écrit. Les
// motifs portent l'extension : le domaine emploie le mot « lanceur » (lanceur
// portable, § 8.6), et un code minifié peut écrire « x.test.call ».
const TRACES_EPREUVE = [/vitest/i, /node:assert/, /lanceur\.js/, /\.test\.[cm]?js/];
// Le client du serveur de développement de Vite : une page qui le nomme
// attend un serveur qui n'existe pas chez l'opérateur.
const CLIENT_DE_DEVELOPPEMENT = '/@vite/client';
// Un littéral de chaîne JavaScript, entre guillemets doubles, simples ou
// accents graves sans interpolation ; son contenu est dans l'un des trois
// groupes.
const LITTERAL = String.raw`(?:"([^"\n]*)"|'([^'\n]*)'|\x60([^\x60$]*)\x60)`;
// Les positions où un fichier construit charge une adresse, par nature :
// attributs src et href du HTML ; url() et @import des feuilles ; argument
// littéral d'import(), de fetch, d'importScripts et des constructeurs de
// travailleur, et spécificateur d'un import statique ou d'une réexportation
// en JavaScript. Un composant Svelte compile son balisage en chaînes du
// JavaScript : les attributs src, href, xlink:href et srcset et les url()
// d'un attribut style y sont relevés dans tout le texte, sans apparier les
// littéraux, qu'une expression régulière ou un guillemet échappé du code
// minifié décalerait ; un guillemet échappé (\") y ouvre la valeur comme un
// guillemet nu, et la valeur s'arrête au premier guillemet ou à la première
// barre oblique inverse. Le balisage compilé écrit en entité HTML un
// guillemet de la valeur d'un attribut — url("…") —, que le texte
// JavaScript voit décodé avant les motifs. url( n'y compte qu'en minuscules,
// hors d'un nom plus long : new URL(…) n'est pas une feuille. Chaque
// candidat d'un srcset compte, l'adresse avant son descripteur de largeur
// ou de densité. Une chaîne hors de ces positions — l'adresse
// https://svelte.dev/e/… d'une erreur de Svelte, un espace de noms XML
// http://www.w3.org/… — ne charge rien et n'est pas relevée. Les
// commentaires du HTML et des feuilles sont retirés d'abord : une balise
// commentée ne charge rien.
//
// Par nature : commentaires, le motif des commentaires retirés d'abord ;
// entites, vrai quand les guillemets écrits en entité HTML se décodent
// avant les motifs ; motifs, dont chaque correspondance porte une adresse
// dans l'un de ses groupes ; listes, dont chaque correspondance porte la
// valeur d'un srcset, liste de candidats séparés par des virgules.
const POSITIONS = {
html: {
commentaires: /<!--[\s\S]*?-->/g,
entites: false,
motifs: [/\s(?:src|(?:xlink:)?href)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi],
listes: [/\ssrcset\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/gi],
},
css: {
commentaires: /\/\*[\s\S]*?\*\//g,
entites: false,
motifs: [/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)\s]*))\s*\)/gi, /@import\s+(?:"([^"]*)"|'([^']*)')/gi],
listes: [],
},
js: {
commentaires: null,
entites: true,
motifs: [
new RegExp(String.raw`(?:\bimport|\bfetch|\bimportScripts|\bWorker|\bSharedWorker)\s*\(\s*${LITTERAL}`, 'g'),
new RegExp(String.raw`(?:\bimport|\bfrom)\s*${LITTERAL}`, 'g'),
/\s(?:src|(?:xlink:)?href)\s*=\s*\\?(?:"([^"\\]*)|'([^'\\]*)|([^\s"'>\\]+))/gi,
/(?<![\w$.])url\(\s*\\?["']?([^)"'\\\s]*)/g,
],
listes: [/\ssrcset\s*=\s*\\?(?:"([^"\\]*)|'([^'\\]*)|([^\s"'>\\]+))/gi],
},
};
// Les guillemets qu'une entité HTML écrit, nommée ou numérique, décimale ou
// hexadécimale, zéros de tête et casse indifférents.
const GUILLEMETS_EN_ENTITE = /&(?:(quot)|apos|#0*(34)|#0*39|#x0*(22)|#x0*27);/gi;
// Le texte où chaque guillemet écrit en entité est rendu à son caractère.
const decoderGuillemets = (texte) =>
texte.replace(GUILLEMETS_EN_ENTITE, (_entite, quot, decimal, hexadecimal) =>
quot || decimal || hexadecimal ? '"' : "'",
);
// Les adresses d'une valeur de srcset : le premier mot de chaque candidat.
const candidatsDe = (valeur) =>
valeur
.split(',')
.map((candidat) => candidat.trim().split(/\s+/)[0])
.filter((adresse) => adresse !== '');
// La nature d'un fichier construit, d'après son extension ; null pour un
// fichier qu'aucune position ne concerne.
function natureDe(nom) {
const extension = extname(nom).toLowerCase();
if (extension === '.html') return 'html';
if (extension === '.css') return 'css';
if (['.js', '.mjs', '.cjs'].includes(extension)) return 'js';
return null;
}
// Les adresses qu'un texte de la nature donnée charge, dans l'ordre du
// texte, motif par motif, puis celles des srcset.
function adressesChargees(nature, texte) {
const { commentaires, entites, motifs, listes } = POSITIONS[nature];
const sansCommentaires = commentaires === null ? texte : texte.replace(commentaires, ' ');
const nettoye = entites ? decoderGuillemets(sansCommentaires) : sansCommentaires;
const valeurs = (motif) => [...nettoye.matchAll(motif)].map((m) => m.slice(1).find((groupe) => groupe !== undefined));
return [...motifs.flatMap(valeurs), ...listes.flatMap((motif) => valeurs(motif).flatMap(candidatsDe))];
}
// Vrai pour une adresse qui sort de la machine : http, https, ou relative au
// protocole (« //hôte/… »).
const distante = (adresse) => /^(?:https?:)?\/\//i.test(adresse.trim());
// Le relevé du travailleur : un greffon de construction des travailleurs,
// qui garde le nom, les modules et le code de chacun de leurs morceaux.
function releveDuTravailleur() {
const morceaux = [];
const greffon = {
name: 'releve-du-travailleur',
generateBundle(_options, paquet) {
for (const sortie of Object.values(paquet)) {
if (sortie.type === 'chunk') {
morceaux.push({ nom: sortie.fileName, moduleIds: sortie.moduleIds, code: sortie.code });
}
}
},
};
return { morceaux, plugins: () => [greffon] };
}
// Les balises script d'un HTML, dans l'ordre : leur src, si elles portent
// type="module", et si elles portent async ou defer. Les commentaires sont
// retirés d'abord.
function scriptsDe(html) {
return [...html.replace(POSITIONS.html.commentaires, ' ').matchAll(/<script\b([^>]*)>/gi)].map(([, attributs]) => ({
src: /\ssrc\s*=\s*["']?([^"'\s>]+)/i.exec(attributs)?.[1] ?? null,
module: /\stype\s*=\s*["']?module\b/i.test(attributs),
differe: /\s(?:async|defer)\b/i.test(attributs),
}));
}
// Construit la page comme la livraison : vite.config.js, mode livraison,
// NODE_ENV=production. Vitest pose NODE_ENV=test, dont Vite déduirait une
// construction de développement — composants compilés en mode dev, paquets
// résolus sous la condition « development ».
async function construireLivraison(sortie, travailleur) {
const nodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';
try {
return await build({
root: RACINE,
configFile: join(RACINE, 'vite.config.js'),
mode: 'livraison',
logLevel: 'error',
// Sans incrustation, chaque actif sort en fichier et garde ses noms
// d'origine ; incrusté en data:, il ne laisserait aucune trace lisible.
build: { outDir: sortie, emptyOutDir: true, assetsInlineLimit: 0 },
worker: { plugins: travailleur.plugins },
});
} finally {
if (nodeEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = nodeEnv;
}
}
describe('construction : le relevé des adresses chargées', () => {
test('chaque position relève son adresse ; commentaires, espaces de noms et adresses d’erreur ne comptent pas', () => {
const html = [
'<!-- <script src="https://commente.invalid/a.js"></script> -->',
'<script src="https://exemple.invalid/a.js"></script>',
'<script src="http://exemple.invalid/k.js"></script>',
"<link rel=stylesheet href='//exemple.invalid/b.css'>",
'<img src=./local.png>',
'<svg><image xlink:href="https://exemple.invalid/q.png"/></svg>',
'<img srcset="./r.png 1x, https://exemple.invalid/r2.png 2x">',
].join('\n');
assert.deepEqual(adressesChargees('html', html), [
'https://exemple.invalid/a.js',
'http://exemple.invalid/k.js',
'//exemple.invalid/b.css',
'./local.png',
'https://exemple.invalid/q.png',
'./r.png',
'https://exemple.invalid/r2.png',
]);
const css = [
'/* url(https://commente.invalid/f.png) */',
'a { background: url( "https://exemple.invalid/c.png" ) }',
"@import 'https://exemple.invalid/d.css';",
'b { background: url(./e.png) }',
].join('\n');
assert.deepEqual(adressesChargees('css', css), [
'https://exemple.invalid/c.png',
'./e.png',
'https://exemple.invalid/d.css',
]);
const js = [
'fetch("https://exemple.invalid/e.json");',
'fetch("HTTP://exemple.invalid/l");',
"import('https://exemple.invalid/f.js');",
'importScripts(`https://exemple.invalid/g.js`);',
'new Worker("./h.js");',
'import"https://exemple.invalid/i.js";',
'export{a}from"https://exemple.invalid/j.js";',
'const n="http://www.w3.org/2000/svg",l="https://svelte.dev/e/effect_orphan";',
'fetch(u);import(`./${m}.js`);',
// Le balisage qu'un composant compilé porte en chaîne, guillemets nus
// ou échappés, gabarit interpolé compris ; l'espace de noms d'une
// balise ne charge rien.
`var t=$.from_html('<img src="https://exemple.invalid/k.png">');`,
'var a="<a href=\\"https://exemple.invalid/m.html\\">x</a>";',
"var d=`<div style=\"background: url('https://exemple.invalid/n.png')\">${x}</div>`;",
'var s="<svg xmlns=\\"http://www.w3.org/2000/svg\\"><use href=\\"#a\\"/></svg>";',
'var o=new URL(e,import.meta.url);',
// Le guillemet d'une valeur d'attribut écrit en entité HTML, comme
// Svelte compile style='background: url("…")'.
'var p=$.from_html(`<div style="background: url(&quot;https://exemple.invalid/o.png&quot;)"></div>`);',
'var q="<div style=\\"background: url(&#39;https://exemple.invalid/p.png&#39;)\\"></div>";',
'var r=\'<i style="background:url(&#X27;https://exemple.invalid/q.png&#x27;)"></i>\';',
// xlink:href et srcset, dont chaque candidat compte.
'var v=$.from_html(`<svg><image xlink:href="https://exemple.invalid/s.png"></image></svg>`);',
'var w="<img srcset=\\"./t.png 1x, https://exemple.invalid/t2.png 2x\\">";',
].join('');
assert.deepEqual(adressesChargees('js', js), [
'https://exemple.invalid/e.json',
'HTTP://exemple.invalid/l',
'https://exemple.invalid/f.js',
'https://exemple.invalid/g.js',
'./h.js',
'https://exemple.invalid/i.js',
'https://exemple.invalid/j.js',
'https://exemple.invalid/k.png',
'https://exemple.invalid/m.html',
'#a',
'https://exemple.invalid/s.png',
'https://exemple.invalid/n.png',
'https://exemple.invalid/o.png',
'https://exemple.invalid/p.png',
'https://exemple.invalid/q.png',
'./t.png',
'https://exemple.invalid/t2.png',
]);
const distantes = [html, css, js].flatMap((texte, rang) =>
adressesChargees(['html', 'css', 'js'][rang], texte).filter(distante),
);
assert.equal(distantes.length, 21);
assert.ok(!distantes.some((a) => a.includes('svelte.dev') || a.includes('w3.org') || a.includes('commente')));
});
test('scriptsDe lit les balises script dans l’ordre, commentaires retirés', () => {
const html = [
'<!-- <script type="module" src="./x.js"></script> -->',
'<script src="./c.js"></script>',
'<script type="module" crossorigin src="./m.js"></script>',
"<script defer src='./d.js'></script>",
].join('');
assert.deepEqual(scriptsDe(html), [
{ src: './c.js', module: false, differe: false },
{ src: './m.js', module: true, differe: false },
{ src: './d.js', module: false, differe: true },
]);
});
});
describe('construction', () => {
test("le paquet livré n'emporte aucun fichier ni module d'épreuve et ne charge rien du réseau (§ 14.9, § 14.15)", async () => {
const sortie = mkdtempSync(join(tmpdir(), 'construction-'));
const travailleur = releveDuTravailleur();
try {
const resultat = await construireLivraison(sortie, travailleur);
const sorties = (Array.isArray(resultat) ? resultat : [resultat]).flatMap(
(r) => r.output,
);
const modules = sorties
.filter((s) => s.type === 'chunk')
.flatMap((s) => s.moduleIds);
assert.ok(
modules.some((id) => id.startsWith(join(RACINE, 'src') + sep)),
`aucun module de src/ parmi ${modules.length} modules examinés`,
);
assert.deepEqual(modules.filter(moduleDEpreuve), []);
// La taille de chaque morceau tient sous le seuil d'avertissement de
// vite.config.js, en kilooctets de 1 000 octets comme Vite les compte :
// un paquet qui grossit au-delà fait tomber cette épreuve, et non
// seulement avertir la construction.
const { default: vite } = await import(pathToFileURL(join(RACINE, 'vite.config.js')).href);
const seuil = vite.build?.chunkSizeWarningLimit;
assert.equal(typeof seuil, 'number', 'vite.config.js ne pose aucun build.chunkSizeWarningLimit');
const morceaux = sorties.filter((s) => s.type === 'chunk');
assert.ok(morceaux.length > 0, `aucun morceau parmi ${sorties.length} sorties`);
assert.deepEqual(
morceaux
.filter((m) => Buffer.byteLength(m.code, 'utf8') > seuil * 1000)
.map((m) => `${m.fileName} : ${Math.ceil(Buffer.byteLength(m.code, 'utf8') / 1000)} ko > ${seuil} ko`),
[],
);
// Un actif n'est pas un module : moduleIds ne le voit pas. Une donnée
// jointe par new URL(…, import.meta.url) n'apparaît que là.
const actifs = sorties
.filter((s) => s.type === 'asset')
.flatMap((s) => (s.originalFileNames ?? []).map((n) => resolve(RACINE, n)));
assert.ok(actifs.length > 0, `aucun actif d'origine connue parmi ${sorties.length} sorties`);
assert.deepEqual(actifs.filter(moduleDEpreuve), []);
// Le paquet du travailleur : ses modules, dont le point d'entrée de la
// génération, passent par le même relevé que ceux du module principal.
const modulesDuTravailleur = travailleur.morceaux.flatMap((m) => m.moduleIds);
assert.ok(travailleur.morceaux.length > 0, 'aucun morceau de travailleur relevé');
assert.ok(
modulesDuTravailleur.includes(join(RACINE, 'src', 'application', 'generation_travailleur.js')),
`generation_travailleur.js absent des ${modulesDuTravailleur.length} modules du travailleur`,
);
assert.deepEqual(modulesDuTravailleur.filter(moduleDEpreuve), []);
const fichiers = readdirSync(sortie, { recursive: true, withFileTypes: true })
.filter((e) => e.isFile())
.map((e) => join(e.parentPath, e.name));
assert.ok(
fichiers.some((f) => f.endsWith('.js')),
`aucun script parmi ${fichiers.length} fichiers écrits`,
);
// Les fichiers écrits, puis le code de chaque morceau du travailleur,
// qui n'est écrit qu'incrusté en chaîne et s'y lirait échappé.
const textes = [
...fichiers.map((f) => ({ nom: relative(sortie, f), contenu: readFileSync(f, 'utf8') })),
...travailleur.morceaux.map((m) => ({ nom: `travailleur:${m.nom}`, contenu: m.code })),
];
const traces = textes.flatMap(({ nom, contenu }) =>
[...TRACES_EPREUVE, CLIENT_DE_DEVELOPPEMENT]
.filter((m) => (typeof m === 'string' ? nom.includes(m) || contenu.includes(m) : m.test(nom) || m.test(contenu)))
.map((m) => `${nom} : ${m}`),
);
assert.deepEqual(traces, []);
// Les adresses chargées : aucune ne sort de la machine. Le HTML en
// porte au moins une, le collecteur, le module d'entrée et sa feuille.
const releves = textes.flatMap(({ nom, contenu }) => {
const nature = natureDe(nom.replace(/^travailleur:/, ''));
return nature === null ? [] : adressesChargees(nature, contenu).map((adresse) => ({ nom, nature, adresse }));
});
assert.ok(
releves.filter(({ nature }) => nature === 'html').length >= 3,
`${releves.length} adresses relevées, dont trop peu dans le HTML`,
);
assert.deepEqual(
releves.filter(({ adresse }) => distante(adresse)).map(({ nom, adresse }) => `${nom} : ${adresse}`),
[],
);
// index.html : la langue, puis le collecteur, script classique copié
// tel quel depuis public/, en tête des scripts et sans async ni defer :
// il s'exécute pendant l'analyse du HTML, avant tout module, que le
// navigateur diffère à la fin de l'analyse.
const html = readFileSync(join(sortie, 'index.html'), 'utf8');
assert.match(html, /<html\b[^>]*\slang="fr"/);
const scripts = scriptsDe(html);
const modulesDuHtml = scripts.filter((s) => s.module);
assert.ok(modulesDuHtml.length > 0, `aucun script module parmi ${scripts.length} scripts`);
assert.deepEqual(scripts[0], { src: './collecteur.js', module: false, differe: false });
assert.equal(
readFileSync(join(sortie, 'collecteur.js'), 'utf8'),
readFileSync(join(RACINE, 'public', 'collecteur.js'), 'utf8'),
);
} finally {
rmSync(sortie, { recursive: true, force: true });
}
});
});