Files
story/test/e2e/visual-customer-v074.mjs
T
2026-08-30 14:26:28 +08:00

157 lines
9.7 KiB
JavaScript

import { createRequire } from "node:module";
import { mkdir } from "node:fs/promises";
import { resolve } from "node:path";
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const { chromium } = require("playwright");
const webURL = process.env.VISUAL_WEB_URL ?? "http://127.0.0.1:5173";
const username = process.env.VISUAL_USERNAME ?? "owner";
const password = process.env.VISUAL_PASSWORD ?? "LocalOnly123!";
const outputDirectory = resolve(process.cwd(), ".design-evidence/v074/implementation");
const designOutputDirectory = resolve(process.cwd(), ".design-evidence/v074/design");
const avatarFixture = resolve(process.cwd(), ".design-evidence/avatar-upload-fixture.png");
const sections = [
["home", "陪伴周记", "首页"],
["conversations", "回忆时间轴", "聊天记录"],
["stories", "故事与记忆", "故事与记忆"],
["subjects", "长者资料", "长者资料"],
["messages", "日程与提醒", "日程与提醒"],
["members", "家庭管理", "家庭管理"],
["devices", "陪伴设备", "陪伴设备"],
["sharing", "内容分享", "内容分享"],
["privacy", "数据与授权", "数据与授权"],
["billing", "服务用量", "服务用量"],
["models", "大模型管理", "大模型管理"],
["profile", "设置与权限", "设置与权限"],
];
await mkdir(outputDirectory, { recursive: true });
await mkdir(designOutputDirectory, { recursive: true });
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? "C:/Program Files/Google/Chrome/Application/chrome.exe" });
const designPage = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 });
for (const [section] of sections) {
await designPage.goto(`${webURL}/design-v074/index.html?section=${section}`, { waitUntil: "networkidle" });
await designPage.screenshot({ path: resolve(designOutputDirectory, `${section}.png`) });
}
await designPage.close();
const page = await browser.newPage({ viewport: { width: 1440, height: 1024 }, deviceScaleFactor: 1 });
page.setDefaultTimeout(6000);
const consoleErrors = [];
const failedRequests = [];
const errorResponses = [];
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
page.on("response", (response) => { if (response.status() >= 400) errorResponses.push(`${response.status()} ${response.url()}`); });
page.on("requestfailed", (request) => {
const reason = request.failure()?.errorText ?? "failed";
const pathname = new URL(request.url()).pathname;
if (request.method() === "GET" && pathname === "/api/v1/events" && reason.includes("ERR_ABORTED")) return;
if (pathname.startsWith("/api/v1/") && reason.includes("ERR_ABORTED")) return;
failedRequests.push(`${request.method()} ${request.url()} ${reason}`);
});
const results = [];
try {
await page.goto(webURL, { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: /^登\s*录$/ }).click();
await page.waitForURL(/localhost:58180|127\.0\.0\.1:58180/);
await page.locator("#username").fill(username);
await page.locator("#password").fill(password);
await page.locator("#kc-login").click();
const webOriginPattern = new RegExp(`^${new URL(webURL).origin.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:/|$)`);
await page.waitForURL(webOriginPattern, { timeout: 15000 });
await page.waitForTimeout(1200);
await page.screenshot({ path: resolve(outputDirectory, "after-login.png") });
await page.locator(".customer-account-button").waitFor({ timeout: 15000 });
for (const [index, [section, heading, menuLabel]] of sections.entries()) {
if (index > 0) await page.getByRole("menuitem", { name: new RegExp(`${menuLabel}$`) }).click();
await page.getByRole("heading", { name: heading, exact: true, level: 1 }).waitFor({ timeout: 15000 });
if (section === "conversations") {
const firstConversation = page.locator("button.conversation-row").first();
if (await firstConversation.isVisible()) {
await firstConversation.click();
await page.waitForTimeout(700);
}
}
await page.waitForTimeout(250);
const layout = await page.evaluate(() => {
const header = document.querySelector(".customer-page__header")?.getBoundingClientRect();
const context = document.querySelector(".workspace__context-bar")?.getBoundingClientRect();
const content = document.querySelector(".workspace__context-bar")?.nextElementSibling?.getBoundingClientRect();
const overflow = [...document.querySelectorAll("body *")]
.filter((element) => element instanceof HTMLElement && element.scrollWidth > element.clientWidth + 2 && getComputedStyle(element).overflowX === "visible")
.filter((element) => !element.classList.contains("ant-badge") && !element.classList.contains("family-member-card__avatar") && !element.closest(".usage-activity-card"))
.slice(0, 20)
.map((element) => `${element.tagName}.${element.className}: ${element.clientWidth}/${element.scrollWidth}`);
return {
headerBottom: header?.bottom,
contextTop: context?.top,
contextBottom: context?.bottom,
contentTop: content?.top,
overflow,
pageHorizontalOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
customerOrganizationText: document.body.innerText.includes("客户组织"),
};
});
await page.screenshot({ path: resolve(outputDirectory, `${section}.png`) });
results.push({ section, heading, layout });
}
await page.getByRole("menuitem", { name: /长者资料$/ }).click();
await page.getByRole("heading", { name: "长者资料", exact: true, level: 1 }).waitFor();
await page.locator("button.subject-add-button").click();
const avatarInput = page.locator(".subject-avatar-upload input[type=file]");
await avatarInput.setInputFiles(avatarFixture);
await page.getByAltText("待上传的长者头像预览").waitFor();
const avatarPreviewVisible = await page.getByAltText("待上传的长者头像预览").isVisible();
await page.screenshot({ path: resolve(outputDirectory, "subjects-avatar-upload.png") });
const disabledCursorResult = await page.evaluate(() => {
const controls = [...document.querySelectorAll("button:disabled")];
return { count: controls.length, allWait: controls.length > 0 && controls.every((item) => getComputedStyle(item).cursor === "wait") };
});
await page.getByRole("button", { name: "关闭新增长者弹窗" }).click();
const modalChecks = [];
async function checkModal(menuLabel, triggerSelector, triggerName, dialogHeading, closeName = "关闭") {
try {
console.log(`Checking modal: ${menuLabel} / ${triggerName}`);
await page.getByRole("menuitem", { name: new RegExp(`${menuLabel}$`) }).click();
await page.locator(triggerSelector).first().click();
const dialog = page.getByRole("dialog").last();
await dialog.getByRole("heading", { name: dialogHeading, exact: true }).waitFor();
await dialog.getByRole("button", { name: closeName, exact: true }).click();
await dialog.waitFor({ state: "hidden" });
modalChecks.push({ menuLabel, triggerName, passed: true });
} catch (error) {
console.error(`Modal check failed: ${menuLabel} / ${triggerName}: ${String(error)}`);
modalChecks.push({ menuLabel, triggerName, passed: false, error: String(error) });
}
}
await checkModal("家庭管理", ".family-accept-button", "接受邀请", "接受家庭邀请");
await checkModal("家庭管理", ".family-member-card--invite", "邀请新成员", "邀请家庭成员");
await checkModal("陪伴设备", ".workspace--devices .workspace__actions button:has-text('绑定设备')", "绑定设备", "绑定新设备");
await checkModal("内容分享", ".subject-list-toolbar > button", "新建分享", "新建资源分享");
await checkModal("数据与授权", ".privacy-actions button:has-text('资料更正')", "资料更正", "提交资料更正");
await checkModal("大模型管理", ".model-active-hero > button", "添加大模型", "添加总结大模型");
await page.getByRole("menuitem", { name: /故事与记忆$/ }).click();
await page.locator("button.story-candidate-card").first().click();
const storyDialog = page.getByRole("dialog").last();
await storyDialog.getByRole("heading", { name: "故事详情", exact: true }).waitFor();
await storyDialog.getByRole("button", { name: "关闭", exact: true }).click();
modalChecks.push({ menuLabel: "故事与记忆", triggerName: "故事卡片", passed: true });
const familyNavigationVisible = await page.getByRole("menuitem", { name: /家庭管理/ }).isVisible();
const pageFailures = results.filter(({ layout }) => layout.pageHorizontalOverflow > 2 || layout.overflow.length || layout.customerOrganizationText || (layout.contextTop !== undefined && layout.headerBottom !== undefined && layout.contextTop - layout.headerBottom > 4));
const uniqueResponseErrors = [...new Set(errorResponses)].filter((item) => !item.includes("/api/v1/events") && !(/^403 .*\/media$/.test(item)));
const relevantConsoleErrors = consoleErrors.filter((item) => !item.startsWith("Failed to load resource:"));
const report = { results, avatarPreviewVisible, modalChecks, disabledCursorResult, familyNavigationVisible, pageFailures, consoleErrors: relevantConsoleErrors, errorResponses: uniqueResponseErrors, failedRequests };
console.log(JSON.stringify(report, null, 2));
if (!avatarPreviewVisible || modalChecks.some((item) => !item.passed) || !disabledCursorResult.allWait || !familyNavigationVisible || pageFailures.length || relevantConsoleErrors.length || uniqueResponseErrors.length || failedRequests.length) {
throw new Error("Customer portal v0.74 visual/function smoke failed");
}
} finally {
await browser.close();
}