mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user