import { expect, test, type BrowserContext, type Page, type TestInfo, } from '@playwright/test'; import { readFileSync, writeFileSync } from 'node:fs'; import { inflateSync } from 'node:zlib'; 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'), ); }); });