/** * Shell flows (D0): command palette (⌘K → `m:claude` → select), More menu (keyboard), density toggle (persisted * `data-density`), evidence drawer on a model page (first `[data-evidence]`), watchlist add → /watchlist feed, * favicon render at 16 / 32 px, OG + icon routes. Screenshots → qa/screens/shell/. * Run: node qa/shell.mjs [BASE_URL] [API_URL] */ import { chromium } from '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs'; import { mkdirSync } from 'node:fs'; const BASE = process.argv[2] ?? 'http://localhost:8330'; const API = process.argv[3] ?? 'http://127.0.0.1:8331'; const OUT = new URL('./screens/shell/', import.meta.url).pathname; mkdirSync(OUT, { recursive: true }); const MOD = process.platform === 'darwin' ? 'Meta' : 'Control'; let failures = 0; const check = (ok, label, extra = '') => { if (!ok) failures++; console.log(`${ok ? 'OK ' : 'FAIL'} ${label}${extra ? ' :: ' + extra : ''}`); }; const firstModel = await fetch(`${API}/api/v1/models?limit=1`) .then((r) => r.json()) .then((j) => j.items?.[0]?.slug) .catch(() => null); check(!!firstModel, `first model slug from API (${firstModel})`); const browser = await chromium.launch(); const errors = []; const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, colorScheme: 'dark' }); await ctx.addInitScript(() => localStorage.setItem('aia-theme', 'dark')); const page = await ctx.newPage(); page.on('pageerror', (e) => errors.push(String(e))); // The 1.1 provenance endpoint may 404 until the API stream lands — the drawer falls back by design, so that resource error is expected. page.on('console', (m) => m.type() === 'error' && !/favicon|Failed to load resource: the server responded with a status of 404/.test(m.text()) && errors.push(m.text())); // ---- 1. palette: ⌘K, type m:claude, arrow down, Enter → a model page await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }); await page.keyboard.press(`${MOD}+k`); await page.waitForSelector('[data-palette-input]', { timeout: 5000 }); check(await page.isVisible('[data-palette]'), 'palette opens with ⌘K'); const cmdRows = await page.locator('[data-palette-row="command"]').count(); check(cmdRows >= 4, `palette shows commands when empty (${cmdRows})`); await page.keyboard.type('m:claude'); await page.waitForSelector('[data-palette-row="entity"]', { timeout: 8000 }).catch(() => undefined); const entRows = await page.locator('[data-palette-row="entity"]').count(); const prefixChip = await page.locator('[data-palette] .mono:has-text("Models")').count(); check(entRows > 0 && prefixChip > 0, `m: prefix filters to models (${entRows} rows, chip=${prefixChip})`); const nonModel = await page.locator('[data-palette-row="entity"] span.uppercase').evaluateAll((els) => els.map((e) => e.textContent.trim()).filter((t) => t !== 'Model').length); check(nonModel === 0, 'all palette rows are models'); await page.screenshot({ path: `${OUT}palette-m-claude.png` }); await page.keyboard.press('ArrowDown'); await page.keyboard.press('Enter'); await page.waitForURL(/\/models\//, { timeout: 15000 }).catch(() => undefined); check(/\/models\//.test(page.url()), `Enter navigates to a model page (${new URL(page.url()).pathname})`); const recent = await page.evaluate(() => JSON.parse(localStorage.getItem('aia-recent') || '[]').length); check(recent >= 1, `recent entities stored (${recent})`); // commands mode: ">" then Toggle density await page.waitForLoadState('networkidle'); await page.keyboard.press(`${MOD}+k`); await page.waitForSelector('[data-palette-input]'); await page.focus('[data-palette-input]'); await page.keyboard.type('>dens'); await page.waitForSelector('[data-palette-row="command"][aria-selected="true"]:has-text("density")', { timeout: 5000 }).catch(() => undefined); const cmdOnly = (await page.locator('[data-palette-row="entity"]').count()) === 0 && (await page.locator('[data-palette-row="command"]').count()) >= 1; check(cmdOnly, '">" shows commands only'); await page.keyboard.press('Enter'); await page.waitForTimeout(200); const dens1 = await page.evaluate(() => document.documentElement.getAttribute('data-density')); check(dens1 === 'compact', `"Toggle density" command → data-density=${dens1}`); // ---- 2. More menu (mouse + keyboard) await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }); await page.click('[data-more-button]'); await page.waitForSelector('[data-more-menu]'); const items = await page.locator('[data-more-menu] [role="menuitem"]').count(); check(items >= 18, `More menu lists the full site (${items} items)`); const menuBox = await page.locator('[data-more-menu]').boundingBox(); check(menuBox && menuBox.x >= 0 && menuBox.x + menuBox.width <= 1440, `More menu inside viewport (x=${Math.round(menuBox?.x)}, w=${Math.round(menuBox?.width)})`); const focused = await page.evaluate(() => document.activeElement?.getAttribute('role')); check(focused === 'menuitem', 'first menu item focused on open'); await page.keyboard.press('ArrowDown'); const second = await page.evaluate(() => document.activeElement?.textContent?.trim().split('\n')[0]); check(!!second, `ArrowDown moves focus (${second})`); await page.screenshot({ path: `${OUT}more-menu.png`, clip: { x: 0, y: 0, width: 1440, height: 460 } }); await page.keyboard.press('Escape'); check((await page.locator('[data-more-menu]').count()) === 0, 'Escape closes the More menu'); // ---- 3. density toggle in header, persisted across reload const before = await page.evaluate(() => document.documentElement.getAttribute('data-density')); await page.click('[data-density-toggle]'); const after = await page.evaluate(() => document.documentElement.getAttribute('data-density')); check(before !== after, `density toggle changes html[data-density] (${before} → ${after})`); const padBefore = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--d-cell-y').trim()); await page.reload({ waitUntil: 'networkidle' }); const persisted = await page.evaluate(() => document.documentElement.getAttribute('data-density')); check(persisted === after, `density persisted before paint after reload (${persisted})`); await page.screenshot({ path: `${OUT}density-${after ?? 'comfortable'}.png` }); await page.evaluate(() => localStorage.removeItem('aia-density')); check(!!padBefore, `density CSS variable readable (--d-cell-y=${padBefore})`); // ---- 4. evidence drawer on a model page if (firstModel) { await page.goto(`${BASE}/models/${firstModel}`, { waitUntil: 'networkidle' }); const triggers = await page.locator('[data-evidence]').count(); check(triggers > 0, `model page has evidence triggers (${triggers})`); if (triggers) { await page.locator('[data-evidence]').first().click(); await page.waitForSelector('[data-evidence-drawer]', { timeout: 5000 }); await page.waitForTimeout(1200); const txt = await page.locator('[data-evidence-drawer]').innerText(); check(/Source/.test(txt) && /Tier/.test(txt) && /Observed/.test(txt) && /Extractor/.test(txt), 'drawer shows Source · Tier · Observed · Extractor'); check(/View history/.test(txt), 'drawer links to the property history'); check(/#evidence=/.test(page.url()), `URL hash mirrors the drawer (${new URL(page.url()).hash.slice(0, 40)})`); await page.screenshot({ path: `${OUT}evidence-drawer.png` }); await page.keyboard.press('Escape'); await page.waitForTimeout(200); check((await page.locator('[data-evidence-drawer]').count()) === 0, 'Escape closes the drawer'); } // ---- 5. watchlist add + page await page.click('[data-watch-button]'); await page.waitForTimeout(200); check((await page.getAttribute('[data-watch-button]', 'aria-pressed')) === 'true', 'Watch button toggles aria-pressed'); const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('aia-watchlist') || '[]')); check(stored.length === 1 && stored[0].slug === firstModel, `watchlist stored in localStorage (${stored.map((s) => s.slug).join(',')})`); await page.goto(`${BASE}/watchlist`, { waitUntil: 'networkidle' }); await page.waitForSelector('[data-watchlist-items]', { timeout: 8000 }); const listed = await page.locator('[data-watchlist-items] li').count(); check(listed === 1, `/watchlist lists the watched entity (${listed})`); await page.waitForTimeout(1500); const feedRows = await page.locator('[data-watchlist-feed] li').count(); const emptyMsg = await page.locator('text=No release, price, deprecation').count(); check(feedRows > 0 || emptyMsg > 0, `/watchlist shows events or an honest empty state (rows=${feedRows}, empty=${emptyMsg})`); await page.screenshot({ path: `${OUT}watchlist.png` }); await page.evaluate(() => localStorage.removeItem('aia-watchlist')); } // ---- 6. favicon crispness at 16 / 32 px + brand routes const brand = await ctx.newPage(); await brand.setViewportSize({ width: 360, height: 100 }); await brand.setContent(``); await brand.waitForTimeout(600); await brand.screenshot({ path: `${OUT}favicon-16-32.png` }); for (const p of ['/icon.svg', '/apple-icon', '/icon-192.png', '/icon-512.png', '/opengraph-image', '/manifest.webmanifest', '/logo.svg', '/logo-lockup.svg', '/logo-lockup-light.svg']) { const r = await fetch(BASE + p); check(r.ok, `${p} → ${r.status} ${r.headers.get('content-type')}`); } await brand.close(); check(errors.length === 0, `no console/page errors during flows (${errors.length})`, errors[0]?.slice(0, 160)); await browser.close(); console.log(failures ? `\n${failures} failure(s)` : '\nall shell flows OK'); process.exit(failures ? 1 : 0);