ci: add smoke-gated quality checks to the main pipeline

Replaced test.yml with ci.yml, added a built-app smoke runner in scripts/smoke-web-app.js, and inserted a release preflight so tags run the same core checks before packaging.
This commit is contained in:
plebeius
2026-03-08 12:41:52 +08:00
parent 4e3cc06a81
commit e46c7dd169
3 changed files with 418 additions and 43 deletions
+221
View File
@@ -0,0 +1,221 @@
import { spawn } from 'node:child_process';
import { constants } from 'node:fs';
import { access } from 'node:fs/promises';
import { join } from 'node:path';
import process from 'node:process';
import { setTimeout as delay } from 'node:timers/promises';
import { chromium, devices } from 'playwright';
const DEFAULT_SERVER_URL = 'http://127.0.0.1:4173/';
const DEFAULT_HOME_URL = `${DEFAULT_SERVER_URL}#/`;
const PREVIEW_PORT = '4173';
const PREVIEW_HOST = '127.0.0.1';
const PREVIEW_TIMEOUT_MS = 30_000;
const PAGE_TIMEOUT_MS = 30_000;
const shouldStartPreview = process.argv.includes('--start-preview');
const smokeBaseUrl = process.env.SMOKE_BASE_URL || DEFAULT_HOME_URL;
const smokeServerUrl = smokeBaseUrl.split('#')[0] || DEFAULT_SERVER_URL;
const buildRouteUrl = (hashPath) => {
const normalizedHash = hashPath.startsWith('#') ? hashPath : `#${hashPath}`;
return `${smokeServerUrl.replace(/\/?$/, '/')}${normalizedHash}`;
};
const previewCommand = process.platform === 'win32' ? 'vite.cmd' : 'vite';
const previewBin = join(process.cwd(), 'node_modules', '.bin', previewCommand);
let previewProcess = null;
let previewShutdownRequested = false;
const scenarios = [
{
name: 'desktop',
contextOptions: {
viewport: { width: 1440, height: 960 },
},
},
{
name: 'mobile',
contextOptions: {
...devices['Pixel 7'],
},
},
];
const cleanupPreview = async () => {
if (!previewProcess || previewProcess.exitCode !== null || previewProcess.killed) {
return;
}
previewShutdownRequested = true;
previewProcess.kill('SIGTERM');
const exited = await Promise.race([
new Promise((resolve) => {
previewProcess?.once('exit', () => resolve(true));
}),
delay(5_000).then(() => false),
]);
if (!exited && previewProcess && previewProcess.exitCode === null && !previewProcess.killed) {
previewProcess.kill('SIGKILL');
}
};
const registerSignalHandlers = () => {
const handler = async (signal) => {
await cleanupPreview();
process.exit(signal === 'SIGINT' ? 130 : 143);
};
process.once('SIGINT', handler);
process.once('SIGTERM', handler);
};
const assertNoPageErrors = (pageErrors, scenarioName, stepName) => {
if (pageErrors.length === 0) {
return;
}
const messages = pageErrors.map((error) => error.stack || error.message).join('\n\n');
throw new Error(`[${scenarioName}] ${stepName} triggered renderer errors:\n${messages}`);
};
const waitForAppRender = async (page, minTextLength = 20) => {
await page.waitForFunction(
(expectedLength) => {
const root = document.getElementById('root');
const text = (document.body?.innerText || '').trim();
return Boolean(root && root.childElementCount > 0 && text.length >= expectedLength);
},
minTextLength,
{ timeout: PAGE_TIMEOUT_MS },
);
};
const waitForTitle = async (page, expectedTitle) => {
await page.waitForFunction((title) => document.title === title, expectedTitle, { timeout: PAGE_TIMEOUT_MS });
};
const waitForVisibleText = async (page, pattern) => {
await page.getByText(pattern).first().waitFor({ state: 'visible', timeout: PAGE_TIMEOUT_MS });
};
const runScenario = async (browser, scenario) => {
const context = await browser.newContext(scenario.contextOptions);
const page = await context.newPage();
const pageErrors = [];
const consoleErrors = new Set();
page.on('pageerror', (error) => pageErrors.push(error));
page.on('crash', () => pageErrors.push(new Error('Page crashed')));
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.add(message.text());
}
});
try {
await page.goto(buildRouteUrl('/'), { waitUntil: 'domcontentloaded', timeout: PAGE_TIMEOUT_MS });
await waitForAppRender(page, 100);
await waitForTitle(page, '5chan');
await waitForVisibleText(page, /What is 5chan|what_is_5chan/i);
assertNoPageErrors(pageErrors, scenario.name, 'home route');
await page.getByRole('link', { name: /^FAQ$/i }).first().click();
await page.waitForURL((url) => url.hash === '#/faq', { timeout: PAGE_TIMEOUT_MS });
await waitForTitle(page, 'FAQ - 5chan');
await waitForVisibleText(page, /Frequently Asked Questions/i);
assertNoPageErrors(pageErrors, scenario.name, 'FAQ route');
await page.getByRole('link', { name: /^Rules$/i }).first().click();
await page.waitForURL((url) => url.hash === '#/rules', { timeout: PAGE_TIMEOUT_MS });
await waitForTitle(page, 'Rules - 5chan');
await waitForVisibleText(page, /^Rules$/i);
await waitForVisibleText(page, /Load rules from a board/i);
assertNoPageErrors(pageErrors, scenario.name, 'Rules route');
await page.goto(buildRouteUrl('/all/settings'), { waitUntil: 'domcontentloaded', timeout: PAGE_TIMEOUT_MS });
await waitForAppRender(page, 20);
await page.locator('#interface-settings').waitFor({ state: 'visible', timeout: PAGE_TIMEOUT_MS });
await page.locator('#media-hosting-settings').waitFor({ state: 'visible', timeout: PAGE_TIMEOUT_MS });
assertNoPageErrors(pageErrors, scenario.name, 'settings route');
if (consoleErrors.size > 0) {
console.warn(`[smoke:${scenario.name}] console errors observed during smoke test:`);
for (const message of consoleErrors) {
console.warn(message);
}
}
} finally {
await context.close();
}
};
const startPreview = async () => {
await access(join(process.cwd(), 'build', 'index.html'), constants.R_OK);
await access(previewBin, constants.X_OK);
previewProcess = spawn(previewBin, ['preview', '--host', PREVIEW_HOST, '--port', PREVIEW_PORT, '--strictPort'], {
cwd: process.cwd(),
env: process.env,
stdio: 'inherit',
});
if (!previewProcess.pid) {
throw new Error('Failed to start Vite preview server');
}
previewProcess.once('exit', (code) => {
if (!previewShutdownRequested && code !== 0 && code !== null) {
console.error(`Vite preview exited early with code ${code}`);
}
});
};
const waitForServer = async () => {
const startedAt = Date.now();
while (Date.now() - startedAt < PREVIEW_TIMEOUT_MS) {
try {
const response = await fetch(smokeServerUrl, { redirect: 'manual' });
if (response.ok || response.status === 304) {
return;
}
} catch {
// Keep polling until timeout.
}
await delay(500);
}
throw new Error(`Timed out waiting for preview server at ${smokeServerUrl}`);
};
const main = async () => {
registerSignalHandlers();
try {
if (shouldStartPreview) {
await startPreview();
await waitForServer();
}
const browser = await chromium.launch({ headless: true });
try {
for (const scenario of scenarios) {
console.log(`Running smoke checks for ${scenario.name}...`);
await runScenario(browser, scenario);
}
} finally {
await browser.close();
}
console.log('Web smoke checks passed.');
} finally {
await cleanupPreview();
}
};
await main();