157 lines
9.7 KiB
JavaScript
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();
|
|
}
|