SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
7.9 KB · 194 lines javascript
Raw Blame History
1#!/usr/bin/env node2/**3 * Interactive mobile flows — drives the real app at a phone viewport and screenshots each state.4 *   node qa/flows.mjs [--width=390] [--model=anthropic/claude-haiku-4-5-20251001] [--send]5 * Requires qa/.e2e-session.json (run responsive-qa.mjs once) and a connected provider for --send.6 */7import fs from "node:fs";8import path from "node:path";9import { chromium } from "@playwright/test";1011const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, "").split("=")).map(([k, v]) => [k, v ?? "1"]));12const BASE = args.base ?? "http://localhost:3000";13const W = Number(args.width ?? 390);14const H = W <= 375 ? 812 : W <= 390 ? 844 : W <= 393 ? 852 : 932;15const MODEL = args.model ?? "anthropic/claude-haiku-4-5-20251001";16const OUT = path.resolve(`qa/out/flows-${W}`);17fs.mkdirSync(OUT, { recursive: true });1819const browser = await chromium.launch();20const context = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 2, isMobile: true, hasTouch: true, storageState: "qa/.e2e-session.json" });21const page = await context.newPage();22const errors = [];23page.on("pageerror", (e) => errors.push(e.message.slice(0, 200)));24page.on("console", (m) => m.type() === "error" && !/favicon|DevTools|hydrat/i.test(m.text()) && errors.push(m.text().slice(0, 200)));2526let n = 0;27const shot = async (name) => {28  n++;29  await page.waitForTimeout(350);30  await page.screenshot({ path: path.join(OUT, `${String(n).padStart(2, "0")}-${name}.png`) });31  const audit = await page.evaluate(() => {32    const vw = window.innerWidth;33    const past = [];34    for (const el of document.querySelectorAll("body *")) {35      const r = el.getBoundingClientRect();36      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)));37    }38    return { docOverflow: Math.max(document.documentElement.scrollWidth, document.body.scrollWidth) - vw, past: past.slice(0, 6) };39  });40  console.log(`${String(n).padStart(2, "0")} ${name.padEnd(28)} ${audit.docOverflow > 1 ? `OVERFLOW +${audit.docOverflow}` : ""} ${audit.past.length ? "past edge: " + audit.past.join(" | ") : ""}`);41};42const step = async (name, fn) => {43  try {44    await fn();45  } catch (e) {46    console.log(`   ! ${name}: ${e.message.split("\n")[0].slice(0, 160)}`);47  }48  await shot(name);49};5051// 1. Chat empty state52await page.goto(`${BASE}/app/chat`, { waitUntil: "networkidle" });53await shot("chat-empty");5455// 2. Drawer via hamburger56await step("drawer-open", async () => {57  await page.getByRole("button", { name: /open sidebar/i }).first().click();58  await page.waitForTimeout(400);59});60await step("drawer-closed", async () => {61  await page.keyboard.press("Escape");62});6364// 3. Model picker sheet65await step("model-picker", async () => {66  await page.getByRole("button", { name: /select model/i }).first().click();67  await page.waitForTimeout(500);68});69await step("model-picker-search", async () => {70  await page.getByLabel(/search models/i).fill("cheap vision");71  await page.waitForTimeout(400);72});73await step("model-picked", async () => {74  await page.getByLabel(/search models/i).fill("");75  const row = page.getByText(new RegExp(MODEL.split("/")[1].replace(/[-.]/g, "[-. ]?").replace(/claude-?/i, "claude"), "i")).first();76  await row.click({ timeout: 4000 }).catch(async () => {77    await page.getByText(/haiku/i).first().click();78  });79  await page.waitForTimeout(400);80});8182// 4. Composer: plus sheet, typing (keyboard), expand83await step("composer-plus-sheet", async () => {84  await page.getByRole("button", { name: /add attachment or option/i }).click();85  await page.waitForTimeout(400);86});87await step("composer-plus-closed", async () => {88  await page.keyboard.press("Escape");89});90await step("composer-typing", async () => {91  const ta = page.getByLabel("Message").first();92  await ta.click();93  await ta.fill("Reply with exactly the word: pong\n\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7");94  await page.waitForTimeout(300);95});96await step("composer-short", async () => {97  await page.getByLabel("Message").first().fill("Reply with exactly the word: pong");98});99100// 5. Send (real request) when --send101if (args.send) {102  await step("streaming", async () => {103    await page.getByRole("button", { name: /^send$/i }).click();104    await page.waitForTimeout(1500);105  });106  await step("answered", async () => {107    await page.waitForFunction(() => !document.querySelector('[aria-label="Stop generation"]'), null, { timeout: 60_000 });108    await page.waitForTimeout(600);109  });110  await step("message-longpress", async () => {111    const msg = page.locator("[id^='msg_'], [id^='m_'], [data-message]").last();112    const box = await msg.boundingBox();113    if (!box) throw new Error("no message box");114    const cdp = await context.newCDPSession(page);115    const x = box.x + box.width / 2, y = box.y + Math.min(box.height / 2, 60);116    await cdp.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x, y }] });117    await page.waitForTimeout(700);118    await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });119    await page.waitForTimeout(500);120  });121  await step("after-longpress", async () => {122    await page.keyboard.press("Escape");123  });124  await step("more-actions", async () => {125    await page.getByRole("button", { name: /more actions/i }).first().click();126    await page.waitForTimeout(400);127  });128  await page.keyboard.press("Escape");129}130131// 6. Arena: pick two models132await step("arena-empty", async () => {133  await page.goto(`${BASE}/app/arena?models=${encodeURIComponent(MODEL)},anthropic/claude-sonnet-4-5-20250929`, { waitUntil: "networkidle" });134});135if (args.send) {136  await step("arena-run", async () => {137    await page.getByLabel("Message").first().fill("Reply with one short sentence about the sea.");138    await page.getByRole("button", { name: /^send$|^run$|compare/i }).first().click();139    await page.waitForTimeout(2500);140  });141  await step("arena-done", async () => {142    await page.waitForFunction(() => !document.querySelector('[aria-label="Stop generation"], [aria-label="Stop"]'), null, { timeout: 90_000 });143    await page.waitForTimeout(800);144  });145  await step("arena-swipe", async () => {146    const row = page.locator(".snap-row").first();147    await row.evaluate((el) => el.scrollTo({ left: el.clientWidth, behavior: "instant" }));148    await page.waitForTimeout(500);149  });150}151152// 7. Search sheet + palette153await step("search-sheet", async () => {154  await page.goto(`${BASE}/app/chat`, { waitUntil: "networkidle" });155  await page.getByRole("button", { name: /open sidebar/i }).first().click();156  await page.waitForTimeout(300);157  await page.getByRole("button", { name: /search conversations/i }).first().click();158  await page.waitForTimeout(500);159  await page.getByPlaceholder(/search chats/i).fill("pong");160  await page.waitForTimeout(700);161});162await step("palette", async () => {163  await page.keyboard.press("Escape");164  await page.waitForTimeout(300);165  await page.keyboard.press("Escape");166  await page.keyboard.press("Meta+k");167  await page.waitForTimeout(500);168});169170// 8. Settings / providers sheet171await step("providers", async () => {172  await page.keyboard.press("Escape");173  await page.goto(`${BASE}/app/settings/providers`, { waitUntil: "networkidle" });174});175await step("add-key-sheet", async () => {176  await page.getByRole("button", { name: /add key|connect/i }).first().click();177  await page.waitForTimeout(500);178});179180// 9. Dark mode home181await step("home-dark", async () => {182  await page.keyboard.press("Escape");183  await page.emulateMedia({ colorScheme: "dark" });184  await page.goto(`${BASE}/`, { waitUntil: "networkidle" });185});186await step("home-dark-scrolled", async () => {187  await page.evaluate(() => window.scrollTo(0, 1800));188  await page.waitForTimeout(700);189});190191console.log(`\nerrors: ${errors.length}`);192for (const e of errors.slice(0, 8)) console.log("  ", e);193await browser.close();194