193 lines
12 KiB
JavaScript
193 lines
12 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", "服务用量", "服务用量"],
|
|
["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();
|
|
const timelineHeaders = page.locator(".timeline-day-group__header");
|
|
await timelineHeaders.first().waitFor();
|
|
const timelineHeaderCount = await timelineHeaders.count();
|
|
const timelineDefaultsWork = timelineHeaderCount >= 3
|
|
&& await timelineHeaders.nth(0).getAttribute("aria-expanded") === "true"
|
|
&& await timelineHeaders.nth(1).getAttribute("aria-expanded") === "true"
|
|
&& await timelineHeaders.nth(2).getAttribute("aria-expanded") === "false";
|
|
const timelineLayout = await page.locator(".memory-timeline-grid .conversation-list").evaluate((element) => ({
|
|
height: element.getBoundingClientRect().height,
|
|
viewportHeight: window.innerHeight,
|
|
overflowY: getComputedStyle(element.querySelector(".memory-timeline-scroll")).overflowY,
|
|
}));
|
|
const timelineIsBounded = timelineLayout.height <= 721 && timelineLayout.height < timelineLayout.viewportHeight && timelineLayout.overflowY === "auto";
|
|
await timelineHeaders.nth(2).click();
|
|
const olderTimelineExpands = await timelineHeaders.nth(2).getAttribute("aria-expanded") === "true";
|
|
await timelineHeaders.nth(2).click();
|
|
|
|
const portalSearch = page.getByPlaceholder("搜索长者、设备、通话", { exact: true });
|
|
await portalSearch.fill("设备");
|
|
await portalSearch.press("Enter");
|
|
await page.getByRole("heading", { name: "陪伴设备", exact: true, level: 1 }).waitFor();
|
|
const searchNavigationWorks = new URL(page.url()).searchParams.get("section") === "devices";
|
|
await page.getByRole("button", { name: "打开提醒与待办", exact: true }).click();
|
|
await page.getByRole("heading", { name: "提醒与待办", exact: true, level: 1 }).waitFor();
|
|
const reminderNavigationWorks = new URL(page.url()).searchParams.get("section") === "messages";
|
|
const reminderPaletteWorks = await page.evaluate(() => {
|
|
const controls = [...document.querySelectorAll(".reminder-todo, .reminder-history__summary > button")];
|
|
return controls.length > 0 && controls.every((item) => {
|
|
const channels = getComputedStyle(item).backgroundColor.match(/\d+/g)?.slice(0, 3).map(Number) ?? [];
|
|
return channels.length === 3 && channels.reduce((sum, value) => sum + value, 0) / 3 > 210;
|
|
});
|
|
});
|
|
|
|
const customerModelNavigationHidden = await page.getByRole("menuitem", { name: /大模型管理$/ }).count() === 0;
|
|
|
|
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")];
|
|
const cursors = controls.map((item) => ({ className: item.className, cursor: getComputedStyle(item).cursor }));
|
|
return { count: controls.length, cursors, allNotAllowed: controls.length > 0 && cursors.every((item) => item.cursor === "not-allowed") };
|
|
});
|
|
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 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, timelineHeaderCount, timelineDefaultsWork, timelineIsBounded, olderTimelineExpands, searchNavigationWorks, reminderNavigationWorks, reminderPaletteWorks, customerModelNavigationHidden, avatarPreviewVisible, modalChecks, disabledCursorResult, familyNavigationVisible, pageFailures, consoleErrors: relevantConsoleErrors, errorResponses: uniqueResponseErrors, failedRequests };
|
|
console.log(JSON.stringify(report, null, 2));
|
|
if (!timelineDefaultsWork || !timelineIsBounded || !olderTimelineExpands || !searchNavigationWorks || !reminderNavigationWorks || !reminderPaletteWorks || !customerModelNavigationHidden || !avatarPreviewVisible || modalChecks.some((item) => !item.passed) || !disabledCursorResult.allNotAllowed || !familyNavigationVisible || pageFailures.length || relevantConsoleErrors.length || uniqueResponseErrors.length || failedRequests.length) {
|
|
throw new Error("Customer portal v0.74 visual/function smoke failed");
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
}
|