const { test, expect } = require("@playwright/test"); const fs = require("fs"); const path = require("path"); const SHOT_DIR = path.resolve(__dirname, "../../../verify-shots"); fs.mkdirSync(SHOT_DIR, { recursive: true }); function loginPayload(user) { return { ok: true, authenticated: true, csrf_token: "portal-csrf", user, }; } async function mockLoginPortal(page, options = {}) { const accounts = options.accounts || []; let currentUserId = options.currentUserId ?? null; await page.route("**/api/**", async (route) => { const url = new URL(route.request().url()); const method = route.request().method(); if (url.pathname === "/api/auth/accounts") { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, accounts, current_user_id: currentUserId }), }); return; } if (url.pathname === "/api/auth/switch" && method === "POST") { const body = route.request().postDataJSON() || {}; const account = accounts.find((item) => Number(item.user_id) === Number(body.user_id)); if (!account || options.switchFails) { await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: "该账号需重新验证" }), }); return; } currentUserId = account.user_id; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(loginPayload(account)), }); return; } if (url.pathname === "/api/auth/forget" && method === "POST") { const body = route.request().postDataJSON() || {}; const index = accounts.findIndex((item) => Number(item.user_id) === Number(body.user_id)); if (index >= 0) accounts.splice(index, 1); await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }), }); return; } if ((url.pathname === "/api/auth/login" || url.pathname === "/api/auth/register") && method === "POST") { if (options.loginDelay) await new Promise((resolve) => setTimeout(resolve, options.loginDelay)); if (options.loginFails) { await route.fulfill({ status: 401, contentType: "application/json", body: JSON.stringify({ error: "账号名或密码不正确,请重新输入。" }), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(loginPayload({ id: 9, username: "new_user", role: "user", membership: { active: false, subscribed: false, is_admin: false }, })), }); return; } if (url.pathname === "/api/auth/me") { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, authenticated: Boolean(currentUserId), csrf_token: "portal-csrf", user: accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null, }), }); return; } await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true }), }); }); } const SAVED_ACCOUNTS = [ { user_id: 1, username: "alpha_user", role: "admin", membership: { active: true, subscribed: true, is_admin: true }, last_used_at: "2026-08-29T01:00:00+00:00", }, { user_id: 2, username: "beta_user", role: "user", membership: { active: false, subscribed: false, is_admin: false }, last_used_at: "2026-08-28T01:00:00+00:00", }, ]; test("first-time login portal asks for a password and hides environment copy", async ({ page }) => { await mockLoginPortal(page, { accounts: [] }); await page.goto("/login/"); await expect(page.locator(".login-card-title")).toHaveText("欢迎回来"); await expect(page.locator("#loginUsername")).toBeVisible(); await expect(page.locator(".login-submit")).toHaveText("登录"); await expect(page.locator("body")).not.toContainText("内网个人版"); await expect(page.locator("body")).not.toContainText("192.168.200.11"); }); test("saved accounts can switch directly and show a re-auth message on failure", async ({ page }) => { await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })) }); await page.goto("/login/"); await expect(page.locator(".login-card-title")).toHaveText("选择账号"); await expect(page.locator(".login-account-row")).toHaveCount(2); const switched = page.waitForRequest((request) => ( request.url().includes("/api/auth/switch") && request.method() === "POST" )); await page.locator('[data-switch-id="2"]').click(); const request = await switched; expect(JSON.parse(request.postData() || "{}")).toEqual({ user_id: 2 }); }); test("failed account switch stays on the portal with the original copy", async ({ page }) => { await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })), switchFails: true, }); await page.goto("/login/"); await page.locator('[data-switch-id="2"]').click(); await expect(page.locator(".login-error")).toHaveText("该账号需重新验证"); await expect(page).toHaveURL(/\/login\/?/); }); test("managing accounts removes a local record after inline confirmation", async ({ page }) => { await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })) }); await page.goto("/login/"); await page.locator('[data-login-action="manage"]').click(); await expect(page.locator(".login-card-title")).toHaveText("管理账号记录"); await page.locator('[data-confirm-id="2"]').click(); await expect(page.locator(".login-confirm-copy")).toContainText("beta_user"); await page.locator('[data-forget-id="2"]').click(); await expect(page.locator(".login-account-row")).toHaveCount(1); await expect(page.locator(".login-account-row")).toContainText("alpha_user"); }); async function assertConfirmedSkeleton(page, { width, height }) { await expect(page.locator(".login-brand-title")).toHaveText("看懂情绪周期,把复盘变成下一次的先手。"); await expect(page.locator(".login-brand-header")).toBeVisible(); await expect(page.locator(".login-brand-chart")).toBeVisible(); await expect(page.locator(".login-brand-stats")).toBeVisible(); await expect(page.locator(".login-brand-kicker")).toHaveText("收盘之后 · 复盘开始"); const brand = await page.locator(".login-brand").boundingBox(); const header = await page.locator(".login-brand-header").boundingBox(); const mark = await page.locator(".login-brand-mark").boundingBox(); const name = await page.locator(".login-brand-name").boundingBox(); const title = await page.locator(".login-brand-title").boundingBox(); const stats = await page.locator(".login-brand-stats").boundingBox(); const chart = await page.locator(".login-brand-chart").boundingBox(); const card = await page.locator(".login-card").boundingBox(); expect(brand).toBeTruthy(); expect(header.y - brand.y).toBeLessThan(48); expect(Math.abs(mark.y - name.y)).toBeLessThan(16); expect(title.y).toBeGreaterThan(height * 0.28); expect(title.y).toBeLessThan(height * 0.72); expect(stats.y).toBeGreaterThan(height * 0.55); expect(chart.height).toBeGreaterThan(80); expect(card.width).toBeGreaterThan(380); expect(card.width).toBeLessThan(450); if (width === 1440) { expect(brand.width).toBeGreaterThan(470); expect(brand.width).toBeLessThan(520); expect(brand.height).toBe(height); } else if (width === 1920) { expect(brand.width).toBeGreaterThan(540); expect(brand.width).toBeLessThan(580); } else { expect(brand.width).toBeGreaterThan(560); } } async function openPortal(page, { theme, width, height, accounts, currentUserId, loginFails, loginDelay }) { await page.addInitScript((nextTheme) => { localStorage.setItem("xiaobaiTheme", nextTheme); }, theme); await page.setViewportSize({ width, height }); await mockLoginPortal(page, { accounts, currentUserId, loginFails, loginDelay }); await page.goto("/login/"); } for (const theme of ["light", "dark"]) { for (const [width, height] of [[1440, 900], [1920, 1080]]) { test(`confirmed skeleton ${theme} ${width}x${height}`, async ({ page }) => { await openPortal(page, { theme, width, height, accounts: [] }); await assertConfirmedSkeleton(page, { width, height }); await expect(page.locator("#loginThemeToggle")).toHaveText(theme === "dark" ? "☀ 日间" : "🌙 夜间"); await page.screenshot({ path: path.join(SHOT_DIR, `first-${theme}-${width}.png`), fullPage: true }); }); } } test("ultrawide keeps the left brand from collapsing into a strip", async ({ page }) => { await openPortal(page, { theme: "dark", width: 2560, height: 1080, accounts: [] }); await assertConfirmedSkeleton(page, { width: 2560, height: 1080 }); }); test("picker add remove error and loading share the same desktop skeleton", async ({ page }) => { const accounts = SAVED_ACCOUNTS.map((item) => ({ ...item })); await openPortal(page, { theme: "dark", width: 1440, height: 900, accounts, currentUserId: 1, }); await assertConfirmedSkeleton(page, { width: 1440, height: 900 }); await expect(page.locator(".login-card-title")).toHaveText("选择账号"); await expect(page.locator(".login-avatar")).toHaveCount(2); await expect(page.locator(".login-add")).toBeVisible(); await page.screenshot({ path: path.join(SHOT_DIR, "picker-dark-1440.png"), fullPage: true }); await page.locator('[data-login-action="add"]').click(); await expect(page.locator(".login-card-title")).toHaveText("添加账号"); await assertConfirmedSkeleton(page, { width: 1440, height: 900 }); await page.screenshot({ path: path.join(SHOT_DIR, "add-dark-1440.png"), fullPage: true }); await page.locator('[data-login-action="picker"]').click(); await page.locator('[data-login-action="manage"]').click(); await expect(page.locator(".login-card-title")).toHaveText("管理账号记录"); await page.locator('[data-confirm-id="2"]').click(); await expect(page.locator(".login-confirm-copy")).toContainText("beta_user"); await page.screenshot({ path: path.join(SHOT_DIR, "remove-dark-1440.png"), fullPage: true }); }); test("login failure and loading keep the confirmed first-login skeleton", async ({ page }) => { await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [], loginFails: true, }); await page.locator("#loginUsername").fill("baiqizhi"); await page.locator("#loginPassword").fill("wrong-password"); await page.locator(".login-submit").click(); await expect(page.locator(".login-error")).toContainText("账号名或密码不正确"); await expect(page.locator("#loginPassword")).toHaveClass(/is-invalid/); await assertConfirmedSkeleton(page, { width: 1440, height: 900 }); await page.screenshot({ path: path.join(SHOT_DIR, "error-light-1440.png"), fullPage: true }); }); test("loading button appears on the confirmed first-login skeleton", async ({ page }) => { await openPortal(page, { theme: "dark", width: 1440, height: 900, accounts: [], loginDelay: 2500, }); await page.evaluate(() => { window.location.replace = () => {}; }); await page.locator("#loginUsername").fill("baiqizhi"); await page.locator("#loginPassword").fill("password12"); const submit = page.locator(".login-submit").click(); await expect(page.locator(".login-submit")).toContainText("正在登录..."); await expect(page.locator(".login-spinner")).toBeVisible(); await assertConfirmedSkeleton(page, { width: 1440, height: 900 }); await page.screenshot({ path: path.join(SHOT_DIR, "loading-dark-1440.png"), fullPage: true }); await submit; });