diff --git a/userfront-e2e/pnpm-lock.yaml b/userfront-e2e/pnpm-lock.yaml new file mode 100644 index 00000000..198583a6 --- /dev/null +++ b/userfront-e2e/pnpm-lock.yaml @@ -0,0 +1,77 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: ^1.58.2 + version: 1.60.0 + '@types/node': + specifier: ^24.3.0 + version: 24.12.4 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + +packages: + + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} + engines: {node: '>=18'} + hasBin: true + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} + engines: {node: '>=18'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + +snapshots: + + '@playwright/test@1.60.0': + dependencies: + playwright: 1.60.0 + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + fsevents@2.3.2: + optional: true + + playwright-core@1.60.0: {} + + playwright@1.60.0: + dependencies: + playwright-core: 1.60.0 + optionalDependencies: + fsevents: 2.3.2 + + typescript@5.9.3: {} + + undici-types@7.16.0: {} diff --git a/userfront-e2e/tests/signup-theme-visibility.spec.ts b/userfront-e2e/tests/signup-theme-visibility.spec.ts new file mode 100644 index 00000000..6acb3a83 --- /dev/null +++ b/userfront-e2e/tests/signup-theme-visibility.spec.ts @@ -0,0 +1,465 @@ +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 { + const button = page.getByRole('button', { name: 'Enable accessibility' }); + if (await button.count()) { + await button.first().click({ force: true }).catch(async () => { + await page.locator('flt-semantics-placeholder').first().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.check({ force: true }); + + 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(); + }); + } +});