diff --git a/userfront-e2e/tests/runtime-env-mobile.spec.ts b/userfront-e2e/tests/runtime-env-mobile.spec.ts index 2c97913f..8b0620a8 100644 --- a/userfront-e2e/tests/runtime-env-mobile.spec.ts +++ b/userfront-e2e/tests/runtime-env-mobile.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type Page } from '@playwright/test'; +import { inflateSync } from 'node:zlib'; type SigninCase = { path: '/ko/signin' | '/en/signin'; @@ -41,12 +42,166 @@ async function mockPublicApis(page: Page): Promise { }); } -async function expectFlutterCanvasRendered(page: Page): Promise { - const canvas = page.locator('canvas').first(); - await expect(canvas).toBeVisible({ timeout: 30_000 }); - const box = await canvas.boundingBox(); - expect(box?.width ?? 0).toBeGreaterThan(100); - expect(box?.height ?? 0).toBeGreaterThan(100); +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 { @@ -61,6 +216,47 @@ test.describe('UserFront signin runtime matrix', () => { 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); diff --git a/userfront/web/index.html b/userfront/web/index.html index aa787a0a..5ed4d06e 100644 --- a/userfront/web/index.html +++ b/userfront/web/index.html @@ -31,8 +31,88 @@ Baron 로그인 + +
+

Baron SW Portal

+ +

Loading sign-in

+
+