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(); }