import { expect, test, type Locator, type Page, type Route } from '@playwright/test'; import { inflateSync } from 'node:zlib'; type ThemeCase = { name: 'light' | 'dark'; }; const themeCases: ThemeCase[] = [ { name: 'light' }, { name: 'dark' }, ]; type Rgb = { r: number; g: number; b: number; }; async function mockSignupApis(page: Page): Promise { await page.route('**/api/v1/**', async (route: Route) => { const request = route.request(); const requestUrl = new URL(request.url()); const path = requestUrl.pathname; const method = request.method().toUpperCase(); if (path.endsWith('/api/v1/user/me')) { await route.fulfill({ status: 401, contentType: 'application/json', body: JSON.stringify({ error: 'unauthorized' }), }); return; } if (path.endsWith('/api/v1/auth/password/policy')) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ minLength: 12, minCharacterTypes: 3, lowercase: true, uppercase: true, number: true, nonAlphanumeric: true, }), }); return; } if (path.endsWith('/api/v1/auth/signup/check-email') && method === 'POST') { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ available: true }), }); return; } if ( (path.endsWith('/api/v1/auth/signup/send-email-code') || path.endsWith('/api/v1/auth/signup/send-sms-code')) && method === 'POST' ) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }), }); return; } if (path.endsWith('/api/v1/auth/signup/verify-code') && method === 'POST') { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, isAffiliate: false }), }); return; } if (path.endsWith('/api/v1/auth/signup') && method === 'POST') { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }), }); return; } if (path.endsWith('/api/v1/auth/tenant-info')) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}), }); return; } if (path.endsWith('/api/v1/client-log')) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }), }); return; } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({}), }); }); } async function enableFlutterAccessibility(page: Page): Promise { await page.waitForTimeout(300); const button = page.getByRole('button', { name: 'Enable accessibility' }); const placeholder = page.locator('flt-semantics-placeholder').first(); await button.click({ force: true, timeout: 1_000 }).catch(async () => { await placeholder.click({ force: true, timeout: 1_000 }).catch(async () => { await placeholder.evaluate((node) => { (node as HTMLElement).click(); }); }); }); await page.waitForTimeout(400); } async function typeIntoField(page: Page, locator: Locator, value: string): Promise { await locator.scrollIntoViewIfNeeded(); await page.waitForTimeout(100); await locator.evaluate((node, nextValue) => { if ( node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement ) { node.focus(); node.value = ''; node.dispatchEvent(new Event('input', { bubbles: true })); node.value = nextValue; node.dispatchEvent(new Event('input', { bubbles: true })); node.dispatchEvent(new Event('change', { bubbles: true })); } }, value).catch(() => {}); const box = await locator.boundingBox(); if (!box) { throw new Error('Field locator is not visible for typing.'); } await page.locator('flt-glass-pane').click({ position: { x: box.x + box.width / 2, y: box.y + box.height / 2, }, force: true, }); await page.waitForTimeout(100); await page.keyboard.press('Control+A'); await page.keyboard.press('Backspace'); await page.keyboard.type(value); await page.waitForTimeout(150); } async function sampleViewportColor( page: Page, x: number, y: number, radius = 2, ): Promise { const buffer = await page.screenshot(); const image = decodePng(buffer); const clampedX = Math.max(0, Math.min(image.width - 1, Math.round(x))); const clampedY = Math.max(0, Math.min(image.height - 1, Math.round(y))); return sampleAverageColor(image, clampedX, clampedY, radius); } 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 idatChunks: 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') { idatChunks.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(idatChunks)); 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; } function sampleAverageColor( image: { width: number; height: number; pixels: Uint8Array }, x: number, y: number, radius = 2, ): Rgb { const xStart = Math.max(0, Math.min(image.width - 1, x - radius)); const xEnd = Math.max(0, Math.min(image.width - 1, x + radius)); const yStart = Math.max(0, Math.min(image.height - 1, y - radius)); const yEnd = Math.max(0, Math.min(image.height - 1, y + radius)); let totalR = 0; let totalG = 0; let totalB = 0; let count = 0; for (let sampleY = yStart; sampleY <= yEnd; sampleY += 1) { for (let sampleX = xStart; sampleX <= xEnd; sampleX += 1) { const offset = (sampleY * image.width + sampleX) * 4; const alpha = image.pixels[offset + 3]; if (alpha < 16) { continue; } totalR += image.pixels[offset]; totalG += image.pixels[offset + 1]; totalB += image.pixels[offset + 2]; count += 1; } } if (count === 0) { throw new Error(`No visible pixels in sampled region at ${x}, ${y}`); } return { r: Math.round(totalR / count), g: Math.round(totalG / count), b: Math.round(totalB / count), }; } function brightness(rgb: Rgb): number { return (rgb.r + rgb.g + rgb.b) / 3; } async function sampleLocatorColor(page: Page, locator: Locator, radius = 2): Promise { const box = await locator.boundingBox(); if (!box) { throw new Error('Target locator is not visible for color sampling.'); } return sampleViewportColor(page, box.x + box.width / 2, box.y + box.height / 2, radius); } async function sampleCheckboxColor(page: Page, locator: Locator): Promise { const box = await locator.boundingBox(); if (!box) { throw new Error('Checkbox locator is not visible for color sampling.'); } const x = box.x + Math.min(18, Math.max(12, box.width * 0.08)); const y = box.y + box.height / 2; return sampleViewportColor(page, x, y, 0); } async function sampleButtonColor(page: Page, locator: Locator): Promise { const box = await locator.boundingBox(); if (!box) { throw new Error('Button locator is not visible for color sampling.'); } const x = box.x + box.width * 0.2; const y = box.y + box.height / 2; return sampleViewportColor(page, x, y, 1); } async function sampleButtonBackground(page: Page, locator: Locator): Promise { const box = await locator.boundingBox(); if (!box) { throw new Error('Button locator is not visible for background sampling.'); } const x = box.x + box.width / 2; const y = Math.max(0, box.y - 14); return sampleViewportColor(page, x, y, 2); } async function expectBrightnessContrast( sample: () => Promise<{ foreground: Rgb; background: Rgb }>, minimumDelta: number, ): Promise { await expect .poll(async () => { const { foreground, background } = await sample(); return Math.abs(brightness(foreground) - brightness(background)); }, { timeout: 10_000 }) .toBeGreaterThanOrEqual(minimumDelta); } async function expectButtonContrast(page: Page, locator: Locator): Promise { await expectBrightnessContrast(async () => { return { foreground: await sampleButtonColor(page, locator), background: await sampleButtonBackground(page, locator), }; }, 45); } async function sampleCheckboxBackground(page: Page, locator: Locator): Promise { const box = await locator.boundingBox(); if (!box) { throw new Error('Checkbox locator is not visible for background sampling.'); } const x = box.x + Math.min(42, Math.max(30, box.width * 0.18)); const y = box.y + box.height / 2; return sampleViewportColor(page, x, y, 1); } async function expectCheckboxContrast(page: Page, locator: Locator): Promise { await expectBrightnessContrast(async () => { return { foreground: await sampleCheckboxColor(page, locator), background: await sampleCheckboxBackground(page, locator), }; }, 40); } test.describe('UserFront signup theme visibility', () => { for (const theme of themeCases) { test(`signup keeps ${theme.name} theme colors visible across steps`, async ({ page, }) => { await mockSignupApis(page); if (theme.name === 'dark') { await page.goto('/ko/signin', { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1200); await enableFlutterAccessibility(page); const themeToggle = page.getByRole('button', { name: /Light|Dark|테마 전환|Theme toggle/i, }); await themeToggle.click({ force: true }); await page.waitForTimeout(500); } await page.goto('/ko/signup', { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1200); await enableFlutterAccessibility(page); const allAgreementCheckbox = page.getByRole('checkbox', { name: /모두 동의합니다|Agree to all/i, }); await expect(allAgreementCheckbox).toBeVisible(); await allAgreementCheckbox.click({ force: true }); await expect(allAgreementCheckbox).toBeChecked(); const nextButton = page.getByRole('button', { name: /다음 단계|Next/i }); await expect(nextButton).toBeVisible(); await expect(nextButton).toBeEnabled(); await nextButton.click({ force: true }); await expect( page.getByText(/본인 확인을 위해|Verify your email and phone number/i), ).toBeVisible(); const emailInput = page.getByRole('textbox', { name: /이메일 주소|Email address/i, }); const phoneInput = page.getByRole('textbox', { name: /휴대폰 번호|Phone number/i, }); const requestButtons = page .getByRole('button') .filter({ hasText: /인증요청|재발송|Send code|Resend/i }); await expect(emailInput).toBeVisible(); await expect(phoneInput).toBeVisible(); await expect(requestButtons.nth(0)).toBeVisible(); await expect(requestButtons.nth(1)).toBeVisible(); await expect(nextButton).toBeVisible(); }); } });