#!/usr/bin/env node /** * Interactive mobile flows — drives the real app at a phone viewport and screenshots each state. * node qa/flows.mjs [--width=390] [--model=anthropic/claude-haiku-4-5-20251001] [--send] * Requires qa/.e2e-session.json (run responsive-qa.mjs once) and a connected provider for --send. */ import fs from "node:fs"; import path from "node:path"; import { chromium } from "@playwright/test"; const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, "").split("=")).map(([k, v]) => [k, v ?? "1"])); const BASE = args.base ?? "http://localhost:3000"; const W = Number(args.width ?? 390); const H = W <= 375 ? 812 : W <= 390 ? 844 : W <= 393 ? 852 : 932; const MODEL = args.model ?? "anthropic/claude-haiku-4-5-20251001"; const OUT = path.resolve(`qa/out/flows-${W}`); fs.mkdirSync(OUT, { recursive: true }); const browser = await chromium.launch(); const context = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, storageState: "qa/.e2e-session.json" }); const page = await context.newPage(); const errors = []; page.on("pageerror", (e) => errors.push(e.message.slice(0, 200))); page.on("console", (m) => m.type() === "error" && !/favicon|DevTools|hydrat/i.test(m.text()) && errors.push(m.text().slice(0, 200))); let n = 0; const shot = async (name) => { n++; await page.waitForTimeout(350); await page.screenshot({ path: path.join(OUT, `${String(n).padStart(2, "0")}-${name}.png`) }); const audit = await page.evaluate(() => { const vw = window.innerWidth; const past = []; for (const el of document.querySelectorAll("body *")) { const r = el.getBoundingClientRect(); if (r.width > 0 && r.right > vw + 1 && r.left < vw && !el.closest(".snap-row, .marquee, [data-allow-overflow]")) past.push(el.tagName + ":" + (el.getAttribute("aria-label") || el.textContent?.trim().slice(0, 30))); } return { docOverflow: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) - vw, past: past.slice(0, 6) }; }); console.log(`${String(n).padStart(2, "0")} ${name.padEnd(28)} ${audit.docOverflow > 1 ? `OVERFLOW +${audit.docOverflow}` : ""} ${audit.past.length ? "past edge: " + audit.past.join(" | ") : ""}`); }; const step = async (name, fn) => { try { await fn(); } catch (e) { console.log(` ! ${name}: ${e.message.split("\n")[0].slice(0, 160)}`); } await shot(name); }; // 1. Chat empty state await page.goto(`${BASE}/app/chat`, { waitUntil: "networkidle" }); await shot("chat-empty"); // 2. Drawer via hamburger await step("drawer-open", async () => { await page.getByRole("button", { name: /open sidebar/i }).first().click(); await page.waitForTimeout(400); }); await step("drawer-closed", async () => { await page.keyboard.press("Escape"); }); // 3. Model picker sheet await step("model-picker", async () => { await page.getByRole("button", { name: /select model/i }).first().click(); await page.waitForTimeout(500); }); await step("model-picker-search", async () => { await page.getByLabel(/search models/i).fill("cheap vision"); await page.waitForTimeout(400); }); await step("model-picked", async () => { await page.getByLabel(/search models/i).fill(""); const row = page.getByText(new RegExp(MODEL.split("/")[1].replace(/[-.]/g, "[-. ]?").replace(/claude-?/i, "claude"), "i")).first(); await row.click({ timeout: 4000 }).catch(async () => { await page.getByText(/haiku/i).first().click(); }); await page.waitForTimeout(400); }); // 4. Composer: plus sheet, typing (keyboard), expand await step("composer-plus-sheet", async () => { await page.getByRole("button", { name: /add attachment or option/i }).click(); await page.waitForTimeout(400); }); await step("composer-plus-closed", async () => { await page.keyboard.press("Escape"); }); await step("composer-typing", async () => { const ta = page.getByLabel("Message").first(); await ta.click(); await ta.fill("Reply with exactly the word: pong\n\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7"); await page.waitForTimeout(300); }); await step("composer-short", async () => { await page.getByLabel("Message").first().fill("Reply with exactly the word: pong"); }); // 5. Send (real request) when --send if (args.send) { await step("streaming", async () => { await page.getByRole("button", { name: /^send$/i }).click(); await page.waitForTimeout(1500); }); await step("answered", async () => { await page.waitForFunction(() => !document.querySelector('[aria-label="Stop generation"]'), null, { timeout: 60_000 }); await page.waitForTimeout(600); }); await step("message-longpress", async () => { const msg = page.locator("[id^='msg_'], [id^='m_'], [data-message]").last(); const box = await msg.boundingBox(); if (!box) throw new Error("no message box"); const cdp = await context.newCDPSession(page); const x = box.x + box.width / 2, y = box.y + Math.min(box.height / 2, 60); await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] }); await page.waitForTimeout(700); await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); await page.waitForTimeout(500); }); await step("after-longpress", async () => { await page.keyboard.press("Escape"); }); await step("more-actions", async () => { await page.getByRole("button", { name: /more actions/i }).first().click(); await page.waitForTimeout(400); }); await page.keyboard.press("Escape"); } // 6. Arena: pick two models await step("arena-empty", async () => { await page.goto(`${BASE}/app/arena?models=${encodeURIComponent(MODEL)},anthropic/claude-sonnet-4-5-20250929`, { waitUntil: "networkidle" }); }); if (args.send) { await step("arena-run", async () => { await page.getByLabel("Message").first().fill("Reply with one short sentence about the sea."); await page.getByRole("button", { name: /^send$|^run$|compare/i }).first().click(); await page.waitForTimeout(2500); }); await step("arena-done", async () => { await page.waitForFunction(() => !document.querySelector('[aria-label="Stop generation"], [aria-label="Stop"]'), null, { timeout: 90_000 }); await page.waitForTimeout(800); }); await step("arena-swipe", async () => { const row = page.locator(".snap-row").first(); await row.evaluate((el) => el.scrollTo({ left: el.clientWidth, behavior: "instant" })); await page.waitForTimeout(500); }); } // 7. Search sheet + palette await step("search-sheet", async () => { await page.goto(`${BASE}/app/chat`, { waitUntil: "networkidle" }); await page.getByRole("button", { name: /open sidebar/i }).first().click(); await page.waitForTimeout(300); await page.getByRole("button", { name: /search conversations/i }).first().click(); await page.waitForTimeout(500); await page.getByPlaceholder(/search chats/i).fill("pong"); await page.waitForTimeout(700); }); await step("palette", async () => { await page.keyboard.press("Escape"); await page.waitForTimeout(300); await page.keyboard.press("Escape"); await page.keyboard.press("Meta+k"); await page.waitForTimeout(500); }); // 8. Settings / providers sheet await step("providers", async () => { await page.keyboard.press("Escape"); await page.goto(`${BASE}/app/settings/providers`, { waitUntil: "networkidle" }); }); await step("add-key-sheet", async () => { await page.getByRole("button", { name: /add key|connect/i }).first().click(); await page.waitForTimeout(500); }); // 9. Dark mode home await step("home-dark", async () => { await page.keyboard.press("Escape"); await page.emulateMedia({ colorScheme: "dark" }); await page.goto(`${BASE}/`, { waitUntil: "networkidle" }); }); await step("home-dark-scrolled", async () => { await page.evaluate(() => window.scrollTo(0, 1800)); await page.waitForTimeout(700); }); console.log(`\nerrors: ${errors.length}`); for (const e of errors.slice(0, 8)) console.log(" ", e); await browser.close();