// phone: what each widget covers on a 375x812 phone screen, how big its tap target is, what opening it does, and what // happens when the keyboard takes the bottom of the screen. // bun run --cwd tools/widget-lab phone (screenshots of the first run: tools/widget-lab/out/phone/) // Chrome's mobile emulation (375x812, touch, device scale 3, Lighthouse's mobile user agent), a fresh profile per run. // Our test page gets a sticky "add to cart" bar, like most shops have on a phone. A real keyboard can't pop in a lab // browser: we shrink the viewport by 336 px (an iPhone's portrait keyboard with its suggestion bar) the way browsers // that resize the page for the keyboard do, and check the message box is still on screen. import { mkdirSync } from "node:fs"; import type { Page } from "playwright-core"; import { chrome, env, find, median, RUNS, save } from "./lab"; import { serve, url } from "./serve"; import { WIDGETS, type Widget } from "./widgets"; const W = 375; const H = 812; const KEYBOARD = 336; const SHOTS = new URL("out/phone/", import.meta.url).pathname; mkdirSync(SHOTS, { recursive: true }); const only = process.env.ONLY?.split(","); const WIDGETISH = /tawk|crisp|intercom|tidio|chat[- ]widget|chatwoot|woot|livechat|spookat/i; type Rect = { x: number; y: number; width: number; height: number }; /** everything of the widget that is visible in the page: its root elements and its iframes */ async function footprint(page: Page, w: Widget): Promise { return page.evaluate(([root, re]) => { const rx = new RegExp(re, "i"); const els = [...document.querySelectorAll(root), ...[...document.querySelectorAll("iframe")].filter((f) => rx.test(`${f.id} ${f.name} ${f.title} ${f.src}`))]; // a shadow host (Spookat's #spookat) has no box of its own: its shadow children do const boxes = (e: Element): DOMRect[] => [e.getBoundingClientRect(), ...[...(e.shadowRoot?.children ?? [])].flatMap(boxes)]; return els .flatMap(boxes) .filter((b) => b.width > 1 && b.height > 1 && b.bottom > 0 && b.right > 0 && b.top < innerHeight && b.left < innerWidth) .map((b) => ({ x: b.x, y: b.y, width: b.width, height: b.height })); }, [w.root, WIDGETISH.source] as const); } /** share of the viewport under any of the rects, sampled on a 3 px grid */ function covered(rects: Rect[], vw = W, vh = H) { let hit = 0; let all = 0; for (let x = 1; x < vw; x += 3) for (let y = 1; y < vh; y += 3) { all++; if (rects.some((r) => x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height)) hit++; } return Math.round((hit / all) * 1000) / 10; } const overlaps = (a: Rect, b: Rect) => Math.max(0, Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x)) * Math.max(0, Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y)); async function once(w: Widget, target: string, shots: boolean) { const { ctx, page, close } = await chrome({ viewport: { width: W, height: H }, mobile: true }); try { await page.goto(target, { waitUntil: "load", timeout: 60_000 }); const t = Date.now(); let launcher = await find(page, w.launcher); while (!launcher && Date.now() - t < 30_000) (await page.waitForTimeout(250), (launcher = await find(page, w.launcher))); if (!launcher) return { error: "no launcher in 30 s" }; await page.waitForTimeout(5000); const box = (await launcher.boundingBox())!; const before = [...(await footprint(page, w)), box]; // the launcher counts even when its root has no box of its own (Crisp) const bar = w.boots ? await page.locator(".bar button").boundingBox() : null; // can a thumb still hit "add to cart" in its middle, or does the widget sit on top of it? const barTappable = bar ? await page.evaluate(([x, y]) => document.elementFromPoint(x, y)?.closest(".bar") !== null, [bar.x + bar.width / 2, bar.y + bar.height / 2] as const) : null; if (shots) await page.screenshot({ path: `${SHOTS}${w.id}-1-closed.png`, scale: "css" }); await page.touchscreen.tap(box.x + box.width / 2, box.y + box.height / 2); let ready = null; for (const t1 = Date.now(); !ready && Date.now() - t1 < 20_000; ) (await page.waitForTimeout(250), (ready = await find(page, w.ready))); await page.waitForTimeout(1500); const after = await footprint(page, w); const input = ready && (await ready.evaluate((e) => ["INPUT", "TEXTAREA"].includes(e.tagName) || (e as HTMLElement).isContentEditable)); const fontPx = input ? await ready!.evaluate((e) => parseFloat(getComputedStyle(e).fontSize)) : null; if (shots) await page.screenshot({ path: `${SHOTS}${w.id}-2-open.png`, scale: "css" }); let keyboard = null; if (input) { await ready!.focus(); await page.setViewportSize({ width: W, height: H - KEYBOARD }); await page.waitForTimeout(1500); const r = await ready!.boundingBox(); keyboard = { inputTop: r && Math.round(r.y), inputBottom: r && Math.round(r.y + r.height), visible: !!r && r.y >= 0 && r.y + r.height <= H - KEYBOARD }; if (shots) await page.screenshot({ path: `${SHOTS}${w.id}-3-keyboard.png`, scale: "css" }); } return { launcher: { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height), right: Math.round(W - box.x - box.width), bottom: Math.round(H - box.y - box.height) }, closedCoversPct: covered(before), closedRects: before.map((r) => ({ x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) })), barOverlapPx: bar ? Math.round(before.reduce((a, r) => a + overlaps(r, bar), 0)) : null, barTappable, opened: !!ready, openCoversPct: covered(after), inputFontPx: fontPx, keyboard, }; } finally { await close(); } } const server = serve(); const started = new Date().toISOString(); const results: Record = {}; for (const w of WIDGETS) { if (only && !only.includes(w.id)) continue; const target = w.boots ? url(w.id, "&bar=1") : w.home; if (!target) continue; const runs = []; for (let i = 0; i < RUNS; i++) { const r = await once(w, target, i === 0).catch((e) => ({ error: String(e).slice(0, 160) })); runs.push({ at: new Date().toISOString(), ...r }); console.log(w.id, i + 1, JSON.stringify(r).slice(0, 300)); } const ok = runs.filter((r) => "launcher" in r) as Extract<(typeof runs)[number], { launcher: unknown }>[]; results[w.id] = { widget: w.name, where: w.boots ? "test page with a sticky add-to-cart bar" : "vendor page", url: target, summary: { launcherW: median(ok.map((r) => r.launcher.width)), launcherH: median(ok.map((r) => r.launcher.height)), rightGap: median(ok.map((r) => r.launcher.right)), bottomGap: median(ok.map((r) => r.launcher.bottom)), closedCoversPct: median(ok.map((r) => r.closedCoversPct)), openCoversPct: median(ok.map((r) => r.openCoversPct)), barTappableRuns: ok.filter((r) => r.barTappable).length, openedRuns: ok.filter((r) => r.opened).length, inputFontPx: median(ok.map((r) => r.inputFontPx ?? NaN)), inputVisibleWithKeyboardRuns: ok.filter((r) => r.keyboard?.visible).length, okRuns: ok.length, }, runs, }; } server.stop(true); save("phone", { test: "chat widgets on a phone", started, finished: new Date().toISOString(), runs: RUNS, viewport: { width: W, height: H, deviceScaleFactor: 3, keyboardPx: KEYBOARD }, env: await env(), method: [ "Headed Google Chrome with mobile emulation: 375x812 CSS px, device scale 3, touch, Lighthouse's mobile user agent, a fresh profile per run.", "Spookat, Tawk.to and Tidio on our test page with a sticky add-to-cart bar at the bottom; Crisp, Intercom, LiveChat and Chatwoot refuse our domain, so on the vendor's own page with its own configuration.", "Closed: 5 s after the launcher shows we measure the launcher's box, everything of the widget that is visible (its root elements and iframes), the share of the screen it covers (3 px grid), and whether the middle of the add-to-cart button is still the button. Then a tap on the launcher; open: the share of the screen the widget covers and the font size of the message box (iOS Safari zooms into inputs under 16 px).", "Keyboard: with the message box focused, the viewport loses 336 px of height (a portrait iPhone keyboard with its suggestion bar), as browsers that resize the page for the keyboard do; we check the message box is still inside the visible area. A real device keyboard can differ.", "Screenshots of the first run: tools/widget-lab/out/phone/.", ], results, });