// ui.spec.mjs — browser regression gate for the panel controller UI. // Runs with @playwright/test: npx playwright test ui.spec.mjs // // Requires a LOCALLY RUNNING controller (see ../../memory/build.md). // BASE_URL controller origin (default http://127.0.0.1:8080) // TEST_EMAIL panel account email \ needed for the authenticated // TEST_PASSWORD panel account password / tests; without them those // tests self-skip (annotated). // // Anything that would need a live agent (server cards with real data, // consoles, etc.) is deliberately NOT asserted — those checks self-skip. // The point of this suite is the 2026-05-08 class of regression: the page // serves, the inline JS boots with zero console errors, the root route's // panel_ui cookie logic doesn't loop, and the command-center chrome // (palette, hero stats) exists. import { test, expect } from '@playwright/test'; const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080'; const EMAIL = process.env.TEST_EMAIL || ''; const PASSWORD = process.env.TEST_PASSWORD || ''; // Collect console errors + page errors on a page. Filters nothing: the gate // is "0 console errors" on first paint, matching the redroid-console house // boilerplate (page.on('console'/'pageerror') capture). function captureErrors(page) { const errs = []; page.on('console', m => { if (m.type() === 'error') errs.push(`[console.error] ${m.text().slice(0, 300)}`); }); page.on('pageerror', e => errs.push(`[pageerror] ${e.message.slice(0, 300)}`)); return errs; } async function login(page) { await page.goto(BASE + '/login'); await page.fill('#email', EMAIL); await page.fill('#password', PASSWORD); await page.click('#submit'); await page.waitForURL(u => !u.pathname.startsWith('/login'), { timeout: 10_000 }); } test.describe('unauthenticated', () => { test('login page renders with its form and 0 console errors', async ({ page }) => { const errs = captureErrors(page); const resp = await page.goto(BASE + '/login'); expect(resp.ok()).toBeTruthy(); await expect(page.locator('#email')).toBeVisible(); await expect(page.locator('#password')).toBeVisible(); await expect(page.locator('#submit')).toBeVisible(); await page.waitForTimeout(500); expect(errs, errs.join('\n')).toHaveLength(0); }); test('root without session 302s to /login (no loop)', async ({ page }) => { await page.goto(BASE + '/'); await expect(page).toHaveURL(/\/login/); }); }); test.describe('authenticated', () => { test.skip(!EMAIL || !PASSWORD, 'TEST_EMAIL / TEST_PASSWORD not set — authenticated UI checks skipped'); test('root serves new.html by default, boots with 0 console errors', async ({ page }, testInfo) => { const errs = captureErrors(page); await login(page); await page.goto(BASE + '/'); // new.html markers: hero-stat tiles + command-palette hint in the topbar. await expect(page.locator('#stat-running')).toBeAttached(); await expect(page.locator('#topbar-cmdk')).toBeAttached(); await page.waitForTimeout(1500); // let the boot JS run // WebSocket/agent-dependent noise would need a live agent — annotate // rather than fail on errors that are clearly missing-agent-shaped. const agentish = errs.filter(e => /websocket|ws:|agent|ECONNREFUSED/i.test(e)); const hard = errs.filter(e => !agentish.includes(e)); if (agentish.length) { testInfo.annotations.push({ type: 'needs-live-agent', description: agentish.join(' | ') }); } expect(hard, hard.join('\n')).toHaveLength(0); }); test('panel_ui=classic cookie serves index.html with no redirect loop', async ({ browser }) => { const ctx = await browser.newContext(); const page = await ctx.newPage(); await login(page); await ctx.addCookies([{ name: 'panel_ui', value: 'classic', url: BASE, }]); // The 2026-05-31 gotcha: FileServer canonicalization made "/" 301-loop // for authenticated classic users (ERR_TOO_MANY_REDIRECTS). A successful // goto with a 200 final response proves no loop. const resp = await page.goto(BASE + '/'); expect(resp.status()).toBe(200); // Classic UI must NOT show the new-brand hero-stat tiles. await expect(page.locator('#stat-running')).toHaveCount(0); await ctx.close(); }); test('command palette opens on Ctrl+K and via the topbar hint', async ({ page }) => { await login(page); await page.goto(BASE + '/'); const modal = page.locator('#cmdk-modal'); await expect(modal).toBeHidden(); await page.keyboard.press('Control+KeyK'); await expect(modal).toBeVisible(); await expect(page.locator('#cmdk-input')).toBeFocused(); await page.keyboard.press('Escape'); await expect(modal).toBeHidden(); await page.click('#topbar-cmdk'); await expect(modal).toBeVisible(); }); test('hero-stat element IDs present', async ({ page }) => { await login(page); await page.goto(BASE + '/'); for (const id of ['stat-running', 'stat-players', 'stat-agents', 'stat-cpu', 'stat-running-hint', 'stat-players-hint', 'stat-agents-hint', 'stat-cpu-hint', 'status-dot', 'status']) { await expect(page.locator('#' + id), `#${id} missing from new.html DOM`).toBeAttached(); } }); });