Compare commits

...

4 Commits

Author SHA1 Message Date
Kpa-clawbot
1191cfc017 fix(ci): restore Linux runner labels and action versions
The previous commit accidentally downgraded action versions (v5/v6 → v4)
and removed the Linux label from self-hosted runner selectors. This caused
Windows runners to pick up jobs with bash syntax, failing with PowerShell
parse errors (Missing '(' after 'if' in if statement).

Fixes:
- Restore runs-on: [self-hosted, Linux] for all self-hosted jobs
- Restore actions/checkout@v5, setup-go@v6, setup-node@v5
- Restore actions/upload-artifact@v5, download-artifact@v5
2026-03-29 15:39:46 +00:00
you
68bdaaca99 perf: speed up frontend coverage tests (~3x faster)
Three optimizations to the CI frontend test pipeline:

1. Run E2E tests and coverage collection concurrently
   - Previously sequential (E2E ~1.5min, then coverage ~5.75min)
   - Now both run in parallel against the same instrumented server
   - Expected savings: ~5 min (coverage runs alongside E2E instead of after)

2. Replace networkidle with domcontentloaded in coverage collector
   - SPA uses hash routing — networkidle waits 500ms for network silence
     on every navigation, adding ~10-15s of dead time across 23 navigations
   - domcontentloaded fires immediately once HTML is parsed; JS initializes
     the route handler synchronously
   - For in-page hash changes, use 200ms setTimeout instead of
     waitForLoadState (which would never re-fire for same-document nav)

3. Extract coverage from E2E tests too
   - E2E tests already exercise the app against the instrumented server
   - Now writes window.__coverage__ to .nyc_output/e2e-coverage.json
   - nyc merges both coverage files for higher total coverage

Also:
- Split Playwright install into browser + deps steps (deps skip if present)
- Replace sleep 5 with health-check poll in quick E2E path
2026-03-29 14:35:05 +00:00
you
074f3d3760 ci: cancel workflow run immediately when any test job fails
When go-test or node-test fails, the workflow run is now cancelled
via the GitHub API so the sibling job doesn't sit queued/running.

Also fixed build job to need both go-test AND node-test (was only
waiting on go-test despite the pipeline comment saying both gate it).
2026-03-29 14:20:22 +00:00
you
206d9bd64a fix: use per-PR concurrency group to prevent cross-PR cancellation
The flat 'deploy' concurrency group caused ALL PRs to share one queue,
so pushing to any PR would cancel CI runs on other PRs.

Changed to deploy-${{ github.event.pull_request.number || github.ref }}
so each PR gets its own concurrency group while re-pushes to the same
PR still cancel the previous run.
2026-03-29 14:14:57 +00:00
3 changed files with 97 additions and 49 deletions

View File

@@ -17,7 +17,7 @@ on:
- 'docs/**'
concurrency:
group: deploy
group: deploy-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
@@ -41,10 +41,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go 1.22
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version: '1.22'
cache-dependency-path: |
@@ -122,9 +122,16 @@ jobs:
echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY
echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY
- name: Cancel workflow on failure
if: failure()
run: |
curl -s -X POST \
-H "Authorization: Bearer ${{ github.token }}" \
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
- name: Upload Go coverage badges
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: go-badges
path: .badges/go-*.json
@@ -136,15 +143,15 @@ jobs:
# ───────────────────────────────────────────────────────────────
node-test:
name: "🧪 Node.js Tests"
runs-on: self-hosted
runs-on: [self-hosted, Linux]
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 2
- name: Set up Node.js 22
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '22'
@@ -190,7 +197,11 @@ jobs:
- name: Install Playwright browser
if: steps.changes.outputs.frontend == 'true'
run: npx playwright install chromium --with-deps 2>/dev/null || true
run: |
# Install chromium (skips download if already cached on self-hosted runner)
npx playwright install chromium 2>/dev/null || true
# Install system deps only if missing (apt-get is slow)
npx playwright install-deps chromium 2>/dev/null || true
- name: Instrument frontend JS for coverage
if: steps.changes.outputs.frontend == 'true'
@@ -220,19 +231,32 @@ jobs:
sleep 1
done
- name: Run Playwright E2E tests
- name: Run Playwright E2E + coverage collection concurrently
if: steps.changes.outputs.frontend == 'true'
run: BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
run: |
# Run E2E tests and coverage collection in parallel — both use the same server
BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt &
E2E_PID=$!
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt &
COV_PID=$!
- name: Collect frontend coverage report
# Wait for both — E2E must pass, coverage is best-effort
E2E_EXIT=0
wait $E2E_PID || E2E_EXIT=$?
wait $COV_PID || true
# Fail if E2E failed
[ $E2E_EXIT -ne 0 ] && exit $E2E_EXIT
true
- name: Generate frontend coverage badges
if: always() && steps.changes.outputs.frontend == 'true'
run: |
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1)
mkdir -p .badges
if [ -f .nyc_output/frontend-coverage.json ]; then
# Merge E2E + coverage collector data if both exist
if [ -f .nyc_output/frontend-coverage.json ] || [ -f .nyc_output/e2e-coverage.json ]; then
npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt
FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0")
FE_COVERAGE=${FE_COVERAGE:-0}
@@ -259,13 +283,24 @@ jobs:
fuser -k 13581/tcp 2>/dev/null || true
PORT=13581 node server.js &
SERVER_PID=$!
sleep 5
# Wait for server to be ready (up to 15s)
for i in $(seq 1 15); do
curl -sf http://localhost:13581/api/stats > /dev/null 2>&1 && break
sleep 1
done
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
kill $SERVER_PID 2>/dev/null || true
- name: Cancel workflow on failure
if: failure()
run: |
curl -s -X POST \
-H "Authorization: Bearer ${{ github.token }}" \
"https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/cancel"
- name: Upload Node.js test badges
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v5
with:
name: node-badges
path: .badges/
@@ -278,14 +313,14 @@ jobs:
build:
name: "🏗️ Build Docker Image"
if: github.event_name == 'push'
needs: [go-test]
runs-on: self-hosted
needs: [go-test, node-test]
runs-on: [self-hosted, Linux]
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Node.js 22
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '22'
@@ -304,10 +339,10 @@ jobs:
name: "🚀 Deploy Staging"
if: github.event_name == 'push'
needs: [build]
runs-on: self-hosted
runs-on: [self-hosted, Linux]
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Start staging on port 82
run: |
@@ -349,21 +384,21 @@ jobs:
name: "📝 Publish Badges & Summary"
if: github.event_name == 'push'
needs: [deploy]
runs-on: self-hosted
runs-on: [self-hosted, Linux]
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Download Go coverage badges
continue-on-error: true
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: go-badges
path: .badges/
- name: Download Node.js test badges
continue-on-error: true
uses: actions/download-artifact@v4
uses: actions/download-artifact@v5
with:
name: node-badges
path: .badges/

View File

@@ -64,9 +64,9 @@ async function collectCoverage() {
// ══════════════════════════════════════════════
console.log(' [coverage] Home page — chooser...');
// Clear localStorage to get chooser
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(BASE, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
await page.evaluate(() => localStorage.clear()).catch(() => {});
await page.goto(`${BASE}/#/home`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Click "I'm new"
await safeClick('#chooseNew');
@@ -105,7 +105,7 @@ async function collectCoverage() {
// Switch to experienced mode
await page.evaluate(() => localStorage.clear()).catch(() => {});
await page.goto(`${BASE}/#/home`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/home`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
await safeClick('#chooseExp');
// Interact with experienced home page
@@ -120,7 +120,7 @@ async function collectCoverage() {
// NODES PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Nodes page...');
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Sort by EVERY column
for (const col of ['name', 'public_key', 'role', 'last_seen', 'advert_count']) {
@@ -168,7 +168,7 @@ async function collectCoverage() {
try {
const firstNodeKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim());
if (firstNodeKey) {
await page.goto(`${BASE}/#/nodes/${firstNodeKey}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/nodes/${firstNodeKey}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Click tabs on detail page
await clickAll('.tab-btn, [data-tab]', 10);
@@ -191,7 +191,7 @@ async function collectCoverage() {
try {
const firstKey = await page.$eval('#nodesBody tr td:nth-child(2)', el => el.textContent.trim()).catch(() => null);
if (firstKey) {
await page.goto(`${BASE}/#/nodes/${firstKey}?scroll=paths`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/nodes/${firstKey}?scroll=paths`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
}
} catch {}
@@ -199,7 +199,7 @@ async function collectCoverage() {
// PACKETS PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Packets page...');
await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Open filter bar
await safeClick('#filterToggleBtn');
@@ -285,13 +285,13 @@ async function collectCoverage() {
} catch {}
// Navigate to specific packet by hash
await page.goto(`${BASE}/#/packets/deadbeef`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/packets/deadbeef`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// ══════════════════════════════════════════════
// MAP PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Map page...');
await page.goto(`${BASE}/#/map`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/map`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Toggle controls panel
await safeClick('#mapControlsToggle');
@@ -345,7 +345,7 @@ async function collectCoverage() {
// ANALYTICS PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Analytics page...');
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/analytics`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Click EVERY analytics tab
const analyticsTabs = ['overview', 'rf', 'topology', 'channels', 'hashsizes', 'collisions', 'subpaths', 'nodes', 'distance'];
@@ -383,7 +383,7 @@ async function collectCoverage() {
// Deep-link to each analytics tab via URL
for (const tab of analyticsTabs) {
await page.goto(`${BASE}/#/analytics?tab=${tab}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/analytics?tab=${tab}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
}
// Region filter on analytics
@@ -396,7 +396,7 @@ async function collectCoverage() {
// CUSTOMIZE
// ══════════════════════════════════════════════
console.log(' [coverage] Customizer...');
await page.goto(BASE, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(BASE, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
await safeClick('#customizeToggle');
// Click EVERY customizer tab
@@ -503,7 +503,7 @@ async function collectCoverage() {
// CHANNELS PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Channels page...');
await page.goto(`${BASE}/#/channels`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/channels`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Click channel rows/items
await clickAll('.channel-item, .channel-row, .channel-card', 3);
await clickAll('table tbody tr', 3);
@@ -512,7 +512,7 @@ async function collectCoverage() {
try {
const channelHash = await page.$eval('table tbody tr td:first-child', el => el.textContent.trim()).catch(() => null);
if (channelHash) {
await page.goto(`${BASE}/#/channels/${channelHash}`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/channels/${channelHash}`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
}
} catch {}
@@ -520,7 +520,7 @@ async function collectCoverage() {
// LIVE PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Live page...');
await page.goto(`${BASE}/#/live`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/live`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// VCR controls
await safeClick('#vcrPauseBtn');
@@ -603,14 +603,14 @@ async function collectCoverage() {
// TRACES PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Traces page...');
await page.goto(`${BASE}/#/traces`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/traces`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
await clickAll('table tbody tr', 3);
// ══════════════════════════════════════════════
// OBSERVERS PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Observers page...');
await page.goto(`${BASE}/#/observers`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/observers`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Click observer rows
const obsRows = await page.$$('table tbody tr, .observer-card, .observer-row');
for (let i = 0; i < Math.min(obsRows.length, 3); i++) {
@@ -631,7 +631,7 @@ async function collectCoverage() {
// PERF PAGE
// ══════════════════════════════════════════════
console.log(' [coverage] Perf page...');
await page.goto(`${BASE}/#/perf`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/perf`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
await safeClick('#perfRefresh');
await safeClick('#perfReset');
@@ -641,14 +641,14 @@ async function collectCoverage() {
console.log(' [coverage] App.js — router + global...');
// Navigate to bad route to trigger error/404
await page.goto(`${BASE}/#/nonexistent-route`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/nonexistent-route`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
// Navigate to every route via hash
const allRoutes = ['home', 'nodes', 'packets', 'map', 'live', 'channels', 'traces', 'observers', 'analytics', 'perf'];
for (const route of allRoutes) {
try {
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
await page.waitForLoadState('networkidle').catch(() => {});
await new Promise(r => setTimeout(r, 200));
} catch {}
}
@@ -788,14 +788,14 @@ async function collectCoverage() {
console.log(' [coverage] Region filter...');
try {
// Open region filter on nodes page
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/nodes`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
await safeClick('#nodesRegionFilter');
await clickAll('#nodesRegionFilter input[type="checkbox"]', 3);
} catch {}
// Region filter on packets
try {
await page.goto(`${BASE}/#/packets`, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.goto(`${BASE}/#/packets`, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {});
await safeClick('#packetsRegionFilter');
await clickAll('#packetsRegionFilter input[type="checkbox"]', 3);
} catch {}
@@ -807,7 +807,7 @@ async function collectCoverage() {
for (const route of allRoutes) {
try {
await page.evaluate((r) => { location.hash = '#/' + r; }, route);
await page.waitForLoadState('networkidle').catch(() => {});
await new Promise(r => setTimeout(r, 200));
} catch {}
}

View File

@@ -909,6 +909,19 @@ async function run() {
assert(hexDump, 'Hex dump should be visible after selecting a packet');
});
// Extract frontend coverage if instrumented server is running
try {
const coverage = await page.evaluate(() => window.__coverage__);
if (coverage) {
const fs = require('fs');
const path = require('path');
const outDir = path.join(__dirname, '.nyc_output');
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
fs.writeFileSync(path.join(outDir, 'e2e-coverage.json'), JSON.stringify(coverage));
console.log(`Frontend coverage from E2E: ${Object.keys(coverage).length} files`);
}
} catch {}
await browser.close();
// Summary