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") { const current = accounts.find((item) => Number(item.user_id) === Number(currentUserId)) || null; const authenticated = Boolean(current) && !options.sessionExpired; await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, authenticated, csrf_token: "portal-csrf", user: authenticated ? { id: current.user_id, username: current.username, role: current.role, membership: current.membership, } : null, }), }); return; } if (url.pathname === "/api/dashboard") { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ ok: true, meta: { trade_date: "2026-07-22", requested_date: "2026-07-22", source: "tushare", realtime: false, cached: true, market_status: "closed", updated_at: "2026-07-22T15:00:00+08:00", }, overview: { up_count: 2100, down_count: 2800, limit_up_count: 42, limit_down_count: 8, broken_count: 17, seal_rate: 71.2, amount_billion: 12600, sentiment_score: 48, }, limits: [], broken: [], down_limits: [], yesterday_limits: [], limit_performance: [], ladders: [], sectors: [], sector_rotation: [], }), }); 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; }); async function openPicker(page, options = {}) { const accounts = options.accounts || SAVED_ACCOUNTS.map((item) => ({ ...item })); const currentUserId = options.currentUserId ?? 1; const next = options.next || "/index.html?view=sentimentCycleView"; await page.unroute("**/api/**").catch(() => {}); await mockLoginPortal(page, { accounts, currentUserId, sessionExpired: options.sessionExpired, switchFails: options.switchFails, }); await page.goto(`/login/?next=${encodeURIComponent(next)}`); await expect(page.locator(".login-card-title")).toHaveText("选择账号"); } test("clicking the current account from two workspace pages returns without switching", async ({ page }) => { const views = ["sentimentCycleView", "ladderView"]; for (const viewId of views) { const next = `/index.html?view=${viewId}`; const switchCalls = []; const onRequest = (request) => { if (request.url().includes("/api/auth/switch") && request.method() === "POST") { switchCalls.push(request); } }; page.on("request", onRequest); await openPicker(page, { next }); await expect(page.locator('[data-resume-id="1"]')).toContainText("继续使用"); await expect(page.locator('[data-resume-id="1"]')).toContainText("当前"); await page.locator('[data-resume-id="1"]').click(); await expect(page).toHaveURL(new RegExp(`[?&]view=${viewId}\\b`)); expect(switchCalls).toEqual([]); page.off("request", onRequest); } }); test("a lone current account can return from the picker instead of dead-ending", async ({ page }) => { await openPicker(page, { accounts: [SAVED_ACCOUNTS[0]], currentUserId: 1, next: "/index.html?view=reviewWorkspaceView", }); await expect(page.locator(".login-account-row")).toHaveCount(1); await expect(page.locator('[data-switch-id]')).toHaveCount(0); await page.locator('[data-resume-id="1"]').click(); await expect(page).toHaveURL(/view=reviewWorkspaceView/); }); test("the return control also restores the originating workspace page", async ({ page }) => { await openPicker(page, { next: "/index.html?view=ladderView" }); await page.locator('[data-login-action="resume"]').click(); await expect(page).toHaveURL(/view=ladderView/); }); test("refreshing the picker still returns to the originating page", async ({ page }) => { await openPicker(page, { next: "/index.html?view=sentimentCycleView" }); await page.reload(); await expect(page.locator(".login-card-title")).toHaveText("选择账号"); await page.locator('[data-resume-id="1"]').click(); await expect(page).toHaveURL(/view=sentimentCycleView/); }); test("an expired current session asks for login instead of pretending to return", async ({ page }) => { await openPicker(page, { next: "/index.html?view=sentimentCycleView", sessionExpired: true, }); await page.locator('[data-resume-id="1"]').click(); await expect(page.locator(".login-error")).toHaveText("当前会话已失效,请重新登录"); await expect(page).toHaveURL(/\/login\/?/); }); test("other saved accounts still switch while the current row only resumes", async ({ page }) => { await openPicker(page, { next: "/index.html?view=auctionView" }); 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("workspace switch-account menu carries the current page back to the picker", async ({ page }) => { await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })), currentUserId: 1, }); await page.goto("/index.html?view=sentimentCycleView"); await expect(page.locator("#accountButton")).toBeVisible(); await page.locator("#accountButton").click(); await page.locator("#switchAccountMenuButton").click(); await expect(page).toHaveURL(/\/login\/\?next=/); await expect(page.locator(".login-card-title")).toHaveText("选择账号"); await page.locator('[data-resume-id="1"]').click(); await expect(page).toHaveURL(/view=sentimentCycleView/); }); test("workspace switch-account from a second page also returns to that page", async ({ page }) => { await mockLoginPortal(page, { accounts: SAVED_ACCOUNTS.map((item) => ({ ...item })), currentUserId: 1, }); await page.goto("/index.html?view=ladderView"); await expect(page.locator("#accountButton")).toBeVisible(); await page.locator("#accountButton").click(); await page.locator("#switchAccountMenuButton").click(); await expect(page.locator(".login-card-title")).toHaveText("选择账号"); await page.locator('[data-resume-id="1"]').click(); await expect(page).toHaveURL(/view=ladderView/); });