在桌面登录门户品牌空白带加入红绿小K线角色,支持焦点、密码遮挡、登录反馈和减少动态效果,且不移动原有文案与行情图。 Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: multica-agent <github@multica.ai>
557 lines
24 KiB
JavaScript
557 lines
24 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") {
|
|
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);
|
|
await expect(page.locator("#loginMascots")).toBeVisible();
|
|
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);
|
|
const mascots = await page.locator("#loginMascots").boundingBox();
|
|
const kicker = await page.locator(".login-brand-kicker").boundingBox();
|
|
expect(mascots).toBeTruthy();
|
|
expect(kicker).toBeTruthy();
|
|
expect(mascots.y).toBeGreaterThan(header.y + header.height - 4);
|
|
expect(mascots.y + mascots.height).toBeLessThan(kicker.y + 8);
|
|
const gapTop = header.y + header.height;
|
|
const gapBottom = kicker.y;
|
|
const mid = (gapTop + gapBottom) / 2;
|
|
const mascotMid = mascots.y + mascots.height / 2;
|
|
expect(Math.abs(mascotMid - mid)).toBeLessThan(48);
|
|
expect(title.y).toBeGreaterThan(470);
|
|
expect(title.y).toBeLessThan(580);
|
|
} 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/);
|
|
});
|
|
|
|
test("desktop mascots react to account focus, password, toggle, loading, success and failure", async ({ page }) => {
|
|
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
|
|
const mascots = page.locator("#loginMascots");
|
|
await expect(mascots).toHaveAttribute("data-mood", "idle");
|
|
await page.locator("#loginUsername").focus();
|
|
await expect(mascots).toHaveAttribute("data-mood", "account");
|
|
await page.locator("#loginPassword").focus();
|
|
await expect(mascots).toHaveAttribute("data-mood", "password");
|
|
await expect.poll(async () => (
|
|
page.locator(".login-mascot.is-red .login-mascot-hand.is-left").evaluate((node) => getComputedStyle(node).opacity)
|
|
)).toBe("1");
|
|
await page.locator(".login-password-toggle").click();
|
|
await expect(page.locator("#loginPassword")).toHaveAttribute("type", "text");
|
|
await expect(mascots).toHaveAttribute("data-mood", "password");
|
|
await page.locator(".login-password-toggle").click();
|
|
await expect(page.locator("#loginPassword")).toHaveAttribute("type", "password");
|
|
await expect(mascots).toHaveAttribute("data-mood", "password");
|
|
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-password-light-1440.png"), fullPage: true });
|
|
});
|
|
|
|
test("login loading and failure drive short mascot feedback", async ({ page }) => {
|
|
await openPortal(page, {
|
|
theme: "dark",
|
|
width: 1440,
|
|
height: 900,
|
|
accounts: [],
|
|
loginFails: true,
|
|
loginDelay: 800,
|
|
});
|
|
await page.locator("#loginUsername").fill("baiqizhi");
|
|
await page.locator("#loginPassword").fill("wrong-password");
|
|
const submit = page.locator(".login-submit").click();
|
|
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "busy");
|
|
await expect(page.locator(".login-error")).toContainText("账号名或密码不正确");
|
|
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "fail");
|
|
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-fail-dark-1440.png"), fullPage: true });
|
|
await submit;
|
|
});
|
|
|
|
test("login success plays a hop before leaving the portal", async ({ page }) => {
|
|
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
|
|
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("#loginMascots")).toHaveAttribute("data-mood", /busy|success/);
|
|
await expect(page.locator("#loginMascots")).toHaveAttribute("data-mood", "success", { timeout: 4000 });
|
|
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-success-light-1440.png"), fullPage: true });
|
|
await submit;
|
|
});
|
|
|
|
test("reduced motion keeps static mascots without mouse tracking", async ({ page }) => {
|
|
await page.emulateMedia({ reducedMotion: "reduce" });
|
|
await openPortal(page, { theme: "dark", width: 1440, height: 900, accounts: [] });
|
|
const mascots = page.locator("#loginMascots");
|
|
await expect(mascots).toBeVisible();
|
|
await expect(mascots).toHaveAttribute("data-mood", "idle");
|
|
const before = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
|
|
await page.mouse.move(1200, 120);
|
|
await page.waitForTimeout(120);
|
|
const after = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
|
|
expect(after).toBe(before);
|
|
await page.locator("#loginPassword").focus();
|
|
await expect(mascots).toHaveAttribute("data-mood", "password");
|
|
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-reduced-dark-1440.png"), fullPage: true });
|
|
});
|
|
|
|
test("mouse follow updates mascot pupils on a fine pointer", async ({ page }) => {
|
|
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
|
|
const mascots = page.locator("#loginMascots");
|
|
await page.mouse.move(80, 160);
|
|
await page.waitForTimeout(180);
|
|
const left = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
|
|
await page.mouse.move(1280, 200);
|
|
await page.waitForTimeout(180);
|
|
const right = await mascots.evaluate((node) => getComputedStyle(node.querySelector(".login-mascot.is-red")).getPropertyValue("--pupil-x"));
|
|
expect(Number.parseFloat(right)).toBeGreaterThan(Number.parseFloat(left));
|
|
});
|
|
|
|
test("narrow screens hide mascots without moving the login card", async ({ page }) => {
|
|
await openPortal(page, { theme: "light", width: 800, height: 900, accounts: [] });
|
|
await expect(page.locator("#loginMascots")).toBeHidden();
|
|
await expect(page.locator(".login-card-title")).toHaveText("欢迎回来");
|
|
await expect(page.locator(".login-brand-title")).toBeHidden();
|
|
});
|
|
|
|
test("theme toggle keeps mascots in the brand gap", async ({ page }) => {
|
|
await openPortal(page, { theme: "light", width: 1440, height: 900, accounts: [] });
|
|
await page.locator("#loginThemeToggle").click();
|
|
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
|
|
await assertConfirmedSkeleton(page, { width: 1440, height: 900 });
|
|
await page.screenshot({ path: path.join(SHOT_DIR, "mascots-idle-dark-1440.png"), fullPage: true });
|
|
});
|