- engines.node >=20.0.0 and .nvmrc mirroring .node-version (#54) - npm audit --audit-level=high is a hard gate (tree is at 0) (#91) - scripts/boot-check.mjs serves dist/ with vite preview and asserts /, config.json, the entry chunk and the Element Call bundle all load (#92) - scripts/check-bundle-size.mjs enforces gzip budgets from scripts/bundle-budget.json (seeded +10%); fails PRs, warns on push (#96) - check:eslint runs with --max-warnings 68 so the count can only go down; 7 unused eslint-disable directives removed to get there (#97) Fixes #54 Fixes #91 Fixes #92 Fixes #96 Fixes #97 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
149 lines
4.6 KiB
JavaScript
149 lines
4.6 KiB
JavaScript
#!/usr/bin/env node
|
|
// Boot-check (Gitea #92): after `npm run build`, nothing actually loaded the
|
|
// built dist/ — a build that produces a broken bundle (bad base path, a 500
|
|
// from an asset, a malformed config.json) still went green. This script
|
|
// serves dist/ the same way production does (vite preview) and makes a
|
|
// handful of real HTTP requests against it, so a broken bundle fails CI
|
|
// instead of surfacing after deploy.
|
|
//
|
|
// No Playwright here: playwright-core is not a project dependency (only the
|
|
// browser binary caches happen to be present on this machine), so we don't
|
|
// depend on it being installed. Plain fetch is enough to catch the class of
|
|
// bug this check exists for — a page/asset/config that doesn't come back.
|
|
import { spawn } from 'node:child_process';
|
|
|
|
const PORT = 4173;
|
|
const HOST = '127.0.0.1';
|
|
const BASE_URL = `http://${HOST}:${PORT}`;
|
|
const BOOT_TIMEOUT_MS = 30_000;
|
|
|
|
function log(msg) {
|
|
console.log(`[boot-check] ${msg}`);
|
|
}
|
|
|
|
function waitForPort(url, timeoutMs) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
const attempt = async () => {
|
|
try {
|
|
const res = await fetch(url, { method: 'GET' });
|
|
return res;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
return new Promise((resolve, reject) => {
|
|
const poll = async () => {
|
|
const res = await attempt();
|
|
if (res) {
|
|
resolve();
|
|
return;
|
|
}
|
|
if (Date.now() > deadline) {
|
|
reject(new Error(`Timed out waiting for ${url} to come up`));
|
|
return;
|
|
}
|
|
setTimeout(poll, 300);
|
|
};
|
|
poll();
|
|
});
|
|
}
|
|
|
|
async function assert(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(`Assertion failed: ${message}`);
|
|
}
|
|
log(`ok: ${message}`);
|
|
}
|
|
|
|
async function main() {
|
|
const preview = spawn(
|
|
'npx',
|
|
['vite', 'preview', '--port', String(PORT), '--strictPort', '--host', HOST],
|
|
{ stdio: ['ignore', 'pipe', 'pipe'] },
|
|
);
|
|
|
|
let previewOutput = '';
|
|
preview.stdout.on('data', (d) => (previewOutput += d.toString()));
|
|
preview.stderr.on('data', (d) => (previewOutput += d.toString()));
|
|
|
|
const cleanup = () => {
|
|
if (!preview.killed) {
|
|
preview.kill('SIGTERM');
|
|
}
|
|
};
|
|
process.on('exit', cleanup);
|
|
process.on('SIGINT', () => {
|
|
cleanup();
|
|
process.exit(1);
|
|
});
|
|
process.on('SIGTERM', () => {
|
|
cleanup();
|
|
process.exit(1);
|
|
});
|
|
|
|
try {
|
|
await waitForPort(BASE_URL, BOOT_TIMEOUT_MS);
|
|
|
|
// 1. index page loads and contains the SPA mount point.
|
|
const indexRes = await fetch(`${BASE_URL}/`);
|
|
await assert(indexRes.status === 200, `GET / returns 200 (got ${indexRes.status})`);
|
|
const indexHtml = await indexRes.text();
|
|
await assert(indexHtml.includes('<div id="root"'), 'GET / contains <div id="root"');
|
|
|
|
// 2. runtime config is valid JSON.
|
|
const configRes = await fetch(`${BASE_URL}/config.json`);
|
|
await assert(
|
|
configRes.status === 200,
|
|
`GET /config.json returns 200 (got ${configRes.status})`,
|
|
);
|
|
const configText = await configRes.text();
|
|
let configJson;
|
|
try {
|
|
configJson = JSON.parse(configText);
|
|
} catch (err) {
|
|
throw new Error(`GET /config.json is not valid JSON: ${err.message}`);
|
|
}
|
|
await assert(
|
|
typeof configJson === 'object' && configJson !== null,
|
|
'/config.json parses to an object',
|
|
);
|
|
|
|
// 3. the main JS entry referenced from index.html actually loads.
|
|
const scriptMatch = indexHtml.match(/<script[^>]+type="module"[^>]+src="([^"]+)"/);
|
|
await assert(!!scriptMatch, 'index.html references a module script entry');
|
|
const mainScriptUrl = new URL(scriptMatch[1], BASE_URL).toString();
|
|
const scriptRes = await fetch(mainScriptUrl);
|
|
await assert(
|
|
scriptRes.status === 200,
|
|
`GET ${scriptMatch[1]} returns 200 (got ${scriptRes.status})`,
|
|
);
|
|
const scriptContentType = scriptRes.headers.get('content-type') || '';
|
|
await assert(
|
|
/javascript/.test(scriptContentType),
|
|
`GET ${scriptMatch[1]} has a JS content-type (got "${scriptContentType}")`,
|
|
);
|
|
|
|
// 4. Element Call widget bundle is present.
|
|
const callRes = await fetch(`${BASE_URL}/public/element-call/index.html`);
|
|
await assert(
|
|
callRes.status === 200,
|
|
`GET /public/element-call/index.html returns 200 (got ${callRes.status})`,
|
|
);
|
|
|
|
log('all checks passed');
|
|
} finally {
|
|
cleanup();
|
|
}
|
|
}
|
|
|
|
main()
|
|
.catch((err) => {
|
|
console.error(`[boot-check] FAILED: ${err.message}`);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(() => {
|
|
// The killed preview server's stdio pipes can keep the event loop alive
|
|
// briefly; force the process down promptly with whatever exit code was set.
|
|
process.exit(process.exitCode ?? 0);
|
|
});
|