/** * QA flows (Playwright): live feed → event drawer → company → timeline filter → compare → watchlist add, plus ⌘K search * and the admin token gate. Mobile (390) and desktop (1440). Fails on console errors. * Run: node qa/flows.mjs [BASE_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:8370'; const OUT = new URL('./screens/', import.meta.url).pathname; mkdirSync(OUT, { recursive: true }); const browser = await chromium.launch(); let failures = 0; const step = (ok, msg) => { if (!ok) failures++; console.log(`${ok ? 'OK ' : 'FAIL'} ${msg}`); }; for (const width of [390, 1440]) { const mobile = width < 768; const ctx = await browser.newContext({ viewport: { width, height: mobile ? 844 : 900 }, isMobile: mobile, hasTouch: mobile, colorScheme: 'dark' }); const page = await ctx.newPage(); const errors = []; page.on('pageerror', (e) => errors.push(String(e))); page.on('console', (m) => m.type() === 'error' && !/favicon/.test(m.text()) && errors.push(m.text())); const shot = (n) => page.screenshot({ path: `${OUT}flow-${width}-${n}.png` }).catch(() => undefined); console.log(`\n— flows @ ${width} —`); // 1. live feed → event drawer await page.goto(`${BASE}/live`, { waitUntil: 'networkidle' }); const rows = await page.locator('[data-live-feed] [data-event-id]').count(); step(rows > 0, `live feed renders ${rows} rows`); await page.locator('[data-live-feed] [data-open-event]').first().click(); await page.waitForSelector('[data-event-drawer]', { timeout: 5000 }).catch(() => undefined); const drawer = await page.locator('[data-event-drawer]').count(); step(drawer === 1, 'event drawer opens with evidence'); const hasSources = await page.locator('[data-event-drawer] a[href^="http"]').count(); step(hasSources > 0, `drawer links to ${hasSources} source URL(s)`); await shot('1-drawer'); // 2. drawer → company page const companyLink = page.locator('[data-event-drawer] a[href^="/company/"]').first(); const companyHref = await companyLink.getAttribute('href'); await companyLink.click(); await page.waitForURL(/\/company\//, { timeout: 15000 }); await page.waitForLoadState('networkidle'); step(!!companyHref && page.url().includes(companyHref), `navigated to company ${companyHref}`); step((await page.locator('[data-metric-tiles]').count()) === 1, 'metric tiles rendered'); step((await page.locator('[data-density-strip]').count()) === 1, 'data-density strip rendered'); await shot('2-company'); // 3. timeline tab + filter await page.getByRole('tab', { name: 'Timeline' }).click(); await page.waitForURL(/tab=timeline/, { timeout: 15000 }); await page.waitForLoadState('networkidle'); await page.getByRole('tab', { name: 'Jobs', exact: true }).nth(0).click().catch(() => undefined); const jobsFilter = page.locator('[role="tablist"][aria-label="Timeline filter"] a', { hasText: 'Jobs' }); await jobsFilter.click(); await page.waitForURL(/filter=jobs/, { timeout: 15000 }); await page.waitForLoadState('networkidle'); const badges = await page.locator('[role="tabpanel"] [data-event-type]').evaluateAll((els) => [...new Set(els.map((e) => e.getAttribute('data-event-type')))]); step(badges.every((b) => b === 'hiring') , `timeline filter=jobs shows only hiring events (${badges.join(',') || 'none'})`); await shot('3-timeline'); // 4. compare await page.goto(`${BASE}/company/compare?companies=stripe,adyen`, { waitUntil: 'networkidle' }); await page.locator('[data-compare-input]').fill('block'); await page.waitForSelector('[data-compare-picker] [role="option"]', { timeout: 5000 }); await page.locator('[data-compare-picker] [role="option"]').first().click(); await page.waitForURL(/companies=stripe,adyen,block/, { timeout: 15000 }); await page.waitForLoadState('networkidle'); step((await page.locator('table thead th').count()) >= 4, 'compare table has three company columns'); await shot('4-compare'); // 5. watchlist add await page.goto(`${BASE}/company/stripe`, { waitUntil: 'networkidle' }); const watch = page.locator('[data-watch="stripe"]'); await watch.click(); await page.waitForTimeout(800); step((await watch.getAttribute('aria-pressed')) === 'true', 'watch button toggles to Watching'); await page.goto(`${BASE}/watchlist`, { waitUntil: 'networkidle' }); await page.waitForTimeout(800); step((await page.locator('[data-watchlist] a[href="/company/stripe"]').count()) > 0, 'watchlist page lists Stripe'); await page.locator('[data-alert-form] input[aria-label="Rule name"]').fill('QA pricing rule'); await page.locator('[data-alert-form] button[type="submit"]').click(); await page.waitForTimeout(800); step((await page.getByText('QA pricing rule').count()) > 0, 'alert rule created'); await shot('5-watchlist'); // 6. ⌘K search await page.goto(`${BASE}/`, { waitUntil: 'networkidle' }); await page.keyboard.press(process.platform === 'darwin' ? 'Meta+k' : 'Control+k'); await page.waitForSelector('[data-palette-input]', { timeout: 5000 }); await page.locator('[data-palette-input]').fill('stri'); await page.waitForSelector('[data-palette-row]', { timeout: 5000 }); await page.keyboard.press('Enter'); await page.waitForURL(/\/company\/stripe|\/search/, { timeout: 15000 }); step(/\/company\/stripe|\/search/.test(page.url()), `⌘K search navigates (${new URL(page.url()).pathname})`); await shot('6-search'); // 7. admin gate await page.goto(`${BASE}/admin`, { waitUntil: 'networkidle' }); await page.locator('[data-admin-token]').fill('dev-admin-token'); await page.locator('[data-admin-token]').press('Enter'); await page.waitForSelector('h1:has-text("Overview")', { timeout: 10000 }).catch(() => undefined); await page.waitForLoadState('networkidle'); step((await page.locator('h1:has-text("Overview")').count()) === 1, 'admin overview renders after token'); await page.goto(`${BASE}/admin/sensors?filter=failing`, { waitUntil: 'networkidle' }); await page.waitForTimeout(800); step((await page.locator('table tbody tr').count()) > 0, 'admin sensors table renders'); await shot('7-admin'); step(errors.length === 0, `no console errors (${errors.length})${errors.length ? ' :: ' + errors[0].slice(0, 160) : ''}`); await ctx.close(); } await browser.close(); console.log(failures ? `\n${failures} failure(s)` : '\nall flows OK'); process.exit(failures ? 1 : 0);