// click-to-open: how long from the click on the launcher until the chat can be used, on a throttled phone-class setup. // bun run --cwd tools/widget-lab click-to-open // Cold: fresh profile, the page loads, the launcher shows, 15 s of quiet, then Lighthouse's mobile throttling (slow 4G + // 4x CPU, DevTools throttling) and the click. Warm: the same profile, a new tab on the same page (the widget's files // are in the HTTP cache now), same routine. "Usable" is the widget's first screen being ready: the message box, or the // button that leads to it (widgets.ts, ready). Timed from the mouse click until that element is visible, polled every // ~25 ms. Site isolation is off so cross-site iframes run in the throttled page's process too. import type { BrowserContext, Page } from "playwright-core"; import { chrome, env, find, median, RUNS, save, throttle, THROTTLE } from "./lab"; import { serve, url } from "./serve"; import { WIDGETS, type Widget } from "./widgets"; const ARGS = ["--disable-site-isolation-trials", "--disable-features=IsolateOrigins,site-per-process"]; const only = process.env.ONLY?.split(","); const SETTLE = Number(process.env.SETTLE ?? 15_000); // after the launcher shows: every widget has booted fully by then async function open(ctx: BrowserContext, page: Page, w: Widget, target: string) { 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) { if (await find(page, w.ready)) return { error: "already open after load" }; // it remembered the last page's open chat await page.waitForTimeout(250); launcher = await find(page, w.launcher); } if (!launcher) return { error: "no launcher in 30 s" }; await page.waitForTimeout(SETTLE); if (await find(page, w.ready)) return { error: "already open after load" }; const box = await launcher.boundingBox({ timeout: 5000 }).catch(() => null) ?? (await (await find(page, w.launcher))?.boundingBox({ timeout: 5000 })); if (!box) return { error: "launcher kept moving" }; const cdp = await throttle(ctx, page); let bytes = 0; let requests = 0; const count = (r: import("playwright-core").Request) => void r.sizes().then((s) => ((bytes += s.responseHeadersSize + s.responseBodySize), requests++), () => {}); ctx.on("requestfinished", count); const t0 = performance.now(); await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); let ms: number | null = null; while (performance.now() - t0 < 30_000) { if (await find(page, w.ready)) { ms = Math.round(performance.now() - t0); break; } await new Promise((r) => setTimeout(r, 25)); } ctx.off("requestfinished", count); await cdp.send("Emulation.setCPUThrottlingRate", { rate: 1 }); await cdp.send("Network.emulateNetworkConditions", { offline: false, latency: 0, downloadThroughput: -1, uploadThroughput: -1 }); await cdp.detach(); // close it again the way it opened, so the next tab starts from a closed chat await page.waitForTimeout(1000); const closer = w.close ? await find(page, w.close) : null; if (closer) await closer.click().catch(() => {}); else await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2); await page.waitForTimeout(2000); if (ms === null) await page.screenshot({ path: new URL(`out/click-${w.id}-${Date.now()}.png`, import.meta.url).pathname }); return ms === null ? { error: "not usable within 30 s" } : { ms, requests, kb: Math.round(bytes / 102.4) / 10 }; } 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) : w.home; if (!target) continue; type Result = Awaited> | { error: string }; const runs: { at: string; cold: Result; warm: Result }[] = []; for (let i = 0; i < RUNS; i++) { const { ctx, page, close } = await chrome({ args: ARGS }); const at = new Date().toISOString(); const cold = await open(ctx, page, w, target).catch((e) => ({ error: String(e).slice(0, 160) })); const tab = await ctx.newPage(); const warm = await open(ctx, tab, w, target).catch((e) => ({ error: String(e).slice(0, 160) })); await close(); runs.push({ at, cold, warm }); console.log(w.id, i + 1, JSON.stringify(cold), JSON.stringify(warm)); } const ms = (k: "cold" | "warm") => median(runs.map((r) => ("ms" in r[k] ? r[k].ms! : NaN))); const kb = (k: "cold" | "warm") => median(runs.map((r) => ("kb" in r[k] ? r[k].kb! : NaN))); results[w.id] = { widget: w.name, where: w.boots ? "test page" : "vendor page", url: target, ready: w.ready, summary: { coldMs: ms("cold"), warmMs: ms("warm"), coldKB: kb("cold"), warmKB: kb("warm"), okRuns: runs.filter((r) => "ms" in r.cold).length }, runs, }; } server.stop(true); save("click-to-open", { test: "click to usable chat", started, finished: new Date().toISOString(), runs: RUNS, throttling: THROTTLE, env: await env(), method: [ "Headed Google Chrome, 1280x800, a fresh profile per run, site isolation off (--disable-site-isolation-trials, --disable-features=IsolateOrigins,site-per-process) so iframes from other sites share the page's process and its throttling.", "Cold: load the page, wait for the launcher, wait 15 s more (every widget has finished booting by then), switch on DevTools throttling with Lighthouse's mobile values (562.5 ms request latency, 1.47 Mbps down, 675 kbps up, 4x CPU slowdown), click the middle of the launcher with the mouse, poll every ~25 ms until the widget's first usable element is visible (widgets.ts: ready). Warm: after the cold run the chat is closed with a second click on the same spot, then a new tab in the same profile, same steps, with the widget's files in the HTTP cache. A widget that reopens its chat by itself in the new tab is recorded as such, without a time.", "Spookat, Tawk.to and Tidio on our test page (Spookat: the production launcher and panel from cdn.spookat.com; its socket goes to the lab's local mock, which answers at once). Crisp, Intercom, LiveChat and Chatwoot refuse our domain, so they were clicked on the vendor's own page, with that vendor's own configuration.", "kb and requests: what finished loading between the click and the widget being usable. Medians over the runs; runs with an error are listed and left out of the median.", ], results, });