import { readFileSync, writeFileSync } from "node:fs"; import { inflateSync } from "node:zlib"; import { type BrowserContext, expect, type Page, type TestInfo, test, } from "@playwright/test"; const lightweightTestFont = readFileSync( new URL("../fixtures/fonts/NotoSansKR-TestSubset.woff2", import.meta.url), ); type SigninCase = { path: "/ko/signin" | "/en/signin"; theme: "light" | "dark"; }; const signinCases: SigninCase[] = [ { path: "/ko/signin", theme: "light" }, { path: "/ko/signin", theme: "dark" }, { path: "/en/signin", theme: "light" }, { path: "/en/signin", theme: "dark" }, ]; async function mockPublicApis(context: BrowserContext): Promise { await context.route(/\/api\/v1\//, async (route) => { const requestUrl = new URL(route.request().url()); if (requestUrl.pathname.endsWith("/api/v1/user/me")) { await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: "unauthorized" }), }); return; } if (requestUrl.pathname.endsWith("/api/v1/auth/tenant-info")) { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({}), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }), }); }); } async function routeLightweightTestFonts( context: BrowserContext, ): Promise { await context.route("https://fonts.gstatic.com/**", async (route) => { await route.fulfill({ status: 200, contentType: "font/woff2", body: lightweightTestFont, headers: { "access-control-allow-origin": "*", "cache-control": "public, max-age=31536000, immutable", }, }); }); } async function expectFlutterCanvasRendered( page: Page, timeoutMs = 10_000, ): Promise { await expect(page.locator("#baron-bootstrap-shell")).toBeHidden({ timeout: timeoutMs, }); await expect .poll( async () => { const screenshot = await captureFlutterCanvasPng(page); return screenshot === null ? false : screenshotHasSigninPaint(screenshot); }, { timeout: timeoutMs, }, ) .toBe(true); } async function expectBootstrapShellVisible(page: Page): Promise { const shell = page.locator("#baron-bootstrap-shell"); await expect(shell).toBeVisible({ timeout: 1_000 }); await expect(shell).toContainText(/Baron SW Portal/); } async function expectSigninSurfaceWithinBudget( page: Page, testInfo: TestInfo, entry: SigninCase, ): Promise { await seedAuthState(page, entry); await page.goto(entry.path, { waitUntil: "domcontentloaded" }); const slug = `${entry.path.slice(1).replace("/", "-")}-${entry.theme}`; let paintedAtMs: number | null = null; let previousElapsedMs = 0; for (const elapsedMs of [500, 1000]) { await page.waitForTimeout(elapsedMs - previousElapsedMs); previousElapsedMs = elapsedMs; const screenshot = await captureFlutterCanvasPng( page, testInfo.outputPath( `${testInfo.project.name}-${slug}-${elapsedMs}ms.png`, ), ); if ( paintedAtMs === null && screenshot !== null && screenshotHasSigninPaint(screenshot) ) { paintedAtMs = elapsedMs; } } expect(paintedAtMs).not.toBeNull(); expect(paintedAtMs ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(1_000); console.log( `[userfront-e2e] ${testInfo.project.name} ${entry.path} ${entry.theme} signin surface painted at ${paintedAtMs}ms`, ); } async function captureFlutterCanvasPng( page: Page, path?: string, ): Promise { const dataUrl = await page.evaluate(() => { const canvas = Array.from(document.querySelectorAll("canvas")) .filter((candidate) => candidate.width > 0 && candidate.height > 0) .sort((left, right) => { return right.width * right.height - left.width * left.height; })[0]; if (!canvas) { return null; } try { return canvas.toDataURL("image/png"); } catch { return null; } }); if (dataUrl?.startsWith("data:image/png;base64,")) { const screenshot = Buffer.from( dataUrl.slice("data:image/png;base64,".length), "base64", ); if (path) { writeFileSync(path, screenshot); } return screenshot; } try { return await page.screenshot({ path, fullPage: true, timeout: 5_000, }); } catch { return null; } } function screenshotHasSigninPaint(buffer: Buffer): boolean { const image = decodePng(buffer); let sampled = 0; let nonWhite = 0; let dark = 0; let buttonBlue = 0; for (let y = 0; y < image.height; y += 8) { for (let x = 0; x < image.width; x += 8) { const offset = (y * image.width + x) * 4; const red = image.pixels[offset]; const green = image.pixels[offset + 1]; const blue = image.pixels[offset + 2]; const alpha = image.pixels[offset + 3]; if (alpha < 16) { continue; } sampled += 1; if (red < 245 || green < 245 || blue < 245) { nonWhite += 1; } if (red < 60 && green < 80 && blue < 110) { dark += 1; } if (red < 80 && green < 120 && blue > 130) { buttonBlue += 1; } } } return ( sampled > 0 && nonWhite / sampled > 0.02 && dark > 12 && buttonBlue > 12 ); } function decodePng(buffer: Buffer): { width: number; height: number; pixels: Uint8Array; } { const signature = buffer.subarray(0, 8).toString("hex"); if (signature !== "89504e470d0a1a0a") { throw new Error("invalid png signature"); } let offset = 8; let width = 0; let height = 0; let colorType = 0; const idat: Buffer[] = []; while (offset < buffer.length) { const length = buffer.readUInt32BE(offset); const type = buffer.subarray(offset + 4, offset + 8).toString("ascii"); const data = buffer.subarray(offset + 8, offset + 8 + length); offset += 12 + length; if (type === "IHDR") { width = data.readUInt32BE(0); height = data.readUInt32BE(4); colorType = data[9]; } else if (type === "IDAT") { idat.push(data); } else if (type === "IEND") { break; } } if (!width || !height || ![2, 6].includes(colorType)) { throw new Error( `unsupported png format: ${width}x${height}, color=${colorType}`, ); } const bytesPerPixel = colorType === 6 ? 4 : 3; const stride = width * bytesPerPixel; const inflated = inflateSync(Buffer.concat(idat)); const raw = new Uint8Array(height * stride); let sourceOffset = 0; let targetOffset = 0; for (let y = 0; y < height; y += 1) { const filter = inflated[sourceOffset]; sourceOffset += 1; for (let x = 0; x < stride; x += 1) { const value = inflated[sourceOffset + x]; const left = x >= bytesPerPixel ? raw[targetOffset + x - bytesPerPixel] : 0; const up = y > 0 ? raw[targetOffset + x - stride] : 0; const upLeft = y > 0 && x >= bytesPerPixel ? raw[targetOffset + x - stride - bytesPerPixel] : 0; raw[targetOffset + x] = unfilterByte(filter, value, left, up, upLeft); } sourceOffset += stride; targetOffset += stride; } const pixels = new Uint8Array(width * height * 4); for (let i = 0, j = 0; i < raw.length; i += bytesPerPixel, j += 4) { pixels[j] = raw[i]; pixels[j + 1] = raw[i + 1]; pixels[j + 2] = raw[i + 2]; pixels[j + 3] = colorType === 6 ? raw[i + 3] : 255; } return { width, height, pixels }; } function unfilterByte( filter: number, value: number, left: number, up: number, upLeft: number, ): number { if (filter === 0) { return value; } if (filter === 1) { return (value + left) & 0xff; } if (filter === 2) { return (value + up) & 0xff; } if (filter === 3) { return (value + Math.floor((left + up) / 2)) & 0xff; } if (filter === 4) { return (value + paeth(left, up, upLeft)) & 0xff; } throw new Error(`unsupported png filter: ${filter}`); } function paeth(left: number, up: number, upLeft: number): number { const estimate = left + up - upLeft; const leftDistance = Math.abs(estimate - left); const upDistance = Math.abs(estimate - up); const upLeftDistance = Math.abs(estimate - upLeft); if (leftDistance <= upDistance && leftDistance <= upLeftDistance) { return left; } if (upDistance <= upLeftDistance) { return up; } return upLeft; } async function seedAuthState(page: Page, entry: SigninCase): Promise { const localeCode = entry.path.slice(1, 3); await page.addInitScript( ({ themeValue, localeValue }) => { window.localStorage.setItem("userfront_auth_theme", themeValue); window.localStorage.setItem("flutter.userfront_auth_theme", themeValue); window.localStorage.setItem("locale", localeValue); window.localStorage.setItem("flutter.locale", localeValue); }, { themeValue: entry.theme, localeValue: localeCode }, ); } test.describe("UserFront signin runtime matrix", () => { test.beforeEach(async ({ context }) => { await mockPublicApis(context); await routeLightweightTestFonts(context); }); test("first paint exposes bootstrap shell before Flutter renders", async ({ page, }, testInfo) => { await page.goto("/ko/signin", { waitUntil: "domcontentloaded" }); await expectBootstrapShellVisible(page); await page.screenshot({ path: testInfo.outputPath("mobile-first-paint-ko.png"), fullPage: true, }); }); for (const entry of signinCases) { test(`${entry.path} ${entry.theme} paints sign-in surface within 1 second with 0.5s captures`, async ({ page, }, testInfo) => { await expectSigninSurfaceWithinBudget(page, testInfo, entry); }); } for (const entry of signinCases) { test(`${entry.path} renders in ${entry.theme} theme`, async ({ page, }, testInfo) => { test.skip( testInfo.project.name === "webkit-desktop" && entry.path === "/en/signin", "WebKit headless keeps /en/signin canvas blank after load; Chromium covers English rendering.", ); await seedAuthState(page, entry); await page.goto(entry.path, { waitUntil: "domcontentloaded" }); await expect(page).toHaveURL(new RegExp(`${entry.path}(?:\\?.*)?$`)); await expectFlutterCanvasRendered(page); }); } test("signin uses configured BACKEND_URL for public API requests", async ({ page, }) => { const expectedBackendOrigin = process.env.EXPECTED_BACKEND_ORIGIN; test.skip(!expectedBackendOrigin, "set EXPECTED_BACKEND_ORIGIN"); const requestedApiOrigins = new Set(); page.on("request", (request) => { const requestUrl = new URL(request.url()); if (requestUrl.pathname.startsWith("/api/v1/")) { requestedApiOrigins.add(requestUrl.origin); } }); for (const entry of signinCases) { await seedAuthState(page, entry); await page.goto(entry.path); await expectFlutterCanvasRendered(page); await expect .poll(() => [...requestedApiOrigins], { timeout: 30_000 }) .toContain(expectedBackendOrigin); expect(requestedApiOrigins).not.toContain("https://sso.example.test"); } }); test("Korean signin renders with test-only lightweight web font", async ({ context, page, }, testInfo) => { if (testInfo.project.name === "webkit-desktop") { await routeLightweightTestFonts(context); } const requestedUrls: string[] = []; page.on("request", (request) => { requestedUrls.push(request.url()); }); await seedAuthState(page, { path: "/ko/signin", theme: "light" }); await page.goto("/ko/signin", { waitUntil: "domcontentloaded" }); await expectFlutterCanvasRendered(page, 10_000); await page.screenshot({ path: testInfo.outputPath( `${testInfo.project.name}-ko-signin-korean-font.png`, ), fullPage: true, }); expect(requestedUrls).toContainEqual( expect.stringContaining("https://fonts.gstatic.com/"), ); expect(requestedUrls).not.toContainEqual( expect.stringContaining("/assets/assets/fonts/NotoSansKR-Regular.ttf"), ); }); });