import { expect, test, type Page } from '@playwright/test'; import { inflateSync } from 'node:zlib'; 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(page: Page): Promise { await page.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 expectFlutterCanvasRendered( page: Page, timeoutMs = 5_000, ): Promise { await expect .poll(() => page.screenshot().then(screenshotHasSigninPaint), { 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/); } 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 seedAuthTheme(page: Page, theme: SigninCase['theme']): Promise { await page.addInitScript((themeValue) => { window.localStorage.setItem('userfront_auth_theme', themeValue); window.localStorage.setItem('flutter.userfront_auth_theme', themeValue); }, theme); } test.describe('UserFront signin runtime matrix', () => { test.beforeEach(async ({ page }) => { await mockPublicApis(page); }); 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, }); }); test('mobile signin paints final UI within 2 seconds with 0.5s captures', async ({ page, }, testInfo) => { test.skip( !testInfo.project.name.includes('mobile'), 'mobile loading budget is verified on the mobile project', ); await seedAuthTheme(page, 'light'); await page.goto('/ko/signin', { waitUntil: 'domcontentloaded' }); let paintedAtMs: number | null = null; let previousElapsedMs = 0; for (const elapsedMs of [500, 1000, 1500, 2000]) { await page.waitForTimeout(elapsedMs - previousElapsedMs); previousElapsedMs = elapsedMs; const screenshot = await page.screenshot({ path: testInfo.outputPath(`mobile-ko-signin-${elapsedMs}ms.png`), fullPage: true, }); if (paintedAtMs === null && screenshotHasSigninPaint(screenshot)) { paintedAtMs = elapsedMs; } } expect(paintedAtMs).not.toBeNull(); expect(paintedAtMs ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual(2_000); console.log(`[userfront-e2e] mobile signin painted at ${paintedAtMs}ms`); }); for (const entry of signinCases) { test(`${entry.path} renders in ${entry.theme} theme`, async ({ page }) => { await seedAuthTheme(page, entry.theme); await page.goto(entry.path); 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 seedAuthTheme(page, entry.theme); 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'); } }); });