1
0
forked from baron/baron-sso

ci: add code check badges and coverage reports

This commit is contained in:
2026-05-29 12:05:43 +09:00
parent c489c7c38f
commit a830242947
164 changed files with 9059 additions and 2012 deletions

4
userfront-e2e/biome.json Normal file
View File

@@ -0,0 +1,4 @@
{
"root": true,
"extends": ["../common/config/biome.base.json"]
}

View File

@@ -8,9 +8,188 @@
"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": {

View File

@@ -11,9 +11,12 @@
"test:ui": "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"

View File

@@ -1,6 +1,6 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from "@playwright/test";
const port = Number.parseInt(process.env.PORT ?? '4173', 10);
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;
@@ -9,60 +9,60 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
: undefined;
export default defineConfig({
testDir: './tests',
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',
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',
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
locale: process.env.LOCALE ?? "ko-KR",
},
projects: [
{
name: 'webkit-desktop',
name: "webkit-desktop",
use: {
...devices['Desktop Safari'],
serviceWorkers: 'block',
...devices["Desktop Safari"],
serviceWorkers: "block",
},
},
{
name: 'webkit-mobile-webapp',
name: "webkit-mobile-webapp",
use: {
...devices['iPhone 13'],
serviceWorkers: 'block',
...devices["iPhone 13"],
serviceWorkers: "block",
},
},
{
name: 'chromium-desktop',
name: "chromium-desktop",
use: {
...devices['Desktop Chrome'],
serviceWorkers: 'block',
...devices["Desktop Chrome"],
serviceWorkers: "block",
},
},
{
name: 'firefox-desktop',
name: "firefox-desktop",
use: {
...devices['Desktop Firefox'],
serviceWorkers: 'block',
...devices["Desktop Firefox"],
serviceWorkers: "block",
},
},
{
name: 'chromium-mobile-webapp',
name: "chromium-mobile-webapp",
use: {
...devices['Pixel 7'],
serviceWorkers: 'block',
...devices["Pixel 7"],
serviceWorkers: "block",
},
},
],
webServer: process.env.BASE_URL
? undefined
: {
command: 'node ./scripts/serve-userfront-build.mjs',
command: "node ./scripts/serve-userfront-build.mjs",
url: defaultBaseUrl,
reuseExistingServer,
timeout: 120_000,

View File

@@ -1,53 +1,53 @@
import { createReadStream, existsSync, statSync } from 'node:fs';
import { dirname, extname, join, normalize } from 'node:path';
import { createServer } from 'node:http';
import { fileURLToPath } from 'node:url';
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'));
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',
"[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 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',
".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 url = new URL(req.url ?? "/", "http://localhost");
const pathname = decodeURIComponent(url.pathname);
if (pathname === '/' && url.search === '') {
if (pathname === "/" && url.search === "") {
res.statusCode = 302;
res.setHeader('Location', '/ko/signin');
res.setHeader('Cache-Control', 'no-cache, max-age=0, must-revalidate');
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 relative = pathname === "/" ? "/index.html" : pathname;
const candidate = normalize(join(root, relative));
if (!candidate.startsWith(root)) {
res.statusCode = 403;
res.end('Forbidden');
res.end("Forbidden");
return;
}
@@ -57,87 +57,92 @@ const server = createServer((req, res) => {
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');
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');
filePath = join(root, "index.html");
servesAppShellFallback = true;
}
const acceptsBrotli = /\bbr\b/.test(req.headers['accept-encoding'] ?? '');
const acceptsBrotli = /\bbr\b/.test(req.headers["accept-encoding"] ?? "");
const brotliPath = `${filePath}.br`;
const servedPath = acceptsBrotli && existsSync(brotliPath) ? brotliPath : filePath;
const servedPath =
acceptsBrotli && existsSync(brotliPath) ? brotliPath : filePath;
const ext = extname(filePath);
const contentType = contentTypes[ext] ?? 'application/octet-stream';
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);
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');
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');
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
if (servedPath === brotliPath) {
res.setHeader('Content-Encoding', 'br');
res.setHeader("Content-Encoding", "br");
}
if (req.headers['if-none-match'] === etag) {
if (req.headers["if-none-match"] === etag) {
res.statusCode = 304;
res.end();
return;
}
createReadStream(servedPath)
.on('error', () => {
.on("error", () => {
res.statusCode = 500;
res.end('Internal Server Error');
res.end("Internal Server Error");
})
.pipe(res);
});
function cacheControlFor(pathname, filePath, servesAppShellFallback) {
const basename = filePath.split('/').pop() ?? '';
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'
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';
return "no-cache, max-age=0, must-revalidate";
}
if (/^\/canvaskit\/.*\.(?:js|wasm)$/i.test(pathname)) {
return 'public, max-age=31536000, immutable';
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';
return "public, max-age=31536000, immutable";
}
if (/\.(?:png|ico|svg|webp|woff|woff2)$/i.test(pathname)) {
return 'public, max-age=31536000, immutable';
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";
}
return 'no-cache, max-age=0, must-revalidate';
return "no-cache, max-age=0, must-revalidate";
}
server.listen(port, '127.0.0.1', () => {
server.listen(port, "127.0.0.1", () => {
console.log(`[userfront-e2e] serving ${root} at http://127.0.0.1:${port}`);
});

View File

@@ -1,4 +1,4 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { expect, type Page, type Route, test } from "@playwright/test";
type MockOptions = {
sessionStatus?: number;
@@ -9,23 +9,23 @@ type MockOptions = {
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');
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');
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");
});
}
@@ -35,29 +35,29 @@ async function mockUserfrontApis(
): Promise<void> {
const sessionStatus = options.sessionStatus ?? 200;
await page.context().route('**/api/v1/**', async (route: Route) => {
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')) {
if (path.endsWith("/api/v1/user/me")) {
options.captureUserMe?.();
if (sessionStatus === 200) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({
id: 'e2e-user',
email: 'e2e@example.com',
name: 'E2E User',
phone: '+821012341234',
department: 'QA',
affiliationType: 'employee',
companyCode: 'BARON',
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',
id: "tenant-1",
name: "Baron",
slug: "baron",
description: "E2E tenant",
},
}),
});
@@ -66,32 +66,32 @@ async function mockUserfrontApis(
await route.fulfill({
status: sessionStatus,
contentType: 'application/json',
body: JSON.stringify({ error: 'unauthorized' }),
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
if (path.endsWith('/api/v1/user/rp/linked')) {
if (path.endsWith("/api/v1/user/rp/linked")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
return;
}
if (path.endsWith('/api/v1/audit/auth/timeline')) {
if (path.endsWith("/api/v1/audit/auth/timeline")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], next_cursor: '' }),
contentType: "application/json",
body: JSON.stringify({ items: [], next_cursor: "" }),
});
return;
}
if (path.endsWith('/api/v1/auth/qr/approve')) {
if (route.request().method() == 'POST') {
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 {
@@ -100,44 +100,55 @@ async function mockUserfrontApis(
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);
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`);
console.log(
`[E2E-MOCK] /api/v1/auth/qr/approve ${route.request().method()} request`,
);
}
await route.fulfill({
status: 200,
contentType: 'application/json',
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')
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>;
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' }),
contentType: "application/json",
body: JSON.stringify({
status: "approved",
pendingRef: "e2e-approved",
}),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({}),
});
});
@@ -145,15 +156,15 @@ async function mockUserfrontApis(
function collectClientFailures(page: Page): string[] {
const failures: string[] = [];
page.on('pageerror', (error) => {
page.on("pageerror", (error) => {
failures.push(error.message);
});
page.on('console', (message) => {
page.on("console", (message) => {
const text = message.text();
if (
message.type() === 'error' ||
message.type() === "error" ||
(/exception|verify_failed|verification failed|인증 실패/i.test(text) &&
!text.includes('Exception while loading service worker'))
!text.includes("Exception while loading service worker"))
) {
failures.push(text);
}
@@ -164,14 +175,14 @@ function collectClientFailures(page: Page): string[] {
async function makeWindowCloseNavigateToRoot(page: Page): Promise<void> {
await page.addInitScript(() => {
window.close = () => {
window.location.href = '/';
window.location.href = "/";
};
});
}
async function enableFlutterAccessibility(page: Page): Promise<void> {
await page.waitForTimeout(300);
const button = page.getByRole('button', { name: 'Enable accessibility' });
const button = page.getByRole("button", { name: "Enable accessibility" });
if (await button.count()) {
await button.first().evaluate((node) => {
(node as HTMLElement).click();
@@ -179,7 +190,7 @@ async function enableFlutterAccessibility(page: Page): Promise<void> {
await page.waitForTimeout(200);
return;
}
const placeholder = page.locator('flt-semantics-placeholder').first();
const placeholder = page.locator("flt-semantics-placeholder").first();
if (await placeholder.count()) {
await placeholder.evaluate((node) => {
(node as HTMLElement).click();
@@ -188,47 +199,51 @@ async function enableFlutterAccessibility(page: Page): Promise<void> {
}
}
test.describe('UserFront WASM auth routing', () => {
test('비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다', async ({ page }) => {
test.describe("UserFront WASM auth routing", () => {
test("비로그인 /ko 진입 시 /ko/signin 으로 리다이렉트된다", async ({
page,
}) => {
await mockUserfrontApis(page, { sessionStatus: 401 });
await page.goto('/ko');
await page.goto("/ko");
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
});
test('로그인 상태 /ko 진입 후 새로고침해도 /ko/dashboard 를 유지한다', async ({
test("로그인 상태 /ko 진입 후 새로고침해도 /ko/dashboard 를 유지한다", async ({
page,
}) => {
await seedTokenLogin(page);
await mockUserfrontApis(page);
await page.goto('/ko');
await page.goto("/ko");
await expect(page).toHaveURL(/\/ko\/dashboard$/);
await page.reload();
await expect(page).toHaveURL(/\/ko\/dashboard$/);
});
test('sessionStorage 기반 로그인 상태에서도 /ko/dashboard 를 유지한다', async ({
test("sessionStorage 기반 로그인 상태에서도 /ko/dashboard 를 유지한다", async ({
page,
}) => {
await seedSessionTokenLogin(page);
await mockUserfrontApis(page);
await page.goto('/ko');
await page.goto("/ko");
await expect(page).toHaveURL(/\/ko\/dashboard$/);
});
test('비로그인 /ko/approve 는 signin(+notice)으로 이동한다', async ({ page }) => {
test("비로그인 /ko/approve 는 signin(+notice)으로 이동한다", async ({
page,
}) => {
await mockUserfrontApis(page, { sessionStatus: 401 });
await page.goto('/ko/approve?ref=e2e-ref');
await page.goto("/ko/approve?ref=e2e-ref");
await expect(page).toHaveURL(/\/ko\/signin\?notice=qr_login_required$/);
});
test('로그인 상태 /ko/approve 는 승인 API 호출 후 dashboard로 이동한다', async ({
test("로그인 상태 /ko/approve 는 승인 API 호출 후 dashboard로 이동한다", async ({
page,
}) => {
let approvedRef: string | null = null;
@@ -240,15 +255,15 @@ test.describe('UserFront WASM auth routing', () => {
},
});
await page.goto('/ko/approve?ref=e2e-approve-ref');
await page.goto("/ko/approve?ref=e2e-approve-ref");
await expect(page).toHaveURL(/\/ko\/dashboard(?:\?.*)?$/, {
timeout: 10_000,
});
expect(approvedRef).toBe('e2e-approve-ref');
expect(approvedRef).toBe("e2e-approve-ref");
});
test('verifyOnly 승인 완료 화면의 상단 액션은 signin으로 이동시키지 않는다', async ({
test("verifyOnly 승인 완료 화면의 상단 액션은 signin으로 이동시키지 않는다", async ({
page,
}) => {
let userMeCalls = 0;
@@ -269,20 +284,20 @@ test.describe('UserFront WASM auth routing', () => {
});
await makeWindowCloseNavigateToRoot(page);
await page.goto('/ko/l/AB123456');
await page.goto("/ko/l/AB123456");
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
expect(verifyRequests[0].path).toContain(
'/api/v1/auth/login/code/verify-short',
"/api/v1/auth/login/code/verify-short",
);
expect(verifyRequests[0].body).toMatchObject({
shortCode: 'AB123456',
shortCode: "AB123456",
verifyOnly: true,
});
await page.locator('flt-glass-pane').click({
await page.locator("flt-glass-pane").click({
position: { x: 30, y: 28 },
force: true,
});
@@ -293,7 +308,7 @@ test.describe('UserFront WASM auth routing', () => {
expect(clientFailures).toEqual([]);
});
test('verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다', async ({
test("verifyOnly 승인 완료 버튼은 SMS 링크에서 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
page,
}) => {
let userMeCalls = 0;
@@ -311,25 +326,25 @@ test.describe('UserFront WASM auth routing', () => {
});
await makeWindowCloseNavigateToRoot(page);
await page.goto('/ko/l/AB123456');
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();
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
expect(userMeCalls).toBe(0);
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
expect(
clientFailures.filter(
(failure) => !failure.includes('401 (Unauthorized)'),
(failure) => !failure.includes("401 (Unauthorized)"),
),
).toEqual([]);
});
test('verifyOnly 원격 승인 완료는 로그인 창 이동 모달 CTA를 표시한다', async ({
test("verifyOnly 원격 승인 완료는 로그인 창 이동 모달 CTA를 표시한다", async ({
page,
}) => {
let verifyCalls = 0;
@@ -343,26 +358,26 @@ test.describe('UserFront WASM auth routing', () => {
});
await makeWindowCloseNavigateToRoot(page);
await page.goto('/ko/l/AB123456');
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('요청하신 로그인이 완료되었습니다'),
page.getByText("요청하신 로그인이 완료되었습니다"),
).toBeVisible();
await expect(page.getByRole('button', { name: '창 닫기' })).toHaveCount(0);
await expect(page.getByRole("button", { name: "창 닫기" })).toHaveCount(0);
await expect(
page.getByRole('button', { name: '로그인 창으로 이동하기' }),
page.getByRole("button", { name: "로그인 창으로 이동하기" }),
).toBeVisible();
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
expect(clientFailures).toEqual([]);
});
test('루트/로그인에 붙은 인증 payload는 전용 verify 라우트에서만 소비하고 완료 URL을 정리한다', async ({
test("루트/로그인에 붙은 인증 payload는 전용 verify 라우트에서만 소비하고 완료 URL을 정리한다", async ({
page,
}) => {
let userMeCalls = 0;
@@ -383,26 +398,26 @@ test.describe('UserFront WASM auth routing', () => {
});
await page.goto(
'/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop',
"/?loginId=e2e%40example.com&code=654321&pendingRef=pending-root&utm=drop",
);
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
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',
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(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 ({
test("로그인 페이지에 붙은 인증 payload도 전용 verify 라우트로 넘긴다", async ({
page,
}) => {
let userMeCalls = 0;
@@ -422,26 +437,26 @@ test.describe('UserFront WASM auth routing', () => {
},
});
await page.goto('/ko/signin?loginId=e2e%40example.com&code=999999');
await page.goto("/ko/signin?loginId=e2e%40example.com&code=999999");
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
expect(verifyRequests[0].body).toMatchObject({
loginId: 'e2e@example.com',
code: '999999',
loginId: "e2e@example.com",
code: "999999",
verifyOnly: true,
});
expect(page.url()).not.toContain('loginId=');
expect(page.url()).not.toContain('code=');
expect(page.url()).not.toContain("loginId=");
expect(page.url()).not.toContain("code=");
expect(clientFailures).toEqual([]);
});
test('verifyOnly 승인 링크를 팝업에서 닫으면 창만 닫히고 부모는 이동하지 않는다', async ({
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.',
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;
@@ -458,16 +473,16 @@ test.describe('UserFront WASM auth routing', () => {
});
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();
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');
const popupPromise = page.waitForEvent("popup");
await page.evaluate((url) => {
window.open(url, '_blank');
window.open(url, "_blank");
}, popupURL);
const popup = await popupPromise;
@@ -477,10 +492,10 @@ test.describe('UserFront WASM auth routing', () => {
if (!popup.isClosed()) {
await enableFlutterAccessibility(popup);
const closePromise = popup.waitForEvent('close').catch(() => undefined);
const closePromise = popup.waitForEvent("close").catch(() => undefined);
try {
await popup
.getByRole('button', { name: '로그인 창으로 이동하기' })
.getByRole("button", { name: "로그인 창으로 이동하기" })
.click();
} catch (error) {
if (!popup.isClosed()) {
@@ -495,7 +510,7 @@ test.describe('UserFront WASM auth routing', () => {
expect(clientFailures).toEqual([]);
});
test('verifyOnly 승인 완료 버튼은 이메일 magic link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다', async ({
test("verifyOnly 승인 완료 버튼은 이메일 magic link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
page,
}) => {
let userMeCalls = 0;
@@ -516,26 +531,26 @@ test.describe('UserFront WASM auth routing', () => {
});
await makeWindowCloseNavigateToRoot(page);
await page.goto('/ko/verify/e2e-email-token');
await page.goto("/ko/verify/e2e-email-token");
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
expect(verifyRequests[0].path).toContain('/api/v1/auth/magic-link/verify');
expect(verifyRequests[0].path).toContain("/api/v1/auth/magic-link/verify");
expect(verifyRequests[0].body).toMatchObject({
token: 'e2e-email-token',
token: "e2e-email-token",
verifyOnly: true,
});
await enableFlutterAccessibility(page);
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
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 ({
test("verifyOnly 승인 완료 버튼은 이메일 code link에서도 로그인 창으로 이동하고 user/me 조회를 만들지 않는다", async ({
page,
}) => {
let userMeCalls = 0;
@@ -557,22 +572,22 @@ test.describe('UserFront WASM auth routing', () => {
await makeWindowCloseNavigateToRoot(page);
await page.goto(
'/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-email',
"/ko/verify?loginId=e2e%40example.com&code=654321&pendingRef=pending-email",
);
await expect.poll(() => verifyRequests.length, { timeout: 10_000 }).toBe(1);
await expect(page).toHaveURL(/\/ko\/verify-complete$/);
expect(userMeCalls).toBe(0);
expect(verifyRequests[0].path).toContain('/api/v1/auth/login/code/verify');
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',
loginId: "e2e@example.com",
code: "654321",
pendingRef: "pending-email",
verifyOnly: true,
});
await enableFlutterAccessibility(page);
await page.getByRole('button', { name: '로그인 창으로 이동하기' }).click();
await page.getByRole("button", { name: "로그인 창으로 이동하기" }).click();
expect(userMeCalls).toBe(0);
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);

View File

@@ -1,11 +1,11 @@
import {
devices,
expect,
test,
type Page,
type Request,
type Response,
} from '@playwright/test';
test,
} from "@playwright/test";
type LoadMetrics = {
appOrigin: string;
@@ -18,20 +18,20 @@ type LoadMetrics = {
};
async function mockPublicApis(page: Page): Promise<void> {
await page.route('**/api/v1/**', async (route) => {
await page.route("**/api/v1/**", async (route) => {
const requestUrl = new URL(route.request().url());
if (requestUrl.pathname.endsWith('/api/v1/user/me')) {
if (requestUrl.pathname.endsWith("/api/v1/user/me")) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'unauthorized' }),
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({}),
});
});
@@ -39,7 +39,7 @@ async function mockPublicApis(page: Page): Promise<void> {
async function measureSigninLoad(page: Page): Promise<LoadMetrics> {
const appOrigin = new URL(
process.env.BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? '4173'}`,
process.env.BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? "4173"}`,
).origin;
const requestedUrls: string[] = [];
const requestedPathCounts = new Map<string, number>();
@@ -50,7 +50,7 @@ async function measureSigninLoad(page: Page): Promise<LoadMetrics> {
const onRequest = (request: Request) => {
const requestUrl = new URL(request.url());
requestedUrls.push(request.url());
if (requestUrl.protocol === 'http:' || requestUrl.protocol === 'https:') {
if (requestUrl.protocol === "http:" || requestUrl.protocol === "https:") {
const resourceKey = `${requestUrl.origin}${requestUrl.pathname}`;
requestedPathCounts.set(
resourceKey,
@@ -61,28 +61,31 @@ async function measureSigninLoad(page: Page): Promise<LoadMetrics> {
const onResponse = async (response: Response) => {
const url = new URL(response.url());
const cacheControl = response.headers()['cache-control'];
const cacheControl = response.headers()["cache-control"];
if (cacheControl) {
cacheControlByPath.set(url.pathname, cacheControl);
}
const contentEncoding = response.headers()['content-encoding'];
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);
const sizes = await response
.request()
.sizes()
.catch(() => null);
transferredBytes += sizes?.responseBodySize ?? 0;
}
};
page.on('request', onRequest);
page.on('response', onResponse);
page.on("request", onRequest);
page.on("response", onResponse);
try {
const start = performance.now();
await page.goto('/ko/signin', { waitUntil: 'networkidle' });
await page.goto("/ko/signin", { waitUntil: "networkidle" });
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
const durationMs = Math.round(performance.now() - start);
@@ -96,8 +99,8 @@ async function measureSigninLoad(page: Page): Promise<LoadMetrics> {
contentEncodingByPath,
};
} finally {
page.off('request', onRequest);
page.off('response', onResponse);
page.off("request", onRequest);
page.off("response", onResponse);
}
}
@@ -109,13 +112,13 @@ function expectNoDuplicateStaticRequests(metrics: LoadMetrics): void {
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')
!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")
);
},
);
@@ -126,41 +129,41 @@ function resolvePerformanceBudget(projectName: string): {
coldMs: number;
warmMs: number;
} {
if (projectName.includes('webkit')) {
if (projectName.includes("webkit")) {
return { coldMs: 4000, warmMs: 4000 };
}
if (projectName.includes('firefox')) {
if (projectName.includes("firefox")) {
return { coldMs: 2600, warmMs: 2800 };
}
if (projectName.includes('mobile')) {
if (projectName.includes("mobile")) {
return { coldMs: 3000, warmMs: 2300 };
}
return { coldMs: 2300, warmMs: 1500 };
}
function resolveRootRedirectBudget(projectName: string): number {
if (projectName.includes('webkit')) {
if (projectName.includes("webkit")) {
return 700;
}
if (projectName.includes('firefox')) {
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 ({
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',
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',
...devices["Pixel 7"],
locale: "ko-KR",
serviceWorkers: "allow",
});
const page = await context.newPage();
await mockPublicApis(page);
@@ -168,22 +171,23 @@ test.describe('UserFront login performance budget', () => {
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'}`,
"/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.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 ({
test("warm login page load stays within the platform budget and reuses cached assets", async ({
page,
}, testInfo) => {
await mockPublicApis(page);
@@ -209,14 +213,14 @@ test.describe('UserFront login performance budget', () => {
...warm.contentEncodingByPath,
]);
const appShellCache = cacheControlByPath.get('/ko/signin') ?? '';
expect(appShellCache).toContain('no-cache');
const appShellCache = cacheControlByPath.get("/ko/signin") ?? "";
expect(appShellCache).toContain("no-cache");
const serviceWorkerState = await page.evaluate(async () => {
if (!('serviceWorker' in navigator)) {
if (!("serviceWorker" in navigator)) {
return {
available: false,
secure: window.isSecureContext,
scriptUrl: '',
scriptUrl: "",
};
}
const registrations = await navigator.serviceWorker.getRegistrations();
@@ -225,43 +229,48 @@ test.describe('UserFront login performance budget', () => {
available: true,
secure: window.isSecureContext,
count: registrations.length,
controller: navigator.serviceWorker.controller?.scriptURL ?? '',
controller: navigator.serviceWorker.controller?.scriptURL ?? "",
scriptUrl:
registration?.active?.scriptURL ??
registration?.waiting?.scriptURL ??
registration?.installing?.scriptURL ??
'',
"",
};
});
if (testInfo.project.name.includes('mobile') && serviceWorkerState.scriptUrl) {
if (
testInfo.project.name.includes("mobile") &&
serviceWorkerState.scriptUrl
) {
expect(new URL(serviceWorkerState.scriptUrl).pathname).toBe(
'/flutter_service_worker.js',
"/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',
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(serviceWorkerState.scriptUrl).toBe("");
}
expect(cold.durationMs).toBeGreaterThanOrEqual(0);
});
test('root redirects to localized signin before Flutter boots', async ({
test("root redirects to localized signin before Flutter boots", async ({
page,
}, testInfo) => {
await mockPublicApis(page);
const requestedUrls: string[] = [];
page.on('request', (request) => {
page.on("request", (request) => {
requestedUrls.push(request.url());
});
const start = performance.now();
await page.goto('/', { waitUntil: 'domcontentloaded' });
await page.goto("/", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/ko\/signin(?:\?.*)?$/);
const durationMs = Math.round(performance.now() - start);
@@ -269,10 +278,10 @@ test.describe('UserFront login performance budget', () => {
resolveRootRedirectBudget(testInfo.project.name),
);
const rootIndex = requestedUrls.findIndex(
(url) => new URL(url).pathname === '/',
(url) => new URL(url).pathname === "/",
);
const bootstrapIndex = requestedUrls.findIndex((url) =>
new URL(url).pathname.endsWith('/flutter_bootstrap.js'),
new URL(url).pathname.endsWith("/flutter_bootstrap.js"),
);
expect(rootIndex).toBeGreaterThanOrEqual(0);
expect(bootstrapIndex).toBeGreaterThan(rootIndex);

View File

@@ -1,26 +1,26 @@
import { expect, test, type Page, type Route } from '@playwright/test';
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) => {
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')) {
if (path.endsWith("/api/v1/user/me")) {
await route.fulfill({
status: options.sessionStatus,
contentType: 'application/json',
body: JSON.stringify({ error: 'unauthorized' }),
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
if (path.endsWith('/api/v1/client-log')) {
if (path.endsWith("/api/v1/client-log")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
return;
@@ -29,23 +29,25 @@ async function mockUserfrontApisForRepro(
// Default mock for other APIs
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({}),
});
});
}
test.describe('Issue #345 Reproduction (Log-based Validation)', () => {
test('비로그인 상태에서 login_challenge와 함께 signin 진입 시 루프 없이 로그가 정상 출력되어야 한다', async ({ page }) => {
test.describe("Issue #345 Reproduction (Log-based Validation)", () => {
test("비로그인 상태에서 login_challenge와 함께 signin 진입 시 루프 없이 로그가 정상 출력되어야 한다", async ({
page,
}) => {
const logs: string[] = [];
page.on('console', msg => {
page.on("console", (msg) => {
const text = msg.text();
logs.push(text);
console.log(`[Browser] ${text}`);
});
const requests: string[] = [];
page.on('request', request => {
page.on("request", (request) => {
if (request.isNavigationRequest()) {
requests.push(request.url());
}
@@ -53,29 +55,31 @@ test.describe('Issue #345 Reproduction (Log-based Validation)', () => {
await mockUserfrontApisForRepro(page, { sessionStatus: 401 });
const targetUrl = '/ko/signin?login_challenge=repro_challenge_12345';
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'));
const signinNavigations = requests.filter((url) => url.includes("/signin"));
// [검증 1] URL 유지 확인
expect(currentUrl).toContain('login_challenge=repro_challenge_12345');
expect(currentUrl).toContain("login_challenge=repro_challenge_12345");
// [검증 2] 리다이렉트 루프 발생 여부 확인 (최초 진입 1회만 있어야 함)
expect(signinNavigations.length).toBeLessThanOrEqual(1);
// [검증 3] 핵심 로직 로그 확인 (성공의 결정적 증거)
// 이전에는 여기서 Exception이 발생했으나, 이제는 아래 로그가 찍혀야 함
const hasSuccessLog = logs.some(log =>
log.includes('[Auth] OIDC auto-accept: No active session (status: 401)')
const hasSuccessLog = logs.some((log) =>
log.includes("[Auth] OIDC auto-accept: No active session (status: 401)"),
);
expect(hasSuccessLog).toBe(true);
console.log('✅ 루프가 해결되었으며, 로그 검증을 통해 정상 동작을 확인했습니다.');
console.log(
"✅ 루프가 해결되었으며, 로그 검증을 통해 정상 동작을 확인했습니다.",
);
});
});

View File

@@ -1,4 +1,10 @@
import { expect, test, type Locator, type Page, type Route } from '@playwright/test';
import {
expect,
type Locator,
type Page,
type Route,
test,
} from "@playwright/test";
type RequestCapture = {
loginBody?: Record<string, unknown>;
@@ -7,13 +13,14 @@ type RequestCapture = {
clientLogs: string[];
};
const resetNewPasswordName = /^(새 비밀번호|ui\.userfront\.reset\.new_password)$/;
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' });
const button = page.getByRole("button", { name: "Enable accessibility" });
if (await button.count()) {
await button.first().evaluate((node) => {
(node as HTMLElement).click();
@@ -22,7 +29,7 @@ async function enableFlutterAccessibility(page: Page): Promise<void> {
return;
}
await page.waitForTimeout(300);
const placeholder = page.locator('flt-semantics-placeholder').first();
const placeholder = page.locator("flt-semantics-placeholder").first();
if (await placeholder.count()) {
await placeholder.evaluate((node) => {
(node as HTMLElement).click();
@@ -98,7 +105,7 @@ async function clickPasswordTab(page: Page): Promise<void> {
}
const coords = coordsFor(page);
await page.waitForTimeout(900);
const pane = page.locator('flt-glass-pane');
const pane = page.locator("flt-glass-pane");
await pane.click({
position: { x: coords.signinPasswordTabX, y: coords.signinTabY },
force: true,
@@ -111,12 +118,17 @@ async function clickPasswordTab(page: Page): Promise<void> {
await page.waitForTimeout(200);
}
async function fillAt(page: Page, x: number, y: number, value: string): Promise<void> {
const pane = page.locator('flt-glass-pane');
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.press("Control+A");
await page.keyboard.press("Backspace");
await page.keyboard.type(value);
}
@@ -127,8 +139,8 @@ async function typeIntoAccessibleField(
): 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.press("Control+A");
await page.keyboard.press("Backspace");
await page.keyboard.type(value);
}
@@ -139,7 +151,7 @@ async function fillPasswordLoginForm(
): Promise<void> {
if (isMobileProject(page)) {
await enableFlutterAccessibility(page);
const inputs = page.getByRole('textbox');
const inputs = page.getByRole("textbox");
await inputs.nth(0).fill(loginId);
await inputs.nth(1).fill(password);
return;
@@ -152,32 +164,47 @@ async function fillPasswordLoginForm(
async function submitPasswordLogin(page: Page): Promise<void> {
if (isMobileProject(page)) {
await enableFlutterAccessibility(page);
await page.getByRole('button', { name: '로그인' }).click({ force: true });
await page.getByRole("button", { name: "로그인" }).click({ force: true });
return;
}
await page.keyboard.press('Enter');
await page.keyboard.press("Enter");
}
async function fillResetPasswordForm(page: Page, password: string): Promise<void> {
async function fillResetPasswordForm(
page: Page,
password: string,
): Promise<void> {
await enableFlutterAccessibility(page);
const newPasswordInput = page.getByRole('textbox', {
const newPasswordInput = page.getByRole("textbox", {
name: resetNewPasswordName,
});
const confirmPasswordInput = page.getByRole('textbox', {
const confirmPasswordInput = page.getByRole("textbox", {
name: resetConfirmPasswordName,
});
if ((await newPasswordInput.count()) > 0 && (await confirmPasswordInput.count()) > 0) {
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);
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.resetNewPasswordX,
coords.resetNewPasswordY,
password,
);
await fillAt(
page,
coords.resetConfirmPasswordX,
@@ -188,7 +215,9 @@ async function fillResetPasswordForm(page: Page, password: string): Promise<void
async function submitResetPassword(page: Page): Promise<void> {
await enableFlutterAccessibility(page);
const submitButton = page.getByRole('button', { name: resetSubmitButtonName });
const submitButton = page.getByRole("button", {
name: resetSubmitButtonName,
});
if ((await submitButton.count()) > 0) {
await submitButton.click({ force: true });
return;
@@ -197,32 +226,35 @@ async function submitResetPassword(page: Page): Promise<void> {
return;
}
const coords = coordsFor(page);
await page.locator('flt-glass-pane').click({
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) => {
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')) {
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!') {
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',
contentType: "application/json",
body: JSON.stringify({
sessionJwt: 'e30.e30.e30',
provider: 'ory',
sessionJwt: "e30.e30.e30",
provider: "ory",
}),
});
return;
@@ -230,16 +262,16 @@ async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void>
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'password_or_email_mismatch' }),
contentType: "application/json",
body: JSON.stringify({ error: "password_or_email_mismatch" }),
});
return;
}
if (path.endsWith('/api/v1/auth/password/policy')) {
if (path.endsWith("/api/v1/auth/password/policy")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({
minLength: 12,
minCharacterTypes: 3,
@@ -252,21 +284,21 @@ async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void>
return;
}
if (path.endsWith('/api/v1/auth/password/reset/complete')) {
if (path.endsWith("/api/v1/auth/password/reset/complete")) {
capture.resetBody = (route.request().postDataJSON() ?? {}) as Record<
string,
unknown
>;
capture.resetToken = requestUrl.searchParams.get('token');
capture.resetToken = requestUrl.searchParams.get("token");
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'ok' }),
contentType: "application/json",
body: JSON.stringify({ status: "ok" }),
});
return;
}
if (path.endsWith('/api/v1/client-log')) {
if (path.endsWith("/api/v1/client-log")) {
const payload = (route.request().postDataJSON() ?? {}) as {
message?: string;
};
@@ -275,108 +307,112 @@ async function mockAuthApis(page: Page, capture: RequestCapture): Promise<void>
}
await route.fulfill({
status: 200,
contentType: 'application/json',
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 ')) {
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' }),
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({
id: 'e2e-user',
email: 'e2e@example.com',
name: 'E2E User',
phone: '+821012341234',
department: 'QA',
affiliationType: 'employee',
companyCode: 'BARON',
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',
id: "tenant-1",
name: "Baron",
slug: "baron",
description: "E2E tenant",
},
}),
});
return;
}
if (path.endsWith('/api/v1/user/rp/linked')) {
if (path.endsWith("/api/v1/user/rp/linked")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
return;
}
if (path.endsWith('/api/v1/audit/auth/timeline')) {
if (path.endsWith("/api/v1/audit/auth/timeline")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], next_cursor: '' }),
contentType: "application/json",
body: JSON.stringify({ items: [], next_cursor: "" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
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.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.',
"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 page.goto("/ko/signin");
await clickPasswordTab(page);
await fillPasswordLoginForm(page, 'e2e@example.com', 'ValidPass1!');
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!');
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'),
window.localStorage.getItem("baron_auth_token"),
);
expect(storedToken).toBe('e30.e30.e30');
expect(storedToken).toBe("e30.e30.e30");
});
test('비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다', async ({ page }) => {
test("비밀번호 로그인 실패 시 에러 코드를 사용자에게 표시한다", async ({
page,
}) => {
test.skip(
isMobileProject(page),
'Mobile webapp keeps dedicated auth-routing coverage; password form canvas automation is covered on desktop.',
"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 page.goto("/ko/signin");
await clickPasswordTab(page);
await fillPasswordLoginForm(page, 'e2e@example.com', 'WrongPass1!');
await fillPasswordLoginForm(page, "e2e@example.com", "WrongPass1!");
await submitPasswordLogin(page);
await expect(page).toHaveURL(/\/ko\/signin$/);
@@ -384,36 +420,37 @@ test.describe('UserFront WASM password login and reset', () => {
.poll(
() =>
capture.clientLogs.some((message) =>
message.includes('password_or_email_mismatch'),
message.includes("password_or_email_mismatch"),
),
{ timeout: 10000 },
)
.toBe(true);
});
test('reset-password에서 변경 성공 시 signin으로 이동한다', async ({ page }) => {
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.url().includes("/api/v1/auth/password/policy") &&
response.status() === 200,
);
await page.goto('/ko/reset-password?token=reset-token-e2e');
await page.goto("/ko/reset-password?token=reset-token-e2e");
await policyLoaded;
await page.waitForTimeout(900);
await fillResetPasswordForm(page, 'ValidPass1!A');
await fillResetPasswordForm(page, "ValidPass1!A");
await submitResetPassword(page);
await expect
.poll(
() => capture.resetBody?.newPassword as string | undefined,
{ timeout: 10000 },
)
.toBe('ValidPass1!A');
.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');
expect(capture.resetToken).toBe("reset-token-e2e");
expect(capture.resetBody?.newPassword).toBe("ValidPass1!A");
});
});

View File

@@ -1,4 +1,4 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { expect, type Page, type Route, test } from "@playwright/test";
type ProfileState = {
department: string;
@@ -7,7 +7,7 @@ type ProfileState = {
};
async function enableFlutterAccessibility(page: Page): Promise<void> {
const button = page.getByRole('button', { name: 'Enable accessibility' });
const button = page.getByRole("button", { name: "Enable accessibility" });
if (await button.count()) {
await button.click({ force: true }).catch(async () => {
await page
@@ -59,26 +59,31 @@ function isMobileProject(page: Page): boolean {
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');
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');
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');
await page.keyboard.press("End");
for (let index = 0; index < 64; index += 1) {
await page.keyboard.press('Backspace');
await page.keyboard.press("Backspace");
}
if (value !== '') {
if (value !== "") {
await page.keyboard.insertText(value);
}
await page.waitForTimeout(100);
@@ -89,8 +94,12 @@ type BoxCenter = {
y: number;
};
async function resolveLocatorCenter(locator: ReturnType<Page['locator']>): Promise<BoxCenter | null> {
const handle = await locator.elementHandle({ timeout: 1_000 }).catch(() => null);
async function resolveLocatorCenter(
locator: ReturnType<Page["locator"]>,
): Promise<BoxCenter | null> {
const handle = await locator
.elementHandle({ timeout: 1_000 })
.catch(() => null);
if (!handle) {
return null;
}
@@ -115,11 +124,14 @@ async function resolveLocatorCenter(locator: ReturnType<Page['locator']>): Promi
};
}
async function clickGlassPaneAt(page: Page, center: BoxCenter | null): Promise<boolean> {
async function clickGlassPaneAt(
page: Page,
center: BoxCenter | null,
): Promise<boolean> {
if (!center) {
return false;
}
await page.locator('flt-glass-pane').click({
await page.locator("flt-glass-pane").click({
position: center,
force: true,
});
@@ -128,22 +140,25 @@ async function clickGlassPaneAt(page: Page, center: BoxCenter | null): Promise<b
}
async function departmentTextboxIsOpen(page: Page): Promise<boolean> {
return (await page.getByRole('textbox', { name: '소속' }).count()) > 0;
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: '소속' });
.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 })
.evaluate(
(element) => {
if (element instanceof HTMLElement) {
element.click();
}
},
{ timeout: 1_000 },
)
.catch(() => undefined);
await page.waitForTimeout(200);
if (await departmentTextboxIsOpen(page)) {
@@ -153,14 +168,16 @@ async function openDepartmentEditor(page: Page): Promise<void> {
if (await departmentTextboxIsOpen(page)) {
return;
}
await accessibleEditor.click({ force: true, timeout: 1_000 }).catch(() => undefined);
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.');
throw new Error("Department editor accessibility button was not found.");
}
const coords = coordsFor(page);
const viewport = page.viewportSize();
@@ -180,17 +197,17 @@ async function openDepartmentEditor(page: Page): Promise<void> {
}
async function blurDepartmentEditor(page: Page): Promise<void> {
const textbox = page.getByRole('textbox', { name: '소속' });
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.');
throw new Error("Department textbox was not found.");
}
const coords = coordsFor(page);
await page.locator('flt-glass-pane').click({
await page.locator("flt-glass-pane").click({
position: { x: coords.blurX, y: coords.blurY },
force: true,
});
@@ -198,21 +215,21 @@ async function blurDepartmentEditor(page: Page): Promise<void> {
}
async function submitDepartmentEditor(page: Page): Promise<void> {
const textbox = page.getByRole('textbox', { name: '소속' });
const textbox = page.getByRole("textbox", { name: "소속" });
if ((await textbox.count()) > 0) {
await textbox.press('Enter');
await textbox.press("Enter");
await page.waitForTimeout(250);
return;
}
if (isMobileProject(page)) {
throw new Error('Department textbox was not found.');
throw new Error("Department textbox was not found.");
}
await page.keyboard.press('Enter');
await page.keyboard.press("Enter");
await page.waitForTimeout(250);
}
async function fillDepartmentField(page: Page, value: string): Promise<void> {
const textbox = page.getByRole('textbox', { name: '소속' });
const textbox = page.getByRole("textbox", { name: "소속" });
if (!isMobileProject(page)) {
if ((await textbox.count()) > 0) {
await textbox.click({ force: true });
@@ -230,92 +247,92 @@ async function fillDepartmentField(page: Page, value: string): Promise<void> {
return;
}
if (isMobileProject(page)) {
throw new Error('Department textbox was not found.');
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) => {
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 ')) {
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' }),
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
state.getMeCount += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({
id: 'e2e-user',
email: 'e2e@example.com',
name: 'E2E User',
phone: '+821012341234',
id: "e2e-user",
email: "e2e@example.com",
name: "E2E User",
phone: "+821012341234",
department: state.department,
affiliationType: 'employee',
companyCode: 'BARON',
affiliationType: "employee",
companyCode: "BARON",
tenant: {
id: 'tenant-1',
name: 'Baron',
slug: 'baron',
description: 'E2E tenant',
id: "tenant-1",
name: "Baron",
slug: "baron",
description: "E2E tenant",
},
}),
});
return;
}
if (path.endsWith('/api/v1/user/me') && method === 'PUT') {
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 !== '') {
const nextDepartment = String(body.department ?? "").trim();
if (nextDepartment !== "") {
state.department = nextDepartment;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({
status: 'success',
updatedAt: '2026-02-24T00:00:00Z',
status: "success",
updatedAt: "2026-02-24T00:00:00Z",
}),
});
return;
}
if (path.endsWith('/api/v1/user/rp/linked')) {
if (path.endsWith("/api/v1/user/rp/linked")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
return;
}
if (path.endsWith('/api/v1/audit/auth/timeline')) {
if (path.endsWith("/api/v1/audit/auth/timeline")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], next_cursor: '' }),
contentType: "application/json",
body: JSON.stringify({ items: [], next_cursor: "" }),
});
return;
}
if (path.endsWith('/api/v1/client-log')) {
if (path.endsWith("/api/v1/client-log")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
return;
@@ -323,14 +340,14 @@ async function mockProfileApis(page: Page, state: ProfileState): Promise<void> {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
});
}
async function openProfilePage(page: Page): Promise<void> {
await page.goto('/ko/profile');
await page.goto("/ko/profile");
await expect(page).toHaveURL(/\/ko\/profile$/);
await enableFlutterAccessibility(page);
await page.waitForTimeout(1200);
@@ -340,22 +357,22 @@ 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.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.',
({ 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/**');
await page.unroute("**/api/v1/**");
});
test('소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다', async ({
test("소속 수정 후 명시 저장하면 저장 요청이 전송되고 새로고침 후 최신 값으로 재조회된다", async ({
page,
}) => {
const state: ProfileState = {
department: 'QA',
department: "QA",
getMeCount: 0,
putBodies: [],
};
@@ -365,24 +382,26 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillDepartmentField(page, 'QA-Updated');
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');
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);
await expect
.poll(() => state.getMeCount)
.toBeGreaterThan(getCountBeforeReload);
});
test('소속 입력 후 즉시 새로고침해도 저장 요청이 중복 전송되지 않는다', async ({
test("소속 입력 후 즉시 새로고침해도 저장 요청이 중복 전송되지 않는다", async ({
page,
}) => {
const state: ProfileState = {
department: 'QA',
department: "QA",
getMeCount: 0,
putBodies: [],
};
@@ -392,24 +411,24 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillDepartmentField(page, 'QA-Repro');
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');
expect(state.putBodies[0]?.department).toBe("QA-Repro");
expect(state.department).toBe("QA-Repro");
return;
}
expect(state.department).toBe('QA');
expect(state.department).toBe("QA");
});
test('소속에 기존값을 그대로 입력하고 포커스 아웃하면 저장 요청이 전송되지 않는다', async ({
test("소속에 기존값을 그대로 입력하고 포커스 아웃하면 저장 요청이 전송되지 않는다", async ({
page,
}) => {
const state: ProfileState = {
department: 'QA',
department: "QA",
getMeCount: 0,
putBodies: [],
};
@@ -419,15 +438,17 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillDepartmentField(page, 'QA');
await fillDepartmentField(page, "QA");
await blurDepartmentEditor(page);
expect(state.putBodies).toHaveLength(0);
});
test('소속을 빈 값으로 입력하면 저장 요청이 전송되지 않는다', async ({ page }) => {
test("소속을 빈 값으로 입력하면 저장 요청이 전송되지 않는다", async ({
page,
}) => {
const state: ProfileState = {
department: 'QA',
department: "QA",
getMeCount: 0,
putBodies: [],
};
@@ -437,16 +458,18 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillDepartmentField(page, '');
await fillDepartmentField(page, "");
await blurDepartmentEditor(page);
expect(state.putBodies).toHaveLength(0);
expect(state.department).toBe('QA');
expect(state.department).toBe("QA");
});
test('소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다', async ({ page }) => {
test("소속을 저장한 뒤 새로고침 후 다시 저장해도 저장 요청이 누락되지 않는다", async ({
page,
}) => {
const state: ProfileState = {
department: 'QA',
department: "QA",
getMeCount: 0,
putBodies: [],
};
@@ -456,7 +479,7 @@ test.describe('UserFront WASM profile department editing', () => {
await waitForInitialProfileLoad(state);
await openDepartmentEditor(page);
await fillDepartmentField(page, 'QA-1');
await fillDepartmentField(page, "QA-1");
await submitDepartmentEditor(page);
await expect.poll(() => state.putBodies.length).toBe(1);
@@ -464,16 +487,18 @@ test.describe('UserFront WASM profile department editing', () => {
await page.reload();
await expect(page).toHaveURL(/\/ko\/profile$/);
await enableFlutterAccessibility(page);
await expect.poll(() => state.getMeCount).toBeGreaterThan(getCountBeforeReload);
await expect
.poll(() => state.getMeCount)
.toBeGreaterThan(getCountBeforeReload);
await page.waitForTimeout(1200);
await openDepartmentEditor(page);
await fillDepartmentField(page, 'QA-2');
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');
expect(state.putBodies[0]?.department).toBe("QA-1");
expect(state.putBodies[1]?.department).toBe("QA-2");
expect(state.department).toBe("QA-2");
});
});

View File

@@ -1,39 +1,39 @@
import { expect, test, type Page, type Route } from '@playwright/test';
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');
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) => {
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 ')) {
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',
contentType: "application/json",
body: JSON.stringify({
id: 'e2e-user',
email: 'e2e@example.com',
name: 'E2E User',
phone: '+821012341234',
department: 'QA',
affiliationType: 'employee',
companyCode: 'BARON',
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',
id: "tenant-1",
name: "Baron",
slug: "baron",
description: "E2E tenant",
},
}),
});
@@ -42,34 +42,34 @@ async function mockInventoryApis(page: Page): Promise<void> {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'unauthorized' }),
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
if (path.endsWith('/api/v1/user/rp/linked')) {
if (path.endsWith("/api/v1/user/rp/linked")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({ items: [] }),
});
return;
}
if (path.endsWith('/api/v1/audit/auth/timeline')) {
if (path.endsWith("/api/v1/audit/auth/timeline")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ items: [], next_cursor: '' }),
contentType: "application/json",
body: JSON.stringify({ items: [], next_cursor: "" }),
});
return;
}
if (path.endsWith('/api/v1/auth/password/policy')) {
if (path.endsWith("/api/v1/auth/password/policy")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({
minLength: 12,
minCharacterTypes: 3,
@@ -82,46 +82,46 @@ async function mockInventoryApis(page: Page): Promise<void> {
return;
}
if (path.endsWith('/api/v1/auth/magic-link/verify')) {
if (path.endsWith("/api/v1/auth/magic-link/verify")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'approved' }),
contentType: "application/json",
body: JSON.stringify({ status: "approved" }),
});
return;
}
if (path.endsWith('/api/v1/auth/login/code/verify')) {
if (path.endsWith("/api/v1/auth/login/code/verify")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'approved' }),
contentType: "application/json",
body: JSON.stringify({ status: "approved" }),
});
return;
}
if (path.endsWith('/api/v1/auth/login/code/verify-short')) {
if (path.endsWith("/api/v1/auth/login/code/verify-short")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ status: 'approved' }),
contentType: "application/json",
body: JSON.stringify({ status: "approved" }),
});
return;
}
if (path.endsWith('/api/v1/auth/consent') && method === 'GET') {
if (path.endsWith("/api/v1/auth/consent") && method === "GET") {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({
client: {
client_name: 'E2E Client',
client_id: 'e2e-client',
client_name: "E2E Client",
client_id: "e2e-client",
},
requested_scope: ['openid'],
requested_scope: ["openid"],
scope_details: {
openid: {
description: 'OpenID',
description: "OpenID",
mandatory: true,
},
},
@@ -130,19 +130,19 @@ async function mockInventoryApis(page: Page): Promise<void> {
return;
}
if (path.endsWith('/api/v1/auth/qr/approve')) {
if (path.endsWith("/api/v1/auth/qr/approve")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
return;
}
if (path.endsWith('/api/v1/client-log')) {
if (path.endsWith("/api/v1/client-log")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
return;
@@ -150,182 +150,186 @@ async function mockInventoryApis(page: Page): Promise<void> {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({}),
});
});
}
test.describe('UserFront WASM route inventory (unauth)', () => {
test.describe("UserFront WASM route inventory (unauth)", () => {
test.beforeEach(async ({ page }) => {
await mockInventoryApis(page);
});
test('route: /', async ({ page }) => {
await page.goto('/');
test("route: /", async ({ page }) => {
await page.goto("/");
await expect(page).toHaveURL(/\/(ko|en)\/signin(?:\?.*)?$/);
});
test('route: /ko', async ({ page }) => {
await page.goto('/ko');
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');
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');
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');
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');
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');
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');
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');
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');
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');
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');
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');
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');
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');
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');
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');
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/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');
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');
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');
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/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');
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');
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.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');
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');
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');
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');
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');
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 ({
test("route: /ko/approve?ref=... -> /ko/dashboard", async ({
page,
}, testInfo) => {
await page.goto('/ko/approve?ref=e2e-ref');
await page.goto("/ko/approve?ref=e2e-ref");
await expect(page).toHaveURL(/\/ko\/dashboard$/, {
timeout: testInfo.project.name === 'webkit-desktop' ? 15_000 : 5_000,
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');
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,
timeout: testInfo.project.name === "webkit-desktop" ? 15_000 : 5_000,
});
});
});

View File

@@ -1,45 +1,45 @@
import { readFileSync, writeFileSync } from "node:fs";
import { inflateSync } from "node:zlib";
import {
expect,
test,
type BrowserContext,
expect,
type Page,
type TestInfo,
} from '@playwright/test';
import { readFileSync, writeFileSync } from 'node:fs';
import { inflateSync } from 'node:zlib';
test,
} from "@playwright/test";
const lightweightTestFont = readFileSync(
new URL('../fixtures/fonts/NotoSansKR-TestSubset.woff2', import.meta.url),
new URL("../fixtures/fonts/NotoSansKR-TestSubset.woff2", import.meta.url),
);
type SigninCase = {
path: '/ko/signin' | '/en/signin';
theme: 'light' | 'dark';
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' },
{ 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')) {
if (requestUrl.pathname.endsWith("/api/v1/user/me")) {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: 'unauthorized' }),
contentType: "application/json",
body: JSON.stringify({ error: "unauthorized" }),
});
return;
}
if (requestUrl.pathname.endsWith('/api/v1/auth/tenant-info')) {
if (requestUrl.pathname.endsWith("/api/v1/auth/tenant-info")) {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({}),
});
return;
@@ -47,21 +47,23 @@ async function mockPublicApis(context: BrowserContext): Promise<void> {
await route.fulfill({
status: 200,
contentType: 'application/json',
contentType: "application/json",
body: JSON.stringify({ ok: true }),
});
});
}
async function routeLightweightTestFonts(context: BrowserContext): Promise<void> {
await context.route('https://fonts.gstatic.com/**', async (route) => {
async function routeLightweightTestFonts(
context: BrowserContext,
): Promise<void> {
await context.route("https://fonts.gstatic.com/**", async (route) => {
await route.fulfill({
status: 200,
contentType: 'font/woff2',
contentType: "font/woff2",
body: lightweightTestFont,
headers: {
'access-control-allow-origin': '*',
'cache-control': 'public, max-age=31536000, immutable',
"access-control-allow-origin": "*",
"cache-control": "public, max-age=31536000, immutable",
},
});
});
@@ -71,21 +73,26 @@ async function expectFlutterCanvasRendered(
page: Page,
timeoutMs = 10_000,
): Promise<void> {
await expect(page.locator('#baron-bootstrap-shell')).toBeHidden({
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,
})
.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');
const shell = page.locator("#baron-bootstrap-shell");
await expect(shell).toBeVisible({ timeout: 1_000 });
await expect(shell).toContainText(/Baron SW Portal/);
}
@@ -96,9 +103,9 @@ async function expectSigninSurfaceWithinBudget(
entry: SigninCase,
): Promise<void> {
await seedAuthState(page, entry);
await page.goto(entry.path, { waitUntil: 'domcontentloaded' });
await page.goto(entry.path, { waitUntil: "domcontentloaded" });
const slug = `${entry.path.slice(1).replace('/', '-')}-${entry.theme}`;
const slug = `${entry.path.slice(1).replace("/", "-")}-${entry.theme}`;
let paintedAtMs: number | null = null;
let previousElapsedMs = 0;
for (const elapsedMs of [500, 1000]) {
@@ -106,7 +113,9 @@ async function expectSigninSurfaceWithinBudget(
previousElapsedMs = elapsedMs;
const screenshot = await captureFlutterCanvasPng(
page,
testInfo.outputPath(`${testInfo.project.name}-${slug}-${elapsedMs}ms.png`),
testInfo.outputPath(
`${testInfo.project.name}-${slug}-${elapsedMs}ms.png`,
),
);
if (
paintedAtMs === null &&
@@ -129,7 +138,7 @@ async function captureFlutterCanvasPng(
path?: string,
): Promise<Buffer | null> {
const dataUrl = await page.evaluate(() => {
const canvas = Array.from(document.querySelectorAll('canvas'))
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;
@@ -138,16 +147,16 @@ async function captureFlutterCanvasPng(
return null;
}
try {
return canvas.toDataURL('image/png');
return canvas.toDataURL("image/png");
} catch {
return null;
}
});
if (dataUrl?.startsWith('data:image/png;base64,')) {
if (dataUrl?.startsWith("data:image/png;base64,")) {
const screenshot = Buffer.from(
dataUrl.slice('data:image/png;base64,'.length),
'base64',
dataUrl.slice("data:image/png;base64,".length),
"base64",
);
if (path) {
writeFileSync(path, screenshot);
@@ -197,7 +206,9 @@ function screenshotHasSigninPaint(buffer: Buffer): boolean {
}
}
return sampled > 0 && nonWhite / sampled > 0.02 && dark > 12 && buttonBlue > 12;
return (
sampled > 0 && nonWhite / sampled > 0.02 && dark > 12 && buttonBlue > 12
);
}
function decodePng(buffer: Buffer): {
@@ -205,9 +216,9 @@ function decodePng(buffer: Buffer): {
height: number;
pixels: Uint8Array;
} {
const signature = buffer.subarray(0, 8).toString('hex');
if (signature !== '89504e470d0a1a0a') {
throw new Error('invalid png signature');
const signature = buffer.subarray(0, 8).toString("hex");
if (signature !== "89504e470d0a1a0a") {
throw new Error("invalid png signature");
}
let offset = 8;
@@ -218,23 +229,25 @@ function decodePng(buffer: Buffer): {
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.subarray(offset + 4, offset + 8).toString('ascii');
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') {
if (type === "IHDR") {
width = data.readUInt32BE(0);
height = data.readUInt32BE(4);
colorType = data[9];
} else if (type === 'IDAT') {
} else if (type === "IDAT") {
idat.push(data);
} else if (type === 'IEND') {
} else if (type === "IEND") {
break;
}
}
if (!width || !height || ![2, 6].includes(colorType)) {
throw new Error(`unsupported png format: ${width}x${height}, color=${colorType}`);
throw new Error(
`unsupported png format: ${width}x${height}, color=${colorType}`,
);
}
const bytesPerPixel = colorType === 6 ? 4 : 3;
@@ -249,7 +262,8 @@ function decodePng(buffer: Buffer): {
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 left =
x >= bytesPerPixel ? raw[targetOffset + x - bytesPerPixel] : 0;
const up = y > 0 ? raw[targetOffset + x - stride] : 0;
const upLeft =
y > 0 && x >= bytesPerPixel
@@ -313,27 +327,30 @@ function paeth(left: number, up: number, upLeft: number): number {
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 });
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.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 ({
test("first paint exposes bootstrap shell before Flutter renders", async ({
page,
}, testInfo) => {
await page.goto('/ko/signin', { waitUntil: 'domcontentloaded' });
await page.goto("/ko/signin", { waitUntil: "domcontentloaded" });
await expectBootstrapShellVisible(page);
await page.screenshot({
path: testInfo.outputPath('mobile-first-paint-ko.png'),
path: testInfo.outputPath("mobile-first-paint-ko.png"),
fullPage: true,
});
});
@@ -351,26 +368,27 @@ test.describe('UserFront signin runtime matrix', () => {
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.',
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 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 ({
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');
test.skip(!expectedBackendOrigin, "set EXPECTED_BACKEND_ORIGIN");
const requestedApiOrigins = new Set<string>();
page.on('request', (request) => {
page.on("request", (request) => {
const requestUrl = new URL(request.url());
if (requestUrl.pathname.startsWith('/api/v1/')) {
if (requestUrl.pathname.startsWith("/api/v1/")) {
requestedApiOrigins.add(requestUrl.origin);
}
});
@@ -382,35 +400,37 @@ test.describe('UserFront signin runtime matrix', () => {
await expect
.poll(() => [...requestedApiOrigins], { timeout: 30_000 })
.toContain(expectedBackendOrigin);
expect(requestedApiOrigins).not.toContain('https://sso.example.test');
expect(requestedApiOrigins).not.toContain("https://sso.example.test");
}
});
test('Korean signin renders with test-only lightweight web font', async ({
test("Korean signin renders with test-only lightweight web font", async ({
context,
page,
}, testInfo) => {
if (testInfo.project.name === 'webkit-desktop') {
if (testInfo.project.name === "webkit-desktop") {
await routeLightweightTestFonts(context);
}
const requestedUrls: string[] = [];
page.on('request', (request) => {
page.on("request", (request) => {
requestedUrls.push(request.url());
});
await seedAuthState(page, { path: '/ko/signin', theme: 'light' });
await page.goto('/ko/signin', { waitUntil: 'domcontentloaded' });
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`),
path: testInfo.outputPath(
`${testInfo.project.name}-ko-signin-korean-font.png`,
),
fullPage: true,
});
expect(requestedUrls).toContainEqual(
expect.stringContaining('https://fonts.gstatic.com/'),
expect.stringContaining("https://fonts.gstatic.com/"),
);
expect(requestedUrls).not.toContainEqual(
expect.stringContaining('/assets/assets/fonts/NotoSansKR-Regular.ttf'),
expect.stringContaining("/assets/assets/fonts/NotoSansKR-Regular.ttf"),
);
});
});

View File

@@ -1,9 +1,10 @@
import { expect, test, type BrowserContext, type Page } from '@playwright/test';
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 ?? '';
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<{
@@ -18,20 +19,20 @@ type SessionApiResponse = {
function ensureCredentials(): void {
if (!LOGIN_ID || !PASSWORD) {
test.skip(true, 'E2E credentials are required');
test.skip(true, "E2E credentials are required");
}
}
async function enableFlutterAccessibility(page: Page): Promise<void> {
await page.waitForTimeout(300);
const button = page.getByRole('button', { name: 'Enable accessibility' });
const button = page.getByRole("button", { name: "Enable accessibility" });
if (await button.count()) {
try {
await button.click({ force: true });
} catch {
return;
}
const placeholder = page.locator('flt-semantics-placeholder');
const placeholder = page.locator("flt-semantics-placeholder");
if (await placeholder.count()) {
await placeholder.first().click({ force: true });
}
@@ -41,7 +42,7 @@ async function enableFlutterAccessibility(page: Page): Promise<void> {
async function clickPasswordTab(page: Page): Promise<void> {
await page.waitForTimeout(900);
const pane = page.locator('flt-glass-pane');
const pane = page.locator("flt-glass-pane");
await pane.click({
position: { x: 522, y: 158 },
force: true,
@@ -54,20 +55,27 @@ async function clickPasswordTab(page: Page): Promise<void> {
await page.waitForTimeout(200);
}
async function fillAt(page: Page, x: number, y: number, value: string): Promise<void> {
const pane = page.locator('flt-glass-pane');
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.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 loginIdInput = page.getByPlaceholder(
/이메일 또는 휴대폰 번호|email|phone/i,
);
const passwordInput = page.getByPlaceholder(/비밀번호|password/i);
const submitButton = page.getByRole('button', { name: /로그인|Login/i });
const submitButton = page.getByRole("button", { name: /로그인|Login/i });
if ((await loginIdInput.count()) >= 1 && (await passwordInput.count()) >= 1) {
await loginIdInput.first().fill(LOGIN_ID);
@@ -79,7 +87,7 @@ async function loginViaUserFront(page: Page): Promise<void> {
await clickPasswordTab(page);
await fillAt(page, 640, 245, LOGIN_ID);
await fillAt(page, 640, 311, PASSWORD);
await page.locator('flt-glass-pane').click({
await page.locator("flt-glass-pane").click({
position: { x: 640, y: 381 },
force: true,
});
@@ -91,7 +99,7 @@ async function ensureConsentIfNeeded(page: Page): Promise<void> {
}
const allowButton = page
.getByRole('button')
.getByRole("button")
.filter({ hasText: /허용|동의|Accept|Allow/i })
.first();
@@ -100,15 +108,17 @@ async function ensureConsentIfNeeded(page: Page): Promise<void> {
}
}
async function captureUserSessionsOnReload(page: Page): Promise<SessionApiResponse> {
async function captureUserSessionsOnReload(
page: Page,
): Promise<SessionApiResponse> {
const responsePromise = page.waitForResponse(
(response) =>
response.request().method() === 'GET' &&
response.url().includes('/api/v1/user/sessions'),
response.request().method() === "GET" &&
response.url().includes("/api/v1/user/sessions"),
{ timeout: 30_000 },
);
await page.reload({ waitUntil: 'domcontentloaded' });
await page.reload({ waitUntil: "domcontentloaded" });
const response = await responsePromise;
return (await response.json()) as SessionApiResponse;
}
@@ -116,7 +126,7 @@ async function captureUserSessionsOnReload(page: Page): Promise<SessionApiRespon
async function loginUserFront(context: BrowserContext): Promise<Page> {
const page = await context.newPage();
await page.goto(`${USERFRONT_BASE_URL}/ko/signin`, {
waitUntil: 'domcontentloaded',
waitUntil: "domcontentloaded",
});
await loginViaUserFront(page);
await expect(page).toHaveURL(/\/ko\/dashboard/, { timeout: 60_000 });
@@ -125,8 +135,10 @@ async function loginUserFront(context: BrowserContext): Promise<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 });
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);
@@ -136,35 +148,38 @@ async function loginAdminFront(context: BrowserContext): Promise<Page> {
const origin = window.location.origin;
const authority = `${USERFRONT_BASE_URL}/oidc`;
const params = new URLSearchParams({
client_id: 'adminfront',
client_id: "adminfront",
redirect_uri: `${origin}/auth/callback`,
response_type: 'code',
scope: 'openid offline_access profile email',
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',
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 page.goto(authorizeUrl, { waitUntil: "domcontentloaded" });
}
await loginViaUserFront(page);
await ensureConsentIfNeeded(page);
await page.waitForURL(/localhost:5173|\/auth\/callback|\/dashboard|\/tenants/, {
timeout: 60_000,
});
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 ({
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 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);
@@ -172,16 +187,20 @@ test.describe('cross-browser session debug', () => {
const sessionsPayload = await captureUserSessionsOnReload(userfrontPage);
const items = sessionsPayload.items ?? [];
const adminfrontItems = items.filter((item) =>
(item.client_id ?? '').toLowerCase().includes('adminfront'),
(item.client_id ?? "").toLowerCase().includes("adminfront"),
);
const unknownCards = await userfrontPage.locator('text=세션 정보').allTextContents();
const adminFrontCards = await userfrontPage.locator('text=AdminFront').allTextContents();
const unknownCards = await userfrontPage
.locator("text=세션 정보")
.allTextContents();
const adminFrontCards = await userfrontPage
.locator("text=AdminFront")
.allTextContents();
await testInfo.attach('user-sessions.json', {
await testInfo.attach("user-sessions.json", {
body: JSON.stringify(sessionsPayload, null, 2),
contentType: 'application/json',
contentType: "application/json",
});
await testInfo.attach('card-summary.json', {
await testInfo.attach("card-summary.json", {
body: JSON.stringify(
{
unknownCards,
@@ -192,7 +211,7 @@ test.describe('cross-browser session debug', () => {
null,
2,
),
contentType: 'application/json',
contentType: "application/json",
});
expect(adminfrontItems.length).toBeGreaterThan(0);