#!/usr/bin/env node /** * E2E Smoke Driver — Playwright navigation smoke test * * Visits every installed surface and asserts: * 1. The shell topbar (.sw-topbar) renders * 2. No JS console errors * 3. The home link exists * * On failure: captures full-page screenshot + console log. * * Usage: * node ci/e2e-smoke-driver.js --server=http://localhost:3000 --token=TOKEN * * Exit codes: 0 = all passed, 1 = failures */ 'use strict'; const { chromium } = require('playwright'); const fs = require('fs'); const path = require('path'); // Parse args const args = {}; process.argv.slice(2).forEach(a => { const [k, v] = a.replace(/^--/, '').split('='); args[k] = v; }); const SERVER = args.server || 'http://localhost:3000'; const TOKEN = args.token || ''; const SCREENSHOT_DIR = args.screenshots || '/tmp/e2e-screenshots'; if (!TOKEN) { console.error('ERROR: --token is required'); process.exit(1); } // Surfaces that require URL parameters or aren't navigable directly const SKIP_SURFACES = new Set([ 'workflow', // requires /w/:id 'workflow-landing', // requires /w/:id/public 'welcome', // redirect/onboarding flow 'test-runners', // tested separately by surface-test-driver ]); /** * Discover all surfaces: core + extension packages with surface_route. */ async function discoverSurfaces(page) { const surfaces = []; // Core surfaces (always present) const coreSurfaces = [ { id: 'admin', route: '/admin' }, { id: 'settings', route: '/settings' }, { id: 'docs', route: '/docs' }, { id: 'team-admin', route: '/team-admin' }, ]; for (const s of coreSurfaces) { if (!SKIP_SURFACES.has(s.id)) { surfaces.push(s); } } // Extension surfaces from API try { const packages = await page.evaluate(async (serverUrl) => { const r = await fetch(serverUrl + '/api/v1/admin/packages', { credentials: 'include', }); if (r.status === 200) { const body = await r.json(); return body.data || body || []; } return []; }, SERVER); for (const pkg of packages) { if (!pkg.enabled) continue; if (pkg.type !== 'surface' && pkg.type !== 'full') continue; if (SKIP_SURFACES.has(pkg.id)) continue; // Extension surfaces live at /s/{id} const route = pkg.surface_route || ('/s/' + pkg.id); surfaces.push({ id: pkg.id, route }); } } catch (e) { console.warn('WARN: Could not fetch package list:', e.message); } return surfaces; } (async () => { const browser = await chromium.launch({ headless: true }); const context = await browser.newContext(); const page = await context.newPage(); // ── Authenticate via page context ───────── // Navigate to login first, then inject tokens into localStorage // and set the arm_token cookie via JS — same mechanism the login // page uses. Playwright's addCookies doesn't reliably work with // SameSite=Strict cookies in Docker DNS environments. console.log('Setting up auth...'); await page.goto(SERVER + '/login', { waitUntil: 'networkidle', timeout: 30000 }); await page.evaluate((token) => { localStorage.setItem('arm_auth', JSON.stringify({ accessToken: token, refreshToken: token, user: null, cookieMaxAge: 604800, })); document.cookie = `arm_token=${token}; path=/; max-age=604800; SameSite=Strict`; }, TOKEN); // ── Discover surfaces ───────────────────── console.log('Discovering surfaces...'); await page.goto(SERVER + '/admin', { waitUntil: 'networkidle', timeout: 30000 }); const surfaces = await discoverSurfaces(page); console.log(`Found ${surfaces.length} surfaces: ${surfaces.map(s => s.id).join(', ')}`); if (surfaces.length === 0) { console.error('ERROR: No surfaces discovered'); await browser.close(); process.exit(1); } // ── Visit each surface ──────────────────── const results = []; let failures = 0; for (const surface of surfaces) { const url = SERVER + surface.route; const consoleErrors = []; // Fresh console error collector per surface const onConsole = msg => { if (msg.type() === 'error') { const text = msg.text(); // Ignore benign errors (favicon, service worker) if (text.includes('favicon') || text.includes('service-worker')) return; consoleErrors.push(text); } }; page.on('console', onConsole); try { process.stdout.write(` ${surface.id} (${surface.route}) ... `); await page.goto(url, { waitUntil: 'networkidle', timeout: 30000 }); // Assert 1: Shell topbar renders (Preact SPA — wait for it to mount) const topbar = await page.waitForSelector('.sw-topbar', { timeout: 15000 }) .catch(() => null); if (!topbar) { throw new Error('Shell topbar (.sw-topbar) not found after 15s'); } // Assert 2: Home link exists (favicon link or any link to /) const homeLink = await page.$('a[href="/"], a[href="./"], .sw-topbar__home'); if (!homeLink) { throw new Error('Home link not found'); } // Assert 3: No JS console errors // Give a moment for any async errors to settle await page.waitForTimeout(500); if (consoleErrors.length > 0) { throw new Error(`JS console errors: ${consoleErrors.join('; ')}`); } console.log('PASS'); results.push({ surface: surface.id, status: 'pass' }); // Capture baseline screenshot (informational, not a gate) const slug = surface.id.replace(/[^a-z0-9-]/g, '_'); await page.screenshot({ path: path.join(SCREENSHOT_DIR, `baseline-${slug}.png`), fullPage: true, }); } catch (err) { console.log('FAIL — ' + err.message); failures++; results.push({ surface: surface.id, status: 'fail', error: err.message }); // Screenshot + console log on failure const slug = surface.id.replace(/[^a-z0-9-]/g, '_'); try { await page.screenshot({ path: path.join(SCREENSHOT_DIR, `fail-${slug}.png`), fullPage: true, }); if (consoleErrors.length > 0) { fs.writeFileSync( path.join(SCREENSHOT_DIR, `fail-${slug}-console.log`), consoleErrors.join('\n') ); } } catch (screenshotErr) { console.warn(' (could not capture screenshot:', screenshotErr.message + ')'); } } finally { page.removeListener('console', onConsole); } } // ── Summary ─────────────────────────────── console.log('\n═══ E2E Smoke Summary ═══'); console.log(` Total: ${results.length}`); console.log(` Passed: ${results.filter(r => r.status === 'pass').length}`); console.log(` Failed: ${failures}`); if (failures > 0) { console.log('\nFailed surfaces:'); for (const r of results.filter(r => r.status === 'fail')) { console.log(` ✗ ${r.surface}: ${r.error}`); } } await browser.close(); process.exit(failures > 0 ? 1 : 0); })();