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
@@ -1,15 +1,138 @@
name: Test Builds
name: CI
on:
push:
branches: [ development, master ]
branches: [development, master]
pull_request:
branches: [ development, master ]
branches: [development, master]
workflow_dispatch:
env:
HUSKY: '0'
permissions:
contents: read
jobs:
test-linux:
name: Test Linux (Ubuntu)
quality:
name: Quality + Smoke
runs-on: ubuntu-22.04
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js v22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'yarn'
- name: Install dependencies
run: |
for i in 1 2 3; do
echo "yarn install attempt $i"
yarn install --frozen-lockfile --ignore-engines --network-timeout 100000 --network-concurrency 1 && break
sleep 5
[ "$i" = "3" ] && exit 1
done
- name: Run Vitest
run: yarn test --run
- name: Collect coverage
run: yarn test:coverage
- name: Lint
run: yarn lint
- name: Type check
run: yarn type-check
- name: Build React app
run: yarn build
env:
CI: ''
- name: Build preload script
run: yarn build:preload
- name: Verify build outputs
run: |
ls -la build/ || exit 1
ls -la build/electron/preload.cjs || exit 1
ls -la build/index.html || exit 1
- name: Verify forge config
run: node -e "import('./forge.config.js').then(c => console.log('Config loaded, makers:', c.default.makers?.length || 0)).catch(e => { console.error(e); process.exit(1); })"
- name: Run React Doctor
run: yarn doctor
- name: Install Chromium for smoke tests
run: npx playwright install --with-deps chromium
- name: Smoke test built web app
run: node scripts/smoke-web-app.js --start-preview
- name: Upload coverage summary
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-summary
path: coverage/coverage-summary.json
if-no-files-found: warn
android-build:
name: Android Build
runs-on: ubuntu-22.04
needs: quality
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Java 17
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: '17'
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v4
- name: Setup Node.js v22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'yarn'
- name: Install dependencies
run: |
for i in 1 2 3; do
echo "yarn install attempt $i"
yarn install --frozen-lockfile --ignore-engines --network-timeout 100000 --network-concurrency 1 && break
sleep 5
[ "$i" = "3" ] && exit 1
done
- name: Build React app
run: yarn build
env:
CI: ''
- name: Sync Capacitor Android project
run: npx cap sync android
- name: Run Android unit tests and assemble debug APK
run: cd android && ./gradlew testDebugUnitTest assembleDebug --stacktrace
package-linux:
name: Package Linux (Ubuntu)
runs-on: ubuntu-22.04
needs: quality
steps:
- uses: actions/checkout@v4
with:
@@ -37,49 +160,38 @@ jobs:
[ "$i" = "3" ] && exit 1
done
- name: Build React App
- name: Build React app
run: yarn build
env:
CI: ''
- name: Build Preload Script
- name: Build preload script
run: yarn build:preload
- name: Verify build outputs
run: |
echo "=== Checking build/ directory ==="
ls -la build/ || exit 1
echo "=== Checking build/electron/preload.cjs ==="
ls -la build/electron/preload.cjs || exit 1
echo "=== Checking build/index.html ==="
ls -la build/index.html || exit 1
- name: Verify forge config
run: |
echo "=== forge.config.js exists ==="
ls -la forge.config.js
echo "=== Validating forge config can be loaded ==="
node -e "import('./forge.config.js').then(c => console.log('Config loaded, makers:', c.default.makers?.length || 0)).catch(e => { console.error(e); process.exit(1); })"
- name: Package Electron App
- name: Package Electron app
timeout-minutes: 30
run: |
set -o pipefail
yarn electron-forge package 2>&1 | tee forge-output.log
echo "=== Checking out/ directory ==="
ls -la out/
- name: Verify Executable
- name: Verify executable
run: |
ls -la out/
EXE=$(node scripts/find-forge-executable.js)
echo "Found executable: $EXE"
# Verify the file exists and is executable
test -f "$EXE" && test -x "$EXE" && echo "✓ Executable is valid"
test -f "$EXE" && test -x "$EXE" && echo "Executable is valid"
test-mac-intel:
name: Test Mac (Intel)
package-mac-intel:
name: Package Mac (Intel)
runs-on: macos-15-intel
needs: quality
steps:
- uses: actions/checkout@v4
with:
@@ -110,12 +222,12 @@ jobs:
[ "$i" = "3" ] && exit 1
done
- name: Build React App
- name: Build React app
run: yarn build
env:
CI: ''
- name: Build Preload Script
- name: Build preload script
run: yarn build:preload
- name: Verify build outputs
@@ -124,23 +236,24 @@ jobs:
ls -la build/electron/preload.cjs || exit 1
ls -la build/index.html || exit 1
- name: Package Electron App
- name: Package Electron app
timeout-minutes: 30
run: |
set -o pipefail
yarn electron-forge package 2>&1 | tee forge-output.log
ls -la out/
- name: Verify Executable
- name: Verify executable
run: |
ls -la out/
EXE=$(node scripts/find-forge-executable.js)
echo "Found executable: $EXE"
test -f "$EXE" && test -x "$EXE" && echo "Executable is valid"
test -f "$EXE" && test -x "$EXE" && echo "Executable is valid"
test-mac-arm:
name: Test Mac (Apple Silicon)
package-mac-arm:
name: Package Mac (Apple Silicon)
runs-on: macos-latest
needs: quality
steps:
- uses: actions/checkout@v4
with:
@@ -171,12 +284,12 @@ jobs:
[ "$i" = "3" ] && exit 1
done
- name: Build React App
- name: Build React app
run: yarn build
env:
CI: ''
- name: Build Preload Script
- name: Build preload script
run: yarn build:preload
- name: Verify build outputs
@@ -185,23 +298,24 @@ jobs:
ls -la build/electron/preload.cjs || exit 1
ls -la build/index.html || exit 1
- name: Package Electron App
- name: Package Electron app
timeout-minutes: 30
run: |
set -o pipefail
yarn electron-forge package 2>&1 | tee forge-output.log
ls -la out/
- name: Verify Executable
- name: Verify executable
run: |
ls -la out/
EXE=$(node scripts/find-forge-executable.js)
echo "Found executable: $EXE"
test -f "$EXE" && test -x "$EXE" && echo "Executable is valid"
test -f "$EXE" && test -x "$EXE" && echo "Executable is valid"
test-windows:
name: Test Windows
package-windows:
name: Package Windows
runs-on: windows-2022
needs: quality
steps:
- uses: actions/checkout@v4
with:
@@ -230,13 +344,13 @@ jobs:
[ "$i" = "3" ] && exit 1
done
- name: Build React App
- name: Build React app
shell: bash
run: yarn build
env:
CI: ''
- name: Build Preload Script
- name: Build preload script
shell: bash
run: yarn build:preload
@@ -247,7 +361,7 @@ jobs:
ls -la build/electron/preload.cjs || exit 1
ls -la build/index.html || exit 1
- name: Package Electron App
- name: Package Electron app
shell: bash
timeout-minutes: 30
run: |
@@ -255,10 +369,10 @@ jobs:
yarn electron-forge package 2>&1 | tee forge-output.log
ls -la out/
- name: Verify Executable
- name: Verify executable
shell: bash
run: |
ls -la out/
EXE=$(node scripts/find-forge-executable.js)
echo "Found executable: $EXE"
test -f "$EXE" && test -x "$EXE" && echo "Executable is valid"
test -f "$EXE" && test -x "$EXE" && echo "Executable is valid"
+40
View File
@@ -13,12 +13,49 @@ env:
HUSKY: '0'
jobs:
preflight:
runs-on: ubuntu-22.04
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js v22
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'yarn'
- name: Install dependencies
run: |
for i in 1 2 3; do
echo "yarn install attempt $i"
yarn install --frozen-lockfile --ignore-engines --network-timeout 100000 --network-concurrency 1 && break
sleep 5
[ "$i" = "3" ] && exit 1
done
- name: Run Vitest
run: yarn test --run
- name: Lint
run: yarn lint
- name: Type check
run: yarn type-check
- name: Build React app
run: CI='' NODE_ENV=production yarn build
- name: Run React Doctor
run: yarn doctor
- name: Install Chromium for smoke tests
run: npx playwright install --with-deps chromium
- name: Smoke test built web app
run: node scripts/smoke-web-app.js --start-preview
linux:
strategy:
fail-fast: false
matrix:
arch: [x64, arm64]
runs-on: ubuntu-22.04
needs: preflight
permissions:
contents: write
steps:
@@ -85,6 +122,7 @@ jobs:
- runner: macos-latest # Apple Silicon arm64
arch: arm64
runs-on: ${{ matrix.runner }}
needs: preflight
permissions:
contents: write
steps:
@@ -154,6 +192,7 @@ jobs:
windows:
runs-on: windows-2022
needs: preflight
permissions:
contents: write
steps:
@@ -195,6 +234,7 @@ jobs:
android:
runs-on: ubuntu-22.04
needs: preflight
permissions:
contents: write
steps:
+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();