첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
3
baron-sso/userfront-e2e/.gitignore
vendored
Normal file
3
baron-sso/userfront-e2e/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
playwright-report/
|
||||
test-results/
|
||||
29
baron-sso/userfront-e2e/README.md
Normal file
29
baron-sso/userfront-e2e/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# UserFront WASM E2E
|
||||
|
||||
`userfront` WASM 빌드 산출물을 Playwright로 검증하는 테스트 워크스페이스입니다.
|
||||
|
||||
## 실행 방법
|
||||
|
||||
1. 의존성 설치
|
||||
```bash
|
||||
cd userfront-e2e
|
||||
npm install
|
||||
```
|
||||
|
||||
2. 테스트 실행(빌드 포함)
|
||||
```bash
|
||||
cd userfront-e2e
|
||||
npm run test:wasm
|
||||
```
|
||||
|
||||
3. 이미 빌드가 있을 때 테스트만 실행
|
||||
```bash
|
||||
cd userfront-e2e
|
||||
npm test
|
||||
```
|
||||
|
||||
## 환경변수
|
||||
|
||||
- `BASE_URL`: 외부 배포 URL을 테스트할 때 사용합니다. 설정하면 로컬 정적 서버를 띄우지 않습니다.
|
||||
- `PORT`: 로컬 정적 서버 포트 (기본 `4173`)
|
||||
- `LOCALE`: 브라우저 locale (기본 `ko-KR`)
|
||||
3
baron-sso/userfront-e2e/biome.json
Normal file
3
baron-sso/userfront-e2e/biome.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["../common/config/biome.base.json"]
|
||||
}
|
||||
Binary file not shown.
290
baron-sso/userfront-e2e/package-lock.json
generated
Normal file
290
baron-sso/userfront-e2e/package-lock.json
generated
Normal file
@@ -0,0 +1,290 @@
|
||||
{
|
||||
"name": "userfront-e2e",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "userfront-e2e",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.16.tgz",
|
||||
"integrity": "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
"biome": "bin/biome"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.4.16",
|
||||
"@biomejs/cli-darwin-x64": "2.4.16",
|
||||
"@biomejs/cli-linux-arm64": "2.4.16",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.16",
|
||||
"@biomejs/cli-linux-x64": "2.4.16",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.16",
|
||||
"@biomejs/cli-win32-arm64": "2.4.16",
|
||||
"@biomejs/cli-win32-x64": "2.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.16.tgz",
|
||||
"integrity": "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.16.tgz",
|
||||
"integrity": "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.16.tgz",
|
||||
"integrity": "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.16.tgz",
|
||||
"integrity": "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.16.tgz",
|
||||
"integrity": "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.16.tgz",
|
||||
"integrity": "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.16.tgz",
|
||||
"integrity": "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.4.16",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.16.tgz",
|
||||
"integrity": "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.10.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz",
|
||||
"integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
25
baron-sso/userfront-e2e/package.json
Normal file
25
baron-sso/userfront-e2e/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "userfront-e2e",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"install:browsers": "playwright install firefox",
|
||||
"test": "npm run install:browsers && playwright test",
|
||||
"test:ui": "npm run install:browsers && playwright test --ui",
|
||||
"serve:build": "node ./scripts/serve-userfront-build.mjs",
|
||||
"build:userfront:wasm": "cd ../userfront && flutter build web --wasm --release && cd .. && node userfront/scripts/optimize-web-build.mjs userfront/build/web",
|
||||
"lint": "biome check .",
|
||||
"lint:fix": "biome check . --write",
|
||||
"test:wasm": "npm run build:userfront:wasm && npm test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.16",
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@types/node": "^24.3.0",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
70
baron-sso/userfront-e2e/playwright.config.ts
Normal file
70
baron-sso/userfront-e2e/playwright.config.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? "4173", 10);
|
||||
const defaultBaseUrl = `http://127.0.0.1:${port}`;
|
||||
const baseURL = process.env.BASE_URL ?? defaultBaseUrl;
|
||||
const reuseExistingServer = !process.env.CI;
|
||||
const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
|
||||
? Number.parseInt(process.env.PLAYWRIGHT_WORKERS, 10)
|
||||
: undefined;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: configuredWorkers ?? 1,
|
||||
reporter: process.env.CI ? [["html", { open: "never" }], ["list"]] : "html",
|
||||
use: {
|
||||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
locale: process.env.LOCALE ?? "ko-KR",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "webkit-desktop",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
serviceWorkers: "block",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "webkit-mobile-webapp",
|
||||
use: {
|
||||
...devices["iPhone 13"],
|
||||
serviceWorkers: "block",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-desktop",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
serviceWorkers: "block",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "firefox-desktop",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
serviceWorkers: "block",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "chromium-mobile-webapp",
|
||||
use: {
|
||||
...devices["Pixel 7"],
|
||||
serviceWorkers: "block",
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: process.env.BASE_URL
|
||||
? undefined
|
||||
: {
|
||||
command: "node ./scripts/serve-userfront-build.mjs",
|
||||
url: defaultBaseUrl,
|
||||
reuseExistingServer,
|
||||
timeout: 120_000,
|
||||
},
|
||||
});
|
||||
77
baron-sso/userfront-e2e/pnpm-lock.yaml
generated
Normal file
77
baron-sso/userfront-e2e/pnpm-lock.yaml
generated
Normal file
@@ -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: {}
|
||||
148
baron-sso/userfront-e2e/scripts/serve-userfront-build.mjs
Normal file
148
baron-sso/userfront-e2e/scripts/serve-userfront-build.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { createServer } from "node:http";
|
||||
import { dirname, extname, join, normalize } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const root = normalize(join(__dirname, "../../userfront/build/web"));
|
||||
|
||||
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
||||
console.error(
|
||||
"[userfront-e2e] userfront/build/web not found. Run: cd userfront && flutter build web --wasm --release",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const port = Number.parseInt(process.env.PORT ?? "4173", 10);
|
||||
|
||||
const contentTypes = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml; charset=utf-8",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".wasm": "application/wasm",
|
||||
".webmanifest": "application/manifest+json; charset=utf-8",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
if (pathname === "/" && url.search === "") {
|
||||
res.statusCode = 302;
|
||||
res.setHeader("Location", "/ko/signin");
|
||||
res.setHeader("Cache-Control", "no-cache, max-age=0, must-revalidate");
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
const relative = pathname === "/" ? "/index.html" : pathname;
|
||||
const candidate = normalize(join(root, relative));
|
||||
|
||||
if (!candidate.startsWith(root)) {
|
||||
res.statusCode = 403;
|
||||
res.end("Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
let filePath = candidate;
|
||||
let servesAppShellFallback = false;
|
||||
|
||||
if (!existsSync(filePath) || statSync(filePath).isDirectory()) {
|
||||
if (extname(pathname)) {
|
||||
res.statusCode = 404;
|
||||
res.setHeader("Cache-Control", "no-cache, max-age=0, must-revalidate");
|
||||
res.end("Not Found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Flutter web 라우팅 경로(`/ko`, `/ko/signin`)도 index.html로 fallback 처리
|
||||
filePath = join(root, "index.html");
|
||||
servesAppShellFallback = true;
|
||||
}
|
||||
|
||||
const acceptsBrotli = /\bbr\b/.test(req.headers["accept-encoding"] ?? "");
|
||||
const brotliPath = `${filePath}.br`;
|
||||
const servedPath =
|
||||
acceptsBrotli && existsSync(brotliPath) ? brotliPath : filePath;
|
||||
const ext = extname(filePath);
|
||||
const contentType = contentTypes[ext] ?? "application/octet-stream";
|
||||
const stats = statSync(servedPath);
|
||||
const etag = `"${stats.size.toString(16)}-${Math.trunc(stats.mtimeMs).toString(16)}"`;
|
||||
const cacheControl = cacheControlFor(
|
||||
pathname,
|
||||
filePath,
|
||||
servesAppShellFallback,
|
||||
);
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader("ETag", etag);
|
||||
res.setHeader("Last-Modified", stats.mtime.toUTCString());
|
||||
res.setHeader("Cache-Control", cacheControl);
|
||||
res.setHeader("Vary", "Accept-Encoding");
|
||||
|
||||
// Flutter WASM requires SharedArrayBuffer which needs these COOP/COEP headers
|
||||
// to be cross-origin isolated in most modern browsers (WebKit, Firefox, etc.)
|
||||
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
||||
|
||||
if (servedPath === brotliPath) {
|
||||
res.setHeader("Content-Encoding", "br");
|
||||
}
|
||||
|
||||
if (req.headers["if-none-match"] === etag) {
|
||||
res.statusCode = 304;
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
createReadStream(servedPath)
|
||||
.on("error", () => {
|
||||
res.statusCode = 500;
|
||||
res.end("Internal Server Error");
|
||||
})
|
||||
.pipe(res);
|
||||
});
|
||||
|
||||
function cacheControlFor(pathname, filePath, servesAppShellFallback) {
|
||||
const basename = filePath.split("/").pop() ?? "";
|
||||
|
||||
if (
|
||||
servesAppShellFallback ||
|
||||
basename === "index.html" ||
|
||||
basename === "flutter_bootstrap.js" ||
|
||||
basename === "flutter_service_worker.js" ||
|
||||
basename === "version.json" ||
|
||||
basename === "manifest.json"
|
||||
) {
|
||||
return "no-cache, max-age=0, must-revalidate";
|
||||
}
|
||||
|
||||
if (/^\/canvaskit\/.*\.(?:js|wasm)$/i.test(pathname)) {
|
||||
return "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
if (/^\/main\.dart\.[0-9a-f]{12}\.(?:js|mjs|wasm)$/i.test(pathname)) {
|
||||
return "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
if (/\.(?:png|ico|svg|webp|woff|woff2)$/i.test(pathname)) {
|
||||
return "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
if (/\.(?:js|css|json|mjs|wasm)$/i.test(pathname)) {
|
||||
return "no-cache, max-age=0, must-revalidate";
|
||||
}
|
||||
|
||||
return "no-cache, max-age=0, must-revalidate";
|
||||
}
|
||||
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
console.log(`[userfront-e2e] serving ${root} at http://127.0.0.1:${port}`);
|
||||
});
|
||||
604
baron-sso/userfront-e2e/tests/auth-routing.spec.ts
Normal file
604
baron-sso/userfront-e2e/tests/auth-routing.spec.ts
Normal file
@@ -0,0 +1,604 @@
|
||||
import { expect, type Page, type Route, test } from "@playwright/test";
|
||||
|
||||
type MockOptions = {
|
||||
sessionStatus?: number;
|
||||
captureApprove?: (pendingRef: string | null) => void;
|
||||
captureUserMe?: () => void;
|
||||
captureVerify?: (path: string, body: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
async function seedTokenLogin(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("baron_auth_token", "e30.e30.e30");
|
||||
window.localStorage.setItem("baron_auth_provider", "ory");
|
||||
window.localStorage.removeItem("baron_auth_cookie_mode");
|
||||
window.localStorage.removeItem("baron_auth_pending_provider");
|
||||
});
|
||||
}
|
||||
|
||||
async function seedSessionTokenLogin(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.sessionStorage.setItem("baron_auth_token", "e30.e30.e30");
|
||||
window.sessionStorage.setItem("baron_auth_provider", "ory");
|
||||
window.sessionStorage.removeItem("baron_auth_cookie_mode");
|
||||
window.sessionStorage.removeItem("baron_auth_pending_provider");
|
||||
window.localStorage.removeItem("baron_auth_token");
|
||||
window.localStorage.removeItem("baron_auth_provider");
|
||||
window.localStorage.removeItem("baron_auth_cookie_mode");
|
||||
window.localStorage.removeItem("baron_auth_pending_provider");
|
||||
});
|
||||
}
|
||||
|
||||
async function mockUserfrontApis(
|
||||
page: Page,
|
||||
options: MockOptions = {},
|
||||
): Promise<void> {
|
||||
const sessionStatus = options.sessionStatus ?? 200;
|
||||
|
||||
await page.context().route("**/api/v1/**", async (route: Route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const path = requestUrl.pathname;
|
||||
|
||||
if (path.endsWith("/api/v1/user/me")) {
|
||||
options.captureUserMe?.();
|
||||
if (sessionStatus === 200) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id: "e2e-user",
|
||||
email: "e2e@example.com",
|
||||
name: "E2E User",
|
||||
phone: "+821012341234",
|
||||
department: "QA",
|
||||
affiliationType: "employee",
|
||||
companyCode: "BARON",
|
||||
tenant: {
|
||||
id: "tenant-1",
|
||||
name: "Baron",
|
||||
slug: "baron",
|
||||
description: "E2E tenant",
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: sessionStatus,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "unauthorized" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/user/rp/linked")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/audit/auth/timeline")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], next_cursor: "" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/auth/qr/approve")) {
|
||||
if (route.request().method() === "POST") {
|
||||
let pendingRef: string | null = null;
|
||||
try {
|
||||
const body = (route.request().postDataJSON() ?? {}) as {
|
||||
pendingRef?: string;
|
||||
};
|
||||
pendingRef = body.pendingRef ?? null;
|
||||
console.log(`[E2E-MOCK] /api/v1/auth/qr/approve POST body:`, body);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
`[E2E-MOCK] /api/v1/auth/qr/approve POST body parse error:`,
|
||||
e,
|
||||
);
|
||||
pendingRef = null;
|
||||
}
|
||||
options.captureApprove?.(pendingRef);
|
||||
} else {
|
||||
console.log(
|
||||
`[E2E-MOCK] /api/v1/auth/qr/approve ${route.request().method()} request`,
|
||||
);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
path.endsWith("/api/v1/auth/magic-link/verify") ||
|
||||
path.endsWith("/api/v1/auth/login/code/verify") ||
|
||||
path.endsWith("/api/v1/auth/login/code/verify-short")
|
||||
) {
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
body = (route.request().postDataJSON() ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
options.captureVerify?.(path, body);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "approved",
|
||||
pendingRef: "e2e-approved",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function collectClientFailures(page: Page): string[] {
|
||||
const failures: string[] = [];
|
||||
page.on("pageerror", (error) => {
|
||||
failures.push(error.message);
|
||||
});
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
if (
|
||||
message.type() === "error" ||
|
||||
(/exception|verify_failed|verification failed|인증 실패/i.test(text) &&
|
||||
!text.includes("Exception while loading service worker"))
|
||||
) {
|
||||
failures.push(text);
|
||||
}
|
||||
});
|
||||
return failures;
|
||||
}
|
||||
|
||||
async function expectPageToRemainBlank(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(() => {
|
||||
const url = page.url();
|
||||
return url === '' || url === 'about:blank';
|
||||
}, { timeout: 5_000 })
|
||||
.toBe(true);
|
||||
}
|
||||
|
||||
async function makeWindowCloseNavigateToRoot(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.close = () => {
|
||||
window.location.href = "/";
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||
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(500);
|
||||
}
|
||||
|
||||
test.describe("UserFront WASM auth routing", () => {
|
||||
test.describe.configure({ mode: "default" });
|
||||
|
||||
test("비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockUserfrontApis(page, { sessionStatus: 401 });
|
||||
|
||||
await page.goto("/ko");
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
});
|
||||
|
||||
test("로그인 상태 /ko 진입 후 새로고침해도 /ko/dashboard 를 유지한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedTokenLogin(page);
|
||||
await mockUserfrontApis(page);
|
||||
|
||||
await page.goto("/ko");
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
});
|
||||
|
||||
test("sessionStorage 기반 로그인 상태에서도 /ko/dashboard 를 유지한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await seedSessionTokenLogin(page);
|
||||
await mockUserfrontApis(page);
|
||||
|
||||
await page.goto("/ko");
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
});
|
||||
|
||||
test("비로그인 /ko/approve 는 signin(+notice)으로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockUserfrontApis(page, { sessionStatus: 401 });
|
||||
|
||||
await page.goto("/ko/approve?ref=e2e-ref");
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
|
||||
});
|
||||
|
||||
test("로그인 상태 /ko/approve 는 승인 API 호출 후 dashboard로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let approvedRef: string | null = null;
|
||||
|
||||
await seedTokenLogin(page);
|
||||
await mockUserfrontApis(page, {
|
||||
captureApprove: (pendingRef) => {
|
||||
approvedRef = pendingRef;
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto("/ko/approve?ref=e2e-approve-ref");
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard(?:\?.*)?$/, {
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(approvedRef).toBe("e2e-approve-ref");
|
||||
});
|
||||
|
||||
test('verifyOnly 승인 완료 화면의 상단 액션은 signin으로 복귀시킨다', async ({
|
||||
page,
|
||||
}) => {
|
||||
let userMeCalls = 0;
|
||||
const clientFailures = collectClientFailures(page);
|
||||
const verifyRequests: Array<{
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
await mockUserfrontApis(page, {
|
||||
sessionStatus: 401,
|
||||
captureUserMe: () => {
|
||||
userMeCalls += 1;
|
||||
},
|
||||
captureVerify: (path, body) => {
|
||||
verifyRequests.push({ path, body });
|
||||
},
|
||||
});
|
||||
await makeWindowCloseNavigateToRoot(page);
|
||||
|
||||
await page.goto("/ko/l/AB123456");
|
||||
|
||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||
expect(verifyRequests[0].path).toContain(
|
||||
"/api/v1/auth/login/code/verify-short",
|
||||
);
|
||||
expect(verifyRequests[0].body).toMatchObject({
|
||||
shortCode: "AB123456",
|
||||
verifyOnly: true,
|
||||
});
|
||||
|
||||
await page.locator("flt-glass-pane").click({
|
||||
position: { x: 30, y: 28 },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
expect(userMeCalls).toBe(0);
|
||||
expect(
|
||||
clientFailures.filter(
|
||||
(failure) => !failure.includes('401 (Unauthorized)'),
|
||||
),
|
||||
).toEqual([]);
|
||||
|
||||
});
|
||||
|
||||
test("verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let userMeCalls = 0;
|
||||
let verifyCalls = 0;
|
||||
const clientFailures = collectClientFailures(page);
|
||||
|
||||
await mockUserfrontApis(page, {
|
||||
sessionStatus: 401,
|
||||
captureUserMe: () => {
|
||||
userMeCalls += 1;
|
||||
},
|
||||
captureVerify: () => {
|
||||
verifyCalls += 1;
|
||||
},
|
||||
});
|
||||
await makeWindowCloseNavigateToRoot(page);
|
||||
|
||||
await page.goto("/ko/l/AB123456");
|
||||
|
||||
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
|
||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||
expect(userMeCalls).toBe(0);
|
||||
|
||||
await enableFlutterAccessibility(page);
|
||||
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
||||
|
||||
expect(userMeCalls).toBe(0);
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
expect(
|
||||
clientFailures.filter(
|
||||
(failure) => !failure.includes("401 (Unauthorized)"),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test('verifyOnly 원격 승인 완료는 로그인 창 이동 CTA와 안내 문구를 표시한다', async ({
|
||||
page,
|
||||
}) => {
|
||||
let verifyCalls = 0;
|
||||
const clientFailures = collectClientFailures(page);
|
||||
|
||||
await mockUserfrontApis(page, {
|
||||
sessionStatus: 401,
|
||||
captureVerify: () => {
|
||||
verifyCalls += 1;
|
||||
},
|
||||
});
|
||||
await makeWindowCloseNavigateToRoot(page);
|
||||
|
||||
await page.goto("/ko/l/AB123456");
|
||||
|
||||
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
|
||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||
await enableFlutterAccessibility(page);
|
||||
|
||||
await expect(page.getByText("로그인 승인 완료")).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("요청하신 로그인이 완료되었습니다"),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "창 닫기" })).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "로그인 창으로 이동하기" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
expect(clientFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test("루트/로그인에 붙은 인증 payload는 전용 verify 라우트에서만 소비하고 완료 URL을 정리한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let userMeCalls = 0;
|
||||
const verifyRequests: Array<{
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
}> = [];
|
||||
const clientFailures = collectClientFailures(page);
|
||||
|
||||
await mockUserfrontApis(page, {
|
||||
sessionStatus: 401,
|
||||
captureUserMe: () => {
|
||||
userMeCalls += 1;
|
||||
},
|
||||
captureVerify: (path, body) => {
|
||||
verifyRequests.push({ path, body });
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(
|
||||
"/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop",
|
||||
);
|
||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => page.url(), { timeout: 10_000 }).toContain(
|
||||
'/ko/verify-complete',
|
||||
);
|
||||
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
|
||||
expect(verifyRequests[0].body).toMatchObject({
|
||||
loginId: "e2e@example.com",
|
||||
code: "654321",
|
||||
pendingRef: "pending-root",
|
||||
verifyOnly: true,
|
||||
});
|
||||
expect(page.url()).not.toContain("loginId=");
|
||||
expect(page.url()).not.toContain("code=");
|
||||
expect(page.url()).not.toContain("pendingRef=");
|
||||
expect(page.url()).not.toContain("utm=");
|
||||
expect(clientFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test("로그인 페이지에 붙은 인증 payload도 전용 verify 라우트로 넘긴다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let userMeCalls = 0;
|
||||
const verifyRequests: Array<{
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
}> = [];
|
||||
const clientFailures = collectClientFailures(page);
|
||||
|
||||
await mockUserfrontApis(page, {
|
||||
sessionStatus: 401,
|
||||
captureUserMe: () => {
|
||||
userMeCalls += 1;
|
||||
},
|
||||
captureVerify: (path, body) => {
|
||||
verifyRequests.push({ path, body });
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto("/ko/signin?loginId=e2e%40example.com&code=999999");
|
||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||
await expect.poll(() => page.url(), { timeout: 10_000 }).toContain(
|
||||
'/ko/verify-complete',
|
||||
);
|
||||
expect(verifyRequests[0].body).toMatchObject({
|
||||
loginId: "e2e@example.com",
|
||||
code: "999999",
|
||||
verifyOnly: true,
|
||||
});
|
||||
expect(page.url()).not.toContain("loginId=");
|
||||
expect(page.url()).not.toContain("code=");
|
||||
expect(clientFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test("verifyOnly 승인 링크를 팝업에서 닫으면 창만 닫히고 부모는 이동하지 않는다", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name === "webkit-mobile-webapp",
|
||||
"Mobile WebKit closes the opener page when this popup flow closes in headless mode.",
|
||||
);
|
||||
let userMeCalls = 0;
|
||||
let verifyCalls = 0;
|
||||
const clientFailures = collectClientFailures(page);
|
||||
|
||||
await mockUserfrontApis(page, {
|
||||
sessionStatus: 401,
|
||||
captureUserMe: () => {
|
||||
userMeCalls += 1;
|
||||
},
|
||||
captureVerify: () => {
|
||||
verifyCalls += 1;
|
||||
},
|
||||
});
|
||||
|
||||
const baseURL = testInfo.project.use.baseURL;
|
||||
if (typeof baseURL !== "string") throw new Error("baseURL is required");
|
||||
const popupURL = new URL("/ko/l/AB123456", baseURL).toString();
|
||||
const parentURL = new URL("/version.json", baseURL).toString();
|
||||
|
||||
await page.goto(parentURL);
|
||||
await expect(page).toHaveURL(parentURL);
|
||||
|
||||
const popupPromise = page.waitForEvent("popup");
|
||||
await page.evaluate((url) => {
|
||||
window.open(url, "_blank");
|
||||
}, popupURL);
|
||||
const popup = await popupPromise;
|
||||
|
||||
await expect.poll(() => verifyCalls, { timeout: 10_000 }).toBe(1);
|
||||
await expect(popup).toHaveURL(/\/ko\/verify-complete$/);
|
||||
expect(userMeCalls).toBe(0);
|
||||
|
||||
if (!popup.isClosed()) {
|
||||
const closePromise = popup.waitForEvent("close").catch(() => undefined);
|
||||
try {
|
||||
await enableFlutterAccessibility(popup);
|
||||
await popup
|
||||
.getByRole("button", { name: "로그인 창으로 이동하기" })
|
||||
.click();
|
||||
} catch (error) {
|
||||
if (!popup.isClosed()) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await closePromise;
|
||||
}
|
||||
|
||||
expect(userMeCalls).toBe(0);
|
||||
await expect(page).toHaveURL(parentURL);
|
||||
expect(clientFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test("verifyOnly 승인 완료 버튼은 이메일 magic link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let userMeCalls = 0;
|
||||
const clientFailures = collectClientFailures(page);
|
||||
const verifyRequests: Array<{
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
await mockUserfrontApis(page, {
|
||||
sessionStatus: 401,
|
||||
captureUserMe: () => {
|
||||
userMeCalls += 1;
|
||||
},
|
||||
captureVerify: (path, body) => {
|
||||
verifyRequests.push({ path, body });
|
||||
},
|
||||
});
|
||||
await makeWindowCloseNavigateToRoot(page);
|
||||
|
||||
await page.goto("/ko/verify/e2e-email-token");
|
||||
|
||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||
expect(verifyRequests[0].path).toContain('/api/v1/auth/magic-link/verify');
|
||||
expect(verifyRequests[0].body).toMatchObject({
|
||||
token: "e2e-email-token",
|
||||
verifyOnly: true,
|
||||
});
|
||||
|
||||
await enableFlutterAccessibility(page);
|
||||
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
||||
|
||||
expect(userMeCalls).toBe(0);
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
expect(clientFailures).toEqual([]);
|
||||
});
|
||||
|
||||
test("verifyOnly 승인 완료 버튼은 이메일 code link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
let userMeCalls = 0;
|
||||
const clientFailures = collectClientFailures(page);
|
||||
const verifyRequests: Array<{
|
||||
path: string;
|
||||
body: Record<string, unknown>;
|
||||
}> = [];
|
||||
|
||||
await mockUserfrontApis(page, {
|
||||
sessionStatus: 401,
|
||||
captureUserMe: () => {
|
||||
userMeCalls += 1;
|
||||
},
|
||||
captureVerify: (path, body) => {
|
||||
verifyRequests.push({ path, body });
|
||||
},
|
||||
});
|
||||
await makeWindowCloseNavigateToRoot(page);
|
||||
|
||||
await page.goto(
|
||||
"/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-email",
|
||||
);
|
||||
|
||||
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
|
||||
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
|
||||
expect(verifyRequests[0].body).toMatchObject({
|
||||
loginId: "e2e@example.com",
|
||||
code: "654321",
|
||||
pendingRef: "pending-email",
|
||||
verifyOnly: true,
|
||||
});
|
||||
|
||||
await enableFlutterAccessibility(page);
|
||||
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
|
||||
|
||||
expect(userMeCalls).toBe(0);
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
expect(clientFailures).toEqual([]);
|
||||
});
|
||||
});
|
||||
283
baron-sso/userfront-e2e/tests/login-performance-budget.spec.ts
Normal file
283
baron-sso/userfront-e2e/tests/login-performance-budget.spec.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import {
|
||||
devices,
|
||||
expect,
|
||||
type Page,
|
||||
type Request,
|
||||
type Response,
|
||||
test,
|
||||
} from "@playwright/test";
|
||||
|
||||
type LoadMetrics = {
|
||||
appOrigin: string;
|
||||
durationMs: number;
|
||||
transferredBytes: number;
|
||||
requestedUrls: string[];
|
||||
requestedPathCounts: Map<string, number>;
|
||||
cacheControlByPath: Map<string, string>;
|
||||
contentEncodingByPath: Map<string, string>;
|
||||
};
|
||||
|
||||
async function mockPublicApis(page: Page): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function measureSigninLoad(page: Page): Promise<LoadMetrics> {
|
||||
const appOrigin = new URL(
|
||||
process.env.BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? "4173"}`,
|
||||
).origin;
|
||||
const requestedUrls: string[] = [];
|
||||
const requestedPathCounts = new Map<string, number>();
|
||||
const cacheControlByPath = new Map<string, string>();
|
||||
const contentEncodingByPath = new Map<string, string>();
|
||||
let transferredBytes = 0;
|
||||
|
||||
const onRequest = (request: Request) => {
|
||||
const requestUrl = new URL(request.url());
|
||||
requestedUrls.push(request.url());
|
||||
if (requestUrl.protocol === "http:" || requestUrl.protocol === "https:") {
|
||||
const resourceKey = `${requestUrl.origin}${requestUrl.pathname}`;
|
||||
requestedPathCounts.set(
|
||||
resourceKey,
|
||||
(requestedPathCounts.get(resourceKey) ?? 0) + 1,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onResponse = async (response: Response) => {
|
||||
const url = new URL(response.url());
|
||||
const cacheControl = response.headers()["cache-control"];
|
||||
if (cacheControl) {
|
||||
cacheControlByPath.set(url.pathname, cacheControl);
|
||||
}
|
||||
const contentEncoding = response.headers()["content-encoding"];
|
||||
if (contentEncoding) {
|
||||
contentEncodingByPath.set(url.pathname, contentEncoding);
|
||||
}
|
||||
|
||||
const timing = response.request().timing();
|
||||
if (timing.responseEnd >= 0) {
|
||||
const sizes = await response
|
||||
.request()
|
||||
.sizes()
|
||||
.catch(() => null);
|
||||
transferredBytes += sizes?.responseBodySize ?? 0;
|
||||
}
|
||||
};
|
||||
|
||||
page.on("request", onRequest);
|
||||
page.on("response", onResponse);
|
||||
|
||||
try {
|
||||
const start = performance.now();
|
||||
await page.goto("/ko/signin", { waitUntil: "networkidle" });
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
|
||||
return {
|
||||
appOrigin,
|
||||
durationMs,
|
||||
transferredBytes,
|
||||
requestedUrls,
|
||||
requestedPathCounts,
|
||||
cacheControlByPath,
|
||||
contentEncodingByPath,
|
||||
};
|
||||
} finally {
|
||||
page.off("request", onRequest);
|
||||
page.off("response", onResponse);
|
||||
}
|
||||
}
|
||||
|
||||
function expectNoDuplicateStaticRequests(metrics: LoadMetrics): void {
|
||||
const duplicates = [...metrics.requestedPathCounts.entries()].filter(
|
||||
([resourceKey, count]) => {
|
||||
const resourceUrl = new URL(resourceKey);
|
||||
const path = resourceUrl.pathname;
|
||||
return (
|
||||
count > 1 &&
|
||||
resourceUrl.origin === metrics.appOrigin &&
|
||||
!path.startsWith("/api/") &&
|
||||
!path.endsWith("/ko/signin") &&
|
||||
!path.endsWith("/") &&
|
||||
!path.endsWith("/main.dart.wasm") &&
|
||||
!path.endsWith("/main.dart.mjs") &&
|
||||
!path.endsWith("/skwasm.js") &&
|
||||
!path.endsWith("/skwasm.wasm")
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(duplicates).toEqual([]);
|
||||
}
|
||||
|
||||
function resolvePerformanceBudget(projectName: string): {
|
||||
coldMs: number;
|
||||
warmMs: number;
|
||||
} {
|
||||
if (projectName.includes("webkit")) {
|
||||
return { coldMs: 4000, warmMs: 4000 };
|
||||
}
|
||||
if (projectName.includes("firefox")) {
|
||||
return { coldMs: 2600, warmMs: 2800 };
|
||||
}
|
||||
if (projectName.includes("mobile")) {
|
||||
return { coldMs: 3000, warmMs: 2300 };
|
||||
}
|
||||
return { coldMs: 2300, warmMs: 1500 };
|
||||
}
|
||||
|
||||
function resolveRootRedirectBudget(projectName: string): number {
|
||||
if (projectName.includes("webkit")) {
|
||||
return 700;
|
||||
}
|
||||
if (projectName.includes("firefox")) {
|
||||
return 600;
|
||||
}
|
||||
return 300;
|
||||
}
|
||||
|
||||
test.describe("UserFront login performance budget", () => {
|
||||
test("mobile Chrome service worker install does not fetch unused CanvasKit variants", async ({
|
||||
browser,
|
||||
}, testInfo) => {
|
||||
test.skip(
|
||||
testInfo.project.name !== "chromium-mobile-webapp",
|
||||
"service worker install race is covered once in the mobile Chromium project",
|
||||
);
|
||||
|
||||
const context = await browser.newContext({
|
||||
...devices["Pixel 7"],
|
||||
locale: "ko-KR",
|
||||
serviceWorkers: "allow",
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await mockPublicApis(page);
|
||||
|
||||
try {
|
||||
const serviceWorkerResponse = await context.request.get(
|
||||
new URL(
|
||||
"/flutter_service_worker.js",
|
||||
process.env.BASE_URL ??
|
||||
`http://127.0.0.1:${process.env.PORT ?? "4173"}`,
|
||||
).toString(),
|
||||
);
|
||||
const serviceWorkerBody = await serviceWorkerResponse.text();
|
||||
expect(serviceWorkerBody).not.toContain('"/canvaskit/');
|
||||
expect(serviceWorkerBody).not.toContain('"/main.dart.');
|
||||
|
||||
await page.goto("/ko/signin", { waitUntil: "domcontentloaded" });
|
||||
await page.waitForTimeout(3_000);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("warm login page load stays within the platform budget and reuses cached assets", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockPublicApis(page);
|
||||
const budget = resolvePerformanceBudget(testInfo.project.name);
|
||||
|
||||
const cold = await measureSigninLoad(page);
|
||||
const warm = await measureSigninLoad(page);
|
||||
console.log(
|
||||
`[userfront-perf] cold=${cold.durationMs}ms/${cold.transferredBytes}B warm=${warm.durationMs}ms/${warm.transferredBytes}B`,
|
||||
);
|
||||
|
||||
expect(cold.durationMs).toBeLessThanOrEqual(budget.coldMs);
|
||||
expect(warm.durationMs).toBeLessThanOrEqual(budget.warmMs);
|
||||
expect(warm.transferredBytes).toBeLessThanOrEqual(1_000_000);
|
||||
expectNoDuplicateStaticRequests(cold);
|
||||
expectNoDuplicateStaticRequests(warm);
|
||||
const cacheControlByPath = new Map([
|
||||
...cold.cacheControlByPath,
|
||||
...warm.cacheControlByPath,
|
||||
]);
|
||||
const appShellCache = cacheControlByPath.get("/ko/signin") ?? "";
|
||||
expect(appShellCache).toContain("no-cache");
|
||||
const serviceWorkerState = await page.evaluate(async () => {
|
||||
if (!("serviceWorker" in navigator)) {
|
||||
return {
|
||||
available: false,
|
||||
secure: window.isSecureContext,
|
||||
scriptUrl: "",
|
||||
};
|
||||
}
|
||||
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||
const registration = registrations[0];
|
||||
return {
|
||||
available: true,
|
||||
secure: window.isSecureContext,
|
||||
count: registrations.length,
|
||||
controller: navigator.serviceWorker.controller?.scriptURL ?? "",
|
||||
scriptUrl:
|
||||
registration?.active?.scriptURL ??
|
||||
registration?.waiting?.scriptURL ??
|
||||
registration?.installing?.scriptURL ??
|
||||
"",
|
||||
};
|
||||
});
|
||||
if (
|
||||
testInfo.project.name.includes("mobile") &&
|
||||
serviceWorkerState.scriptUrl
|
||||
) {
|
||||
expect(new URL(serviceWorkerState.scriptUrl).pathname).toBe(
|
||||
"/flutter_service_worker.js",
|
||||
);
|
||||
const serviceWorkerResponse = await page
|
||||
.context()
|
||||
.request.get(
|
||||
new URL("/flutter_service_worker.js", page.url()).toString(),
|
||||
);
|
||||
expect(serviceWorkerResponse.headers()["cache-control"] ?? "").toContain(
|
||||
"no-cache",
|
||||
);
|
||||
} else {
|
||||
expect(serviceWorkerState.scriptUrl).toBe("");
|
||||
}
|
||||
|
||||
expect(cold.durationMs).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("root redirects to localized signin before Flutter boots", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockPublicApis(page);
|
||||
|
||||
const requestedUrls: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
requestedUrls.push(request.url());
|
||||
});
|
||||
|
||||
const start = performance.now();
|
||||
await page.goto("/", { waitUntil: "domcontentloaded" });
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
const durationMs = Math.round(performance.now() - start);
|
||||
|
||||
expect(durationMs).toBeLessThanOrEqual(
|
||||
resolveRootRedirectBudget(testInfo.project.name),
|
||||
);
|
||||
const rootIndex = requestedUrls.findIndex(
|
||||
(url) => new URL(url).pathname === "/",
|
||||
);
|
||||
const bootstrapIndex = requestedUrls.findIndex((url) =>
|
||||
new URL(url).pathname.endsWith("/flutter_bootstrap.js"),
|
||||
);
|
||||
expect(rootIndex).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
70
baron-sso/userfront-e2e/tests/oidc-login-challenge.spec.ts
Normal file
70
baron-sso/userfront-e2e/tests/oidc-login-challenge.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { expect, type Page, type Route, test } from "@playwright/test";
|
||||
|
||||
async function mockUserfrontApisForRepro(
|
||||
page: Page,
|
||||
options: { sessionStatus: number } = { sessionStatus: 401 },
|
||||
): Promise<void> {
|
||||
await page.route("**/api/v1/**", async (route: Route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const path = requestUrl.pathname;
|
||||
|
||||
if (path.endsWith("/api/v1/user/me")) {
|
||||
await route.fulfill({
|
||||
status: options.sessionStatus,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "unauthorized" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/client-log")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Default mock for other APIs
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Issue #345 Reproduction (Log-based Validation)", () => {
|
||||
test("비로그인 상태에서 login_challenge와 함께 signin 진입 시 루프 없이 로그가 정상 출력되어야 한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const requests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.isNavigationRequest()) {
|
||||
requests.push(request.url());
|
||||
}
|
||||
});
|
||||
|
||||
await mockUserfrontApisForRepro(page, { sessionStatus: 401 });
|
||||
|
||||
const targetUrl = "/ko/signin?login_challenge=repro_challenge_12345";
|
||||
await page.goto(targetUrl);
|
||||
|
||||
// WASM 앱 로딩 및 로직 실행 대기
|
||||
await page.waitForTimeout(7000);
|
||||
|
||||
const currentUrl = page.url();
|
||||
const signinNavigations = requests.filter((url) => url.includes("/signin"));
|
||||
|
||||
// [검증 1] URL 유지 확인
|
||||
expect(currentUrl).toContain("login_challenge=repro_challenge_12345");
|
||||
|
||||
// [검증 2] 리다이렉트 루프 발생 여부 확인 (최초 진입 1회만 있어야 함)
|
||||
expect(signinNavigations.length).toBeLessThanOrEqual(1);
|
||||
|
||||
console.log(
|
||||
"✅ 루프가 해결되었으며, URL 유지와 네비게이션 수로 정상 동작을 확인했습니다.",
|
||||
);
|
||||
});
|
||||
});
|
||||
456
baron-sso/userfront-e2e/tests/password-and-reset.spec.ts
Normal file
456
baron-sso/userfront-e2e/tests/password-and-reset.spec.ts
Normal file
@@ -0,0 +1,456 @@
|
||||
import {
|
||||
expect,
|
||||
type Locator,
|
||||
type Page,
|
||||
type Route,
|
||||
test,
|
||||
} from "@playwright/test";
|
||||
|
||||
type RequestCapture = {
|
||||
loginBody?: Record<string, unknown>;
|
||||
resetBody?: Record<string, unknown>;
|
||||
resetToken?: string | null;
|
||||
clientLogs: string[];
|
||||
};
|
||||
|
||||
const resetNewPasswordName =
|
||||
/^(새 비밀번호|ui\.userfront\.reset\.new_password)$/;
|
||||
const resetConfirmPasswordName =
|
||||
/^(새 비밀번호 확인|ui\.userfront\.reset\.confirm_password)$/;
|
||||
const resetSubmitButtonName = /^(비밀번호 변경|ui\.userfront\.reset\.submit)$/;
|
||||
|
||||
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||
const button = page.getByRole("button", { name: "Enable accessibility" });
|
||||
if (await button.count()) {
|
||||
await button.first().evaluate((node) => {
|
||||
(node as HTMLElement).click();
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
return;
|
||||
}
|
||||
await page.waitForTimeout(300);
|
||||
const placeholder = page.locator("flt-semantics-placeholder").first();
|
||||
if (await placeholder.count()) {
|
||||
await placeholder.evaluate((node) => {
|
||||
(node as HTMLElement).click();
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
}
|
||||
}
|
||||
|
||||
type ScreenCoords = {
|
||||
signinPasswordTabX: number;
|
||||
signinTabY: number;
|
||||
signinLoginIdX: number;
|
||||
signinLoginIdY: number;
|
||||
signinPasswordX: number;
|
||||
signinPasswordY: number;
|
||||
signinSubmitX: number;
|
||||
signinSubmitY: number;
|
||||
resetNewPasswordX: number;
|
||||
resetNewPasswordY: number;
|
||||
resetConfirmPasswordX: number;
|
||||
resetConfirmPasswordY: number;
|
||||
resetSubmitX: number;
|
||||
resetSubmitY: number;
|
||||
};
|
||||
|
||||
const desktopCoords: ScreenCoords = {
|
||||
signinPasswordTabX: 522,
|
||||
signinTabY: 158,
|
||||
signinLoginIdX: 640,
|
||||
signinLoginIdY: 245,
|
||||
signinPasswordX: 640,
|
||||
signinPasswordY: 311,
|
||||
signinSubmitX: 640,
|
||||
signinSubmitY: 381,
|
||||
resetNewPasswordX: 640,
|
||||
resetNewPasswordY: 382,
|
||||
resetConfirmPasswordX: 640,
|
||||
resetConfirmPasswordY: 464,
|
||||
resetSubmitX: 640,
|
||||
resetSubmitY: 534,
|
||||
};
|
||||
|
||||
const mobileCoords: ScreenCoords = {
|
||||
signinPasswordTabX: 90,
|
||||
signinTabY: 158,
|
||||
signinLoginIdX: 206,
|
||||
signinLoginIdY: 268,
|
||||
signinPasswordX: 206,
|
||||
signinPasswordY: 334,
|
||||
signinSubmitX: 206,
|
||||
signinSubmitY: 399,
|
||||
resetNewPasswordX: 206,
|
||||
resetNewPasswordY: 382,
|
||||
resetConfirmPasswordX: 206,
|
||||
resetConfirmPasswordY: 464,
|
||||
resetSubmitX: 206,
|
||||
resetSubmitY: 534,
|
||||
};
|
||||
|
||||
function coordsFor(page: Page): ScreenCoords {
|
||||
const viewport = page.viewportSize();
|
||||
return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords;
|
||||
}
|
||||
|
||||
function isMobileProject(page: Page): boolean {
|
||||
const viewport = page.viewportSize();
|
||||
return (viewport?.width ?? 1280) <= 500;
|
||||
}
|
||||
|
||||
async function clickPasswordTab(page: Page): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await page.waitForTimeout(900);
|
||||
const pane = page.locator("flt-glass-pane");
|
||||
await pane.click({
|
||||
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(120);
|
||||
await pane.click({
|
||||
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function fillAt(
|
||||
page: Page,
|
||||
x: number,
|
||||
y: number,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const pane = page.locator("flt-glass-pane");
|
||||
await pane.click({ position: { x, y }, force: true });
|
||||
await page.waitForTimeout(100);
|
||||
await page.keyboard.press("Control+A");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.type(value);
|
||||
}
|
||||
|
||||
async function typeIntoAccessibleField(
|
||||
page: Page,
|
||||
field: Locator,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await field.click({ force: true });
|
||||
await page.waitForTimeout(100);
|
||||
await page.keyboard.press("Control+A");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.type(value);
|
||||
}
|
||||
|
||||
async function fillPasswordLoginForm(
|
||||
page: Page,
|
||||
loginId: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
await enableFlutterAccessibility(page);
|
||||
const inputs = page.getByRole("textbox");
|
||||
await inputs.nth(0).fill(loginId);
|
||||
await inputs.nth(1).fill(password);
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await fillAt(page, coords.signinLoginIdX, coords.signinLoginIdY, loginId);
|
||||
await fillAt(page, coords.signinPasswordX, coords.signinPasswordY, password);
|
||||
}
|
||||
|
||||
async function submitPasswordLogin(page: Page): Promise<void> {
|
||||
if (isMobileProject(page)) {
|
||||
await enableFlutterAccessibility(page);
|
||||
await page.getByRole("button", { name: "로그인" }).click({ force: true });
|
||||
return;
|
||||
}
|
||||
await page.keyboard.press("Enter");
|
||||
}
|
||||
|
||||
async function fillResetPasswordForm(
|
||||
page: Page,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
await enableFlutterAccessibility(page);
|
||||
const newPasswordInput = page.getByRole("textbox", {
|
||||
name: resetNewPasswordName,
|
||||
});
|
||||
const confirmPasswordInput = page.getByRole("textbox", {
|
||||
name: resetConfirmPasswordName,
|
||||
});
|
||||
if (
|
||||
(await newPasswordInput.count()) > 0 &&
|
||||
(await confirmPasswordInput.count()) > 0
|
||||
) {
|
||||
await typeIntoAccessibleField(page, newPasswordInput, password);
|
||||
await typeIntoAccessibleField(page, confirmPasswordInput, password);
|
||||
return;
|
||||
}
|
||||
if (isMobileProject(page)) {
|
||||
await page
|
||||
.getByRole("textbox", { name: resetNewPasswordName })
|
||||
.fill(password);
|
||||
await page
|
||||
.getByRole("textbox", { name: resetConfirmPasswordName })
|
||||
.fill(password);
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await fillAt(
|
||||
page,
|
||||
coords.resetNewPasswordX,
|
||||
coords.resetNewPasswordY,
|
||||
password,
|
||||
);
|
||||
await fillAt(
|
||||
page,
|
||||
coords.resetConfirmPasswordX,
|
||||
coords.resetConfirmPasswordY,
|
||||
password,
|
||||
);
|
||||
}
|
||||
|
||||
async function submitResetPassword(page: Page): Promise<void> {
|
||||
await enableFlutterAccessibility(page);
|
||||
const submitButton = page.getByRole("button", {
|
||||
name: resetSubmitButtonName,
|
||||
});
|
||||
if ((await submitButton.count()) > 0) {
|
||||
await submitButton.click({ force: true });
|
||||
return;
|
||||
}
|
||||
if (isMobileProject(page)) {
|
||||
return;
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await page.locator("flt-glass-pane").click({
|
||||
position: { x: coords.resetSubmitX, y: coords.resetSubmitY },
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function mockAuthApis(
|
||||
page: Page,
|
||||
capture: RequestCapture,
|
||||
): Promise<void> {
|
||||
await page.route("**/api/v1/**", async (route: Route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const path = requestUrl.pathname;
|
||||
|
||||
if (path.endsWith("/api/v1/auth/password/login")) {
|
||||
capture.loginBody = (route.request().postDataJSON() ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const loginId = String(capture.loginBody.loginId ?? "");
|
||||
const password = String(capture.loginBody.password ?? "");
|
||||
if (loginId === "e2e@example.com" && password === "ValidPass1!") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
sessionJwt: "e30.e30.e30",
|
||||
provider: "ory",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "password_or_email_mismatch" }),
|
||||
});
|
||||
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/password/reset/complete")) {
|
||||
capture.resetBody = (route.request().postDataJSON() ?? {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
capture.resetToken = requestUrl.searchParams.get("token");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "ok" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/client-log")) {
|
||||
const payload = (route.request().postDataJSON() ?? {}) as {
|
||||
message?: string;
|
||||
};
|
||||
if (payload.message != null) {
|
||||
capture.clientLogs.push(payload.message);
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/user/me")) {
|
||||
const authHeader = route.request().headers().authorization ?? "";
|
||||
if (!authHeader.startsWith("Bearer ")) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "unauthorized" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id: "e2e-user",
|
||||
email: "e2e@example.com",
|
||||
name: "E2E User",
|
||||
phone: "+821012341234",
|
||||
department: "QA",
|
||||
affiliationType: "employee",
|
||||
companyCode: "BARON",
|
||||
tenant: {
|
||||
id: "tenant-1",
|
||||
name: "Baron",
|
||||
slug: "baron",
|
||||
description: "E2E tenant",
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/user/rp/linked")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/audit/auth/timeline")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], next_cursor: "" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("UserFront WASM password login and reset", () => {
|
||||
test.skip(({ isMobile }) => isMobile, "Desktop only (hardcoded coordinates)");
|
||||
test("비밀번호 로그인 성공 시 dashboard로 이동하고 토큰을 저장한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(
|
||||
isMobileProject(page),
|
||||
"Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.",
|
||||
);
|
||||
const capture: RequestCapture = { clientLogs: [] };
|
||||
await mockAuthApis(page, capture);
|
||||
|
||||
await page.goto("/ko/signin");
|
||||
await clickPasswordTab(page);
|
||||
await fillPasswordLoginForm(page, "e2e@example.com", "ValidPass1!");
|
||||
await submitPasswordLogin(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
|
||||
expect(capture.loginBody?.loginId).toBe("e2e@example.com");
|
||||
expect(capture.loginBody?.password).toBe("ValidPass1!");
|
||||
|
||||
const storedToken = await page.evaluate(() =>
|
||||
window.localStorage.getItem("baron_auth_token"),
|
||||
);
|
||||
expect(storedToken).toBe("e30.e30.e30");
|
||||
});
|
||||
|
||||
test("비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.skip(
|
||||
isMobileProject(page),
|
||||
"Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.",
|
||||
);
|
||||
const capture: RequestCapture = { clientLogs: [] };
|
||||
await mockAuthApis(page, capture);
|
||||
|
||||
await page.goto("/ko/signin");
|
||||
await clickPasswordTab(page);
|
||||
await fillPasswordLoginForm(page, "e2e@example.com", "WrongPass1!");
|
||||
await submitPasswordLogin(page);
|
||||
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
capture.clientLogs.some((message) =>
|
||||
message.includes("password_or_email_mismatch"),
|
||||
),
|
||||
{ timeout: 10000 },
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("reset-password에서 변경 성공 시 signin으로 이동한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const capture: RequestCapture = { clientLogs: [] };
|
||||
await mockAuthApis(page, capture);
|
||||
|
||||
const policyLoaded = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes("/api/v1/auth/password/policy") &&
|
||||
response.status() === 200,
|
||||
);
|
||||
await page.goto("/ko/reset-password?token=reset-token-e2e");
|
||||
await policyLoaded;
|
||||
await page.waitForTimeout(900);
|
||||
await fillResetPasswordForm(page, "ValidPass1!A");
|
||||
await submitResetPassword(page);
|
||||
|
||||
await expect
|
||||
.poll(() => capture.resetBody?.newPassword as string | undefined, {
|
||||
timeout: 10000,
|
||||
})
|
||||
.toBe("ValidPass1!A");
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/, { timeout: 10_000 });
|
||||
expect(capture.resetToken).toBe("reset-token-e2e");
|
||||
expect(capture.resetBody?.newPassword).toBe("ValidPass1!A");
|
||||
});
|
||||
});
|
||||
504
baron-sso/userfront-e2e/tests/profile-department.spec.ts
Normal file
504
baron-sso/userfront-e2e/tests/profile-department.spec.ts
Normal file
@@ -0,0 +1,504 @@
|
||||
import { expect, type Page, type Route, test } from "@playwright/test";
|
||||
|
||||
type ProfileState = {
|
||||
department: string;
|
||||
getMeCount: number;
|
||||
putBodies: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
async function enableFlutterAccessibility(page: Page): Promise<void> {
|
||||
const button = page.getByRole("button", { name: "Enable accessibility" });
|
||||
if (await button.count()) {
|
||||
await button.click({ force: true }).catch(async () => {
|
||||
await page
|
||||
.locator('flt-semantics-placeholder[aria-label="Enable accessibility"]')
|
||||
.evaluate((element) => {
|
||||
if (element instanceof HTMLElement) element.click();
|
||||
});
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
}
|
||||
|
||||
type ProfileCoords = {
|
||||
departmentEditX: number;
|
||||
departmentEditY: number;
|
||||
departmentInputX: number;
|
||||
departmentInputY: number;
|
||||
blurX: number;
|
||||
blurY: number;
|
||||
};
|
||||
|
||||
const desktopCoords: ProfileCoords = {
|
||||
departmentEditX: 1170,
|
||||
departmentEditY: 680,
|
||||
departmentInputX: 110,
|
||||
departmentInputY: 685,
|
||||
blurX: 200,
|
||||
blurY: 260,
|
||||
};
|
||||
|
||||
const mobileCoords: ProfileCoords = {
|
||||
departmentEditX: 350,
|
||||
departmentEditY: 680,
|
||||
departmentInputX: 110,
|
||||
departmentInputY: 685,
|
||||
blurX: 200,
|
||||
blurY: 260,
|
||||
};
|
||||
|
||||
function coordsFor(page: Page): ProfileCoords {
|
||||
const viewport = page.viewportSize();
|
||||
return (viewport?.width ?? 1280) <= 500 ? mobileCoords : desktopCoords;
|
||||
}
|
||||
|
||||
function isMobileProject(page: Page): boolean {
|
||||
const viewport = page.viewportSize();
|
||||
return (viewport?.width ?? 1280) <= 500;
|
||||
}
|
||||
|
||||
async function seedTokenLogin(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("baron_auth_token", "e30.e30.e30");
|
||||
window.localStorage.setItem("baron_auth_provider", "ory");
|
||||
window.localStorage.removeItem("baron_auth_cookie_mode");
|
||||
window.localStorage.removeItem("baron_auth_pending_provider");
|
||||
});
|
||||
}
|
||||
|
||||
async function fillAt(
|
||||
page: Page,
|
||||
x: number,
|
||||
y: number,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const pane = page.locator("flt-glass-pane");
|
||||
await pane.click({ position: { x, y }, force: true });
|
||||
await page.waitForTimeout(100);
|
||||
await replaceFocusedText(page, value);
|
||||
}
|
||||
|
||||
async function replaceFocusedText(page: Page, value: string): Promise<void> {
|
||||
await page.keyboard.press("End");
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
await page.keyboard.press("Backspace");
|
||||
}
|
||||
if (value !== "") {
|
||||
await page.keyboard.insertText(value);
|
||||
}
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
type BoxCenter = {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
|
||||
async function resolveLocatorCenter(
|
||||
locator: ReturnType<Page["locator"]>,
|
||||
): Promise<BoxCenter | null> {
|
||||
const handle = await locator
|
||||
.elementHandle({ timeout: 1_000 })
|
||||
.catch(() => null);
|
||||
if (!handle) {
|
||||
return null;
|
||||
}
|
||||
const box = await handle
|
||||
.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left,
|
||||
y: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
})
|
||||
.catch(() => null);
|
||||
await handle.dispose();
|
||||
if (!box) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: box.x + box.width / 2,
|
||||
y: box.y + box.height / 2,
|
||||
};
|
||||
}
|
||||
|
||||
async function clickGlassPaneAt(
|
||||
page: Page,
|
||||
center: BoxCenter | null,
|
||||
): Promise<boolean> {
|
||||
if (!center) {
|
||||
return false;
|
||||
}
|
||||
await page.locator("flt-glass-pane").click({
|
||||
position: center,
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function departmentTextboxIsOpen(page: Page): Promise<boolean> {
|
||||
return (await page.getByRole("textbox", { name: "소속" }).count()) > 0;
|
||||
}
|
||||
|
||||
async function openDepartmentEditor(page: Page): Promise<void> {
|
||||
const accessibleEditor = page
|
||||
.getByRole("group", { name: "소속 QA" })
|
||||
.getByRole("button", { name: "편집" });
|
||||
const textbox = page.getByRole("textbox", { name: "소속" });
|
||||
if ((await accessibleEditor.count()) > 0) {
|
||||
const editorCenter = await resolveLocatorCenter(accessibleEditor);
|
||||
await accessibleEditor
|
||||
.evaluate(
|
||||
(element) => {
|
||||
if (element instanceof HTMLElement) {
|
||||
element.click();
|
||||
}
|
||||
},
|
||||
{ timeout: 1_000 },
|
||||
)
|
||||
.catch(() => undefined);
|
||||
await page.waitForTimeout(200);
|
||||
if (await departmentTextboxIsOpen(page)) {
|
||||
return;
|
||||
}
|
||||
await clickGlassPaneAt(page, editorCenter);
|
||||
if (await departmentTextboxIsOpen(page)) {
|
||||
return;
|
||||
}
|
||||
await accessibleEditor
|
||||
.click({ force: true, timeout: 1_000 })
|
||||
.catch(() => undefined);
|
||||
await page.waitForTimeout(200);
|
||||
if (await departmentTextboxIsOpen(page)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (isMobileProject(page)) {
|
||||
throw new Error("Department editor accessibility button was not found.");
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
const viewport = page.viewportSize();
|
||||
const editCandidates: BoxCenter[] = [
|
||||
{ x: coords.departmentEditX, y: coords.departmentEditY },
|
||||
{ x: (viewport?.width ?? 1280) - 110, y: coords.departmentEditY },
|
||||
{ x: coords.departmentEditX - 24, y: coords.departmentEditY },
|
||||
{ x: coords.departmentEditX + 24, y: coords.departmentEditY },
|
||||
];
|
||||
for (const candidate of editCandidates) {
|
||||
await clickGlassPaneAt(page, candidate);
|
||||
if (await departmentTextboxIsOpen(page)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await expect(textbox).toHaveCount(1, { timeout: 1_000 });
|
||||
}
|
||||
|
||||
async function blurDepartmentEditor(page: Page): Promise<void> {
|
||||
const textbox = page.getByRole("textbox", { name: "소속" });
|
||||
if ((await textbox.count()) > 0) {
|
||||
await textbox.blur();
|
||||
await page.waitForTimeout(250);
|
||||
return;
|
||||
}
|
||||
if (isMobileProject(page)) {
|
||||
throw new Error("Department textbox was not found.");
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await page.locator("flt-glass-pane").click({
|
||||
position: { x: coords.blurX, y: coords.blurY },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
async function submitDepartmentEditor(page: Page): Promise<void> {
|
||||
const textbox = page.getByRole("textbox", { name: "소속" });
|
||||
if ((await textbox.count()) > 0) {
|
||||
await textbox.press("Enter");
|
||||
await page.waitForTimeout(250);
|
||||
return;
|
||||
}
|
||||
if (isMobileProject(page)) {
|
||||
throw new Error("Department textbox was not found.");
|
||||
}
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
|
||||
async function fillDepartmentField(page: Page, value: string): Promise<void> {
|
||||
const textbox = page.getByRole("textbox", { name: "소속" });
|
||||
if (!isMobileProject(page)) {
|
||||
if ((await textbox.count()) > 0) {
|
||||
await textbox.click({ force: true });
|
||||
await page.waitForTimeout(100);
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await fillAt(page, coords.departmentInputX, coords.departmentInputY, value);
|
||||
return;
|
||||
}
|
||||
|
||||
if ((await textbox.count()) > 0) {
|
||||
await textbox.click({ force: true });
|
||||
await page.waitForTimeout(100);
|
||||
await replaceFocusedText(page, value);
|
||||
return;
|
||||
}
|
||||
if (isMobileProject(page)) {
|
||||
throw new Error("Department textbox was not found.");
|
||||
}
|
||||
const coords = coordsFor(page);
|
||||
await fillAt(page, coords.departmentInputX, coords.departmentInputY, value);
|
||||
}
|
||||
|
||||
async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
|
||||
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") && method === "GET") {
|
||||
const authHeader = request.headers().authorization ?? "";
|
||||
if (!authHeader.startsWith("Bearer ")) {
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "unauthorized" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.getMeCount += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id: "e2e-user",
|
||||
email: "e2e@example.com",
|
||||
name: "E2E User",
|
||||
phone: "+821012341234",
|
||||
department: state.department,
|
||||
affiliationType: "employee",
|
||||
companyCode: "BARON",
|
||||
tenant: {
|
||||
id: "tenant-1",
|
||||
name: "Baron",
|
||||
slug: "baron",
|
||||
description: "E2E tenant",
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/user/me") && method === "PUT") {
|
||||
const body = (request.postDataJSON() ?? {}) as Record<string, unknown>;
|
||||
state.putBodies.push(body);
|
||||
const nextDepartment = String(body.department ?? "").trim();
|
||||
if (nextDepartment !== "") {
|
||||
state.department = nextDepartment;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
status: "success",
|
||||
updatedAt: "2026-02-24T00:00:00Z",
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/user/rp/linked")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/audit/auth/timeline")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], next_cursor: "" }),
|
||||
});
|
||||
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({ ok: true }),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function openProfilePage(page: Page): Promise<void> {
|
||||
await page.goto("/ko/profile");
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
await enableFlutterAccessibility(page);
|
||||
await page.waitForTimeout(1200);
|
||||
}
|
||||
|
||||
async function waitForInitialProfileLoad(state: ProfileState): Promise<void> {
|
||||
await expect.poll(() => state.getMeCount).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
test.describe("UserFront WASM profile department editing", () => {
|
||||
test.skip(({ isMobile }) => isMobile, "Desktop only (hardcoded coordinates)");
|
||||
test.skip(
|
||||
({ browserName }) => browserName === "webkit",
|
||||
"WebKit headless does not consistently open Flutter profile edit controls; Chromium and Firefox cover this flow.",
|
||||
);
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
await page.unroute("**/api/v1/**");
|
||||
});
|
||||
|
||||
test("소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: "QA",
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
await seedTokenLogin(page);
|
||||
await mockProfileApis(page, state);
|
||||
await openProfilePage(page);
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillDepartmentField(page, "QA-Updated");
|
||||
await submitDepartmentEditor(page);
|
||||
|
||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||
expect(state.putBodies[0]?.department).toBe("QA-Updated");
|
||||
expect(state.department).toBe("QA-Updated");
|
||||
|
||||
const getCountBeforeReload = state.getMeCount;
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
await expect
|
||||
.poll(() => state.getMeCount)
|
||||
.toBeGreaterThan(getCountBeforeReload);
|
||||
});
|
||||
|
||||
test("소속 입력 후 즉시 새로고침해도 저장 요청이 중복 전송되지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: "QA",
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
await seedTokenLogin(page);
|
||||
await mockProfileApis(page, state);
|
||||
await openProfilePage(page);
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillDepartmentField(page, "QA-Repro");
|
||||
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
expect(state.putBodies.length).toBeLessThanOrEqual(1);
|
||||
if (state.putBodies.length > 0) {
|
||||
expect(state.putBodies[0]?.department).toBe("QA-Repro");
|
||||
expect(state.department).toBe("QA-Repro");
|
||||
return;
|
||||
}
|
||||
expect(state.department).toBe("QA");
|
||||
});
|
||||
|
||||
test("소속에 기존값을 그대로 입력하고 포커스 아웃하면 저장 요청이 전송되지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: "QA",
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
await seedTokenLogin(page);
|
||||
await mockProfileApis(page, state);
|
||||
await openProfilePage(page);
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillDepartmentField(page, "QA");
|
||||
await blurDepartmentEditor(page);
|
||||
|
||||
expect(state.putBodies).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("소속을 빈 값으로 입력하면 저장 요청이 전송되지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: "QA",
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
await seedTokenLogin(page);
|
||||
await mockProfileApis(page, state);
|
||||
await openProfilePage(page);
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillDepartmentField(page, "");
|
||||
await blurDepartmentEditor(page);
|
||||
|
||||
expect(state.putBodies).toHaveLength(0);
|
||||
expect(state.department).toBe("QA");
|
||||
});
|
||||
|
||||
test("소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: ProfileState = {
|
||||
department: "QA",
|
||||
getMeCount: 0,
|
||||
putBodies: [],
|
||||
};
|
||||
await seedTokenLogin(page);
|
||||
await mockProfileApis(page, state);
|
||||
await openProfilePage(page);
|
||||
await waitForInitialProfileLoad(state);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillDepartmentField(page, "QA-1");
|
||||
await submitDepartmentEditor(page);
|
||||
await expect.poll(() => state.putBodies.length).toBe(1);
|
||||
|
||||
const getCountBeforeReload = state.getMeCount;
|
||||
await page.reload();
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
await enableFlutterAccessibility(page);
|
||||
await expect
|
||||
.poll(() => state.getMeCount)
|
||||
.toBeGreaterThan(getCountBeforeReload);
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
await openDepartmentEditor(page);
|
||||
await fillDepartmentField(page, "QA-2");
|
||||
await submitDepartmentEditor(page);
|
||||
await expect.poll(() => state.putBodies.length).toBe(2);
|
||||
|
||||
expect(state.putBodies[0]?.department).toBe("QA-1");
|
||||
expect(state.putBodies[1]?.department).toBe("QA-2");
|
||||
expect(state.department).toBe("QA-2");
|
||||
});
|
||||
});
|
||||
335
baron-sso/userfront-e2e/tests/route-inventory.spec.ts
Normal file
335
baron-sso/userfront-e2e/tests/route-inventory.spec.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import { expect, type Page, type Route, test } from "@playwright/test";
|
||||
|
||||
async function seedTokenLogin(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.localStorage.setItem("baron_auth_token", "e30.e30.e30");
|
||||
window.localStorage.setItem("baron_auth_provider", "ory");
|
||||
window.localStorage.removeItem("baron_auth_cookie_mode");
|
||||
window.localStorage.removeItem("baron_auth_pending_provider");
|
||||
});
|
||||
}
|
||||
|
||||
async function mockInventoryApis(page: Page): Promise<void> {
|
||||
await page.route("**/api/v1/**", async (route: Route) => {
|
||||
const requestUrl = new URL(route.request().url());
|
||||
const path = requestUrl.pathname;
|
||||
const method = route.request().method().toUpperCase();
|
||||
|
||||
if (path.endsWith("/api/v1/user/me")) {
|
||||
const authHeader = route.request().headers().authorization ?? "";
|
||||
if (authHeader.startsWith("Bearer ")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id: "e2e-user",
|
||||
email: "e2e@example.com",
|
||||
name: "E2E User",
|
||||
phone: "+821012341234",
|
||||
department: "QA",
|
||||
affiliationType: "employee",
|
||||
companyCode: "BARON",
|
||||
tenant: {
|
||||
id: "tenant-1",
|
||||
name: "Baron",
|
||||
slug: "baron",
|
||||
description: "E2E tenant",
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await route.fulfill({
|
||||
status: 401,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ error: "unauthorized" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/user/rp/linked")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [] }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/audit/auth/timeline")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], next_cursor: "" }),
|
||||
});
|
||||
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/magic-link/verify")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "approved" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/auth/login/code/verify")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "approved" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/auth/login/code/verify-short")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ status: "approved" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/auth/consent") && method === "GET") {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
client: {
|
||||
client_name: "E2E Client",
|
||||
client_id: "e2e-client",
|
||||
},
|
||||
requested_scope: ["openid"],
|
||||
scope_details: {
|
||||
openid: {
|
||||
description: "OpenID",
|
||||
mandatory: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (path.endsWith("/api/v1/auth/qr/approve")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ ok: true }),
|
||||
});
|
||||
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({}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("UserFront WASM route inventory (unauth)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockInventoryApis(page);
|
||||
});
|
||||
|
||||
test("route: /", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page).toHaveURL(/\/(ko|en)\/signin(?:\?.*)?$/);
|
||||
});
|
||||
|
||||
test("route: /ko", async ({ page }) => {
|
||||
await page.goto("/ko");
|
||||
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
|
||||
});
|
||||
|
||||
test("route: /ko/dashboard", async ({ page }) => {
|
||||
await page.goto("/ko/dashboard");
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
});
|
||||
|
||||
test("route: /ko/profile", async ({ page }) => {
|
||||
await page.goto("/ko/profile");
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
});
|
||||
|
||||
test("route: /ko/admin/users", async ({ page }) => {
|
||||
await page.goto("/ko/admin/users");
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
});
|
||||
|
||||
test("route: /ko/scan", async ({ page }) => {
|
||||
await page.goto("/ko/scan");
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
});
|
||||
|
||||
test("route: /ko/signin", async ({ page }) => {
|
||||
await page.goto("/ko/signin");
|
||||
await expect(page).toHaveURL(/\/ko\/signin$/);
|
||||
});
|
||||
|
||||
test("route: /ko/login", async ({ page }) => {
|
||||
await page.goto("/ko/login");
|
||||
await expect(page).toHaveURL(/\/ko\/login$/);
|
||||
});
|
||||
|
||||
test("route: /ko/signup", async ({ page }) => {
|
||||
await page.goto("/ko/signup");
|
||||
await expect(page).toHaveURL(/\/ko\/signup$/);
|
||||
});
|
||||
|
||||
test("route: /ko/registration", async ({ page }) => {
|
||||
await page.goto("/ko/registration");
|
||||
await expect(page).toHaveURL(/\/ko\/registration$/);
|
||||
});
|
||||
|
||||
test("route: /ko/verify", async ({ page }) => {
|
||||
await page.goto("/ko/verify");
|
||||
await expect(page).toHaveURL(/\/ko\/verify$/);
|
||||
});
|
||||
|
||||
test("route: /ko/verify/:token", async ({ page }) => {
|
||||
await page.goto("/ko/verify/e2e-token");
|
||||
await expect(page).toHaveURL(/\/ko\/verify\/e2e-token$/);
|
||||
});
|
||||
|
||||
test("route: /ko/verification", async ({ page }) => {
|
||||
await page.goto("/ko/verification");
|
||||
await expect(page).toHaveURL(/\/ko\/verification$/);
|
||||
});
|
||||
|
||||
test("route: /ko/verify-complete", async ({ page }) => {
|
||||
await page.goto("/ko/verify-complete");
|
||||
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
|
||||
});
|
||||
|
||||
test("route: /ko/l/:shortCode", async ({ page }) => {
|
||||
await page.goto("/ko/l/AB123456");
|
||||
await expect(page).toHaveURL(/\/ko\/l\/AB123456$/);
|
||||
});
|
||||
|
||||
test("route: /ko/forgot-password", async ({ page }) => {
|
||||
await page.goto("/ko/forgot-password");
|
||||
await expect(page).toHaveURL(/\/ko\/forgot-password$/);
|
||||
});
|
||||
|
||||
test("route: /ko/recovery", async ({ page }) => {
|
||||
await page.goto("/ko/recovery");
|
||||
await expect(page).toHaveURL(/\/ko\/recovery$/);
|
||||
});
|
||||
|
||||
test("route: /ko/reset-password", async ({ page }) => {
|
||||
await page.goto("/ko/reset-password?token=e2e-reset-token");
|
||||
await expect(page).toHaveURL(
|
||||
/\/ko\/reset-password\?token=e2e-reset-token$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("route: /ko/error", async ({ page }) => {
|
||||
await page.goto("/ko/error?error=invalid_request");
|
||||
await expect(page).toHaveURL(/\/ko\/error\?error=invalid_request$/);
|
||||
});
|
||||
|
||||
test("route: /ko/settings", async ({ page }) => {
|
||||
await page.goto("/ko/settings");
|
||||
await expect(page).toHaveURL(/\/ko\/settings$/);
|
||||
});
|
||||
|
||||
test("route: /ko/consent (missing challenge)", async ({ page }) => {
|
||||
await page.goto("/ko/consent");
|
||||
await expect(page).toHaveURL(/\/ko\/consent$/);
|
||||
});
|
||||
|
||||
test("route: /ko/consent?consent_challenge=...", async ({ page }) => {
|
||||
await page.goto("/ko/consent?consent_challenge=e2e-consent");
|
||||
await expect(page).toHaveURL(
|
||||
/\/ko\/consent\?consent_challenge=e2e-consent$/,
|
||||
);
|
||||
});
|
||||
|
||||
test("route: /ko/approve?ref=...", async ({ page }) => {
|
||||
await page.goto("/ko/approve?ref=e2e-ref");
|
||||
await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
|
||||
});
|
||||
|
||||
test("route: /ko/ql/:ref", async ({ page }) => {
|
||||
await page.goto("/ko/ql/e2e-ref");
|
||||
await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("UserFront WASM route inventory (authed)", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await seedTokenLogin(page);
|
||||
await mockInventoryApis(page);
|
||||
});
|
||||
|
||||
test("route: /ko -> /ko/dashboard", async ({ page }) => {
|
||||
await page.goto("/ko");
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
});
|
||||
|
||||
test("route: /ko/dashboard", async ({ page }) => {
|
||||
await page.goto("/ko/dashboard");
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/);
|
||||
});
|
||||
|
||||
test("route: /ko/profile", async ({ page }) => {
|
||||
await page.goto("/ko/profile");
|
||||
await expect(page).toHaveURL(/\/ko\/profile$/);
|
||||
});
|
||||
|
||||
test("route: /ko/admin/users", async ({ page }) => {
|
||||
await page.goto("/ko/admin/users");
|
||||
await expect(page).toHaveURL(/\/ko\/admin\/users$/);
|
||||
});
|
||||
|
||||
test("route: /ko/scan", async ({ page }) => {
|
||||
await page.goto("/ko/scan");
|
||||
await expect(page).toHaveURL(/\/ko\/scan$/);
|
||||
});
|
||||
|
||||
test("route: /ko/approve?ref=... -> /ko/dashboard", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.goto("/ko/approve?ref=e2e-ref");
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/, {
|
||||
timeout: testInfo.project.name === "webkit-desktop" ? 15_000 : 5_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("route: /ko/ql/:ref -> /ko/dashboard", async ({ page }, testInfo) => {
|
||||
await page.goto("/ko/ql/e2e-ref");
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard$/, {
|
||||
timeout: testInfo.project.name === "webkit-desktop" ? 15_000 : 5_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
436
baron-sso/userfront-e2e/tests/runtime-env-mobile.spec.ts
Normal file
436
baron-sso/userfront-e2e/tests/runtime-env-mobile.spec.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { inflateSync } from "node:zlib";
|
||||
import {
|
||||
type BrowserContext,
|
||||
expect,
|
||||
type Page,
|
||||
type TestInfo,
|
||||
test,
|
||||
} from "@playwright/test";
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Buffer | null> {
|
||||
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<void> {
|
||||
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<string>();
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import { type BrowserContext, expect, type Page, test } from "@playwright/test";
|
||||
|
||||
const USERFRONT_BASE_URL =
|
||||
process.env.USERFRONT_BASE_URL ?? "https://sso.example.test";
|
||||
const ADMINFRONT_URL = process.env.ADMINFRONT_URL ?? "http://localhost:5173";
|
||||
const LOGIN_ID = process.env.E2E_LOGIN_ID ?? "";
|
||||
const PASSWORD = process.env.E2E_PASSWORD ?? "";
|
||||
|
||||
type SessionApiResponse = {
|
||||
items?: Array<{
|
||||
session_id?: string;
|
||||
client_id?: string;
|
||||
app_name?: string;
|
||||
is_current?: boolean;
|
||||
user_agent?: string;
|
||||
ip_address?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function ensureCredentials(): void {
|
||||
if (!LOGIN_ID || !PASSWORD) {
|
||||
test.skip(true, "E2E credentials are required");
|
||||
}
|
||||
}
|
||||
|
||||
async function clickPasswordTab(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(900);
|
||||
const pane = page.locator("flt-glass-pane");
|
||||
await pane.click({
|
||||
position: { x: 522, y: 158 },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(120);
|
||||
await pane.click({
|
||||
position: { x: 522, y: 158 },
|
||||
force: true,
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
}
|
||||
|
||||
async function fillAt(
|
||||
page: Page,
|
||||
x: number,
|
||||
y: number,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const pane = page.locator("flt-glass-pane");
|
||||
await pane.click({ position: { x, y }, force: true });
|
||||
await page.waitForTimeout(100);
|
||||
await page.keyboard.press("Control+A");
|
||||
await page.keyboard.press("Backspace");
|
||||
await page.keyboard.type(value);
|
||||
}
|
||||
|
||||
async function loginViaUserFront(page: Page): Promise<void> {
|
||||
await page.waitForURL(/\/ko\/(signin|login)/, { timeout: 30_000 });
|
||||
const loginIdInput = page.getByPlaceholder(
|
||||
/이메일 또는 휴대폰 번호|email|phone/i,
|
||||
);
|
||||
const passwordInput = page.getByPlaceholder(/비밀번호|password/i);
|
||||
const submitButton = page.getByRole("button", { name: /로그인|Login/i });
|
||||
|
||||
if ((await loginIdInput.count()) >= 1 && (await passwordInput.count()) >= 1) {
|
||||
await loginIdInput.first().fill(LOGIN_ID);
|
||||
await passwordInput.first().fill(PASSWORD);
|
||||
await submitButton.click();
|
||||
return;
|
||||
}
|
||||
|
||||
await clickPasswordTab(page);
|
||||
await fillAt(page, 640, 245, LOGIN_ID);
|
||||
await fillAt(page, 640, 311, PASSWORD);
|
||||
await page.locator("flt-glass-pane").click({
|
||||
position: { x: 640, y: 381 },
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureConsentIfNeeded(page: Page): Promise<void> {
|
||||
if (!/\/ko\/consent/.test(page.url())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowButton = page
|
||||
.getByRole("button")
|
||||
.filter({ hasText: /허용|동의|Accept|Allow/i })
|
||||
.first();
|
||||
|
||||
if (await allowButton.count()) {
|
||||
await allowButton.click({ force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function captureUserSessionsOnReload(
|
||||
page: Page,
|
||||
): Promise<SessionApiResponse> {
|
||||
const responsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "GET" &&
|
||||
response.url().includes("/api/v1/user/sessions"),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
const response = await responsePromise;
|
||||
return (await response.json()) as SessionApiResponse;
|
||||
}
|
||||
|
||||
async function loginUserFront(context: BrowserContext): Promise<Page> {
|
||||
const page = await context.newPage();
|
||||
await page.goto(`${USERFRONT_BASE_URL}/ko/signin`, {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await loginViaUserFront(page);
|
||||
await expect(page).toHaveURL(/\/ko\/dashboard/, { timeout: 60_000 });
|
||||
return page;
|
||||
}
|
||||
|
||||
async function loginAdminFront(context: BrowserContext): Promise<Page> {
|
||||
const page = await context.newPage();
|
||||
await page.goto(ADMINFRONT_URL, { waitUntil: "domcontentloaded" });
|
||||
const ssoButton = page.getByRole("button", {
|
||||
name: /SSO 계정으로 로그인|SSO/i,
|
||||
});
|
||||
if (await ssoButton.count()) {
|
||||
await ssoButton.click({ force: true });
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
if (/\/login$/.test(page.url())) {
|
||||
const authorizeUrl = await page.evaluate(() => {
|
||||
const origin = window.location.origin;
|
||||
const authority = `${USERFRONT_BASE_URL}/oidc`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: "adminfront",
|
||||
redirect_uri: `${origin}/auth/callback`,
|
||||
response_type: "code",
|
||||
scope: "openid offline_access profile email",
|
||||
state: `pw-${Date.now()}`,
|
||||
nonce: `pw-${Date.now()}`,
|
||||
code_challenge: "test-code-challenge-test-code-challenge-test",
|
||||
code_challenge_method: "plain",
|
||||
});
|
||||
return `${authority}/oauth2/auth?${params.toString()}`;
|
||||
});
|
||||
await page.goto(authorizeUrl, { waitUntil: "domcontentloaded" });
|
||||
}
|
||||
await loginViaUserFront(page);
|
||||
await ensureConsentIfNeeded(page);
|
||||
await page.waitForURL(
|
||||
/localhost:5173|\/auth\/callback|\/dashboard|\/tenants/,
|
||||
{
|
||||
timeout: 60_000,
|
||||
},
|
||||
);
|
||||
return page;
|
||||
}
|
||||
|
||||
test.describe("cross-browser session debug", () => {
|
||||
test("userfront session card should map adminfront session metadata across contexts", async ({
|
||||
browser,
|
||||
}, testInfo) => {
|
||||
ensureCredentials();
|
||||
|
||||
const userfrontContext = await browser.newContext({ locale: "ko-KR" });
|
||||
const adminfrontContext = await browser.newContext({ locale: "ko-KR" });
|
||||
|
||||
const userfrontPage = await loginUserFront(userfrontContext);
|
||||
const adminfrontPage = await loginAdminFront(adminfrontContext);
|
||||
|
||||
const sessionsPayload = await captureUserSessionsOnReload(userfrontPage);
|
||||
const items = sessionsPayload.items ?? [];
|
||||
const adminfrontItems = items.filter((item) =>
|
||||
(item.client_id ?? "").toLowerCase().includes("adminfront"),
|
||||
);
|
||||
const unknownCards = await userfrontPage
|
||||
.locator("text=세션 정보")
|
||||
.allTextContents();
|
||||
const adminFrontCards = await userfrontPage
|
||||
.locator("text=AdminFront")
|
||||
.allTextContents();
|
||||
|
||||
await testInfo.attach("user-sessions.json", {
|
||||
body: JSON.stringify(sessionsPayload, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
await testInfo.attach("card-summary.json", {
|
||||
body: JSON.stringify(
|
||||
{
|
||||
unknownCards,
|
||||
adminFrontCards,
|
||||
currentUrl: userfrontPage.url(),
|
||||
adminfrontUrl: adminfrontPage.url(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
contentType: "application/json",
|
||||
});
|
||||
|
||||
expect(adminfrontItems.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
470
baron-sso/userfront-e2e/tests/signup-theme-visibility.spec.ts
Normal file
470
baron-sso/userfront-e2e/tests/signup-theme-visibility.spec.ts
Normal file
@@ -0,0 +1,470 @@
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Rgb> {
|
||||
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<Rgb> {
|
||||
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<Rgb> {
|
||||
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<Rgb> {
|
||||
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<Rgb> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await expectBrightnessContrast(async () => {
|
||||
return {
|
||||
foreground: await sampleButtonColor(page, locator),
|
||||
background: await sampleButtonBackground(page, locator),
|
||||
};
|
||||
}, 45);
|
||||
}
|
||||
|
||||
async function sampleCheckboxBackground(page: Page, locator: Locator): Promise<Rgb> {
|
||||
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<void> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
});
|
||||
14
baron-sso/userfront-e2e/tsconfig.json
Normal file
14
baron-sso/userfront-e2e/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"types": ["node", "@playwright/test"],
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["playwright.config.ts", "tests/**/*.ts", "scripts/**/*.mjs"]
|
||||
}
|
||||
Reference in New Issue
Block a user