登录页覆盖全局 body 底边距,让左栏品牌面板铺满视口高度。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
292 lines
12 KiB
JavaScript
292 lines
12 KiB
JavaScript
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;
|
|
});
|