import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { mkdir, mkdtemp, rm } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { pathToFileURL } from 'node:url'; const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url); const { chromium } = require('playwright'); const { createServer } = await import(pathToFileURL(resolve('web/node_modules/vite/dist/node/index.js'))); const elderA = '10000000-0000-4000-8000-000000000411'; const elderB = '10000000-0000-4000-8000-000000000412'; const cacheDir = await mkdtemp(join(tmpdir(), 'home-overview-vite-')); const server = await createServer({ configFile: false, root: resolve('web'), cacheDir, oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', // The test's virtual entry is absent from index.html. Prebundle its dependencies // without sharing the production checkout's optimizer cache or discovering new // bundles during interactions, which can otherwise reload the test document. optimizeDeps: { noDiscovery: true, include: ['react', 'react/jsx-runtime', 'react/jsx-dev-runtime', 'react-dom', 'react-dom/client', 'antd', '@ant-design/icons', '@phosphor-icons/react', 'oidc-client-ts', 'go-captcha-react'] }, server: { host: '127.0.0.1', port: 5329, strictPort: true }, plugins: [{ name: 'home-overview-test', configureServer(server) { server.middlewares.use('/__home-test', (_req, res) => { res.setHeader('Content-Type', 'text/html'); res.end('
'); }); }, resolveId(id) { if (id === '__home-module.tsx') return '\0__home-module.tsx'; }, load(id) { if (id !== '\0__home-module.tsx') return; return `import React from 'react';import {createRoot} from 'react-dom/client'; import {AuthProvider} from '/src/auth/useAuth.ts';import {CustomerPortal} from '/src/pages/CustomerPortal.tsx'; import {installPortalFixtures} from '/src/dev/portal-fixtures.ts'; import '/src/styles.css';import '/src/cal-theme.css';import '/src/customer-responsive.css';import '/src/components/home/customer-portal.css'; const nativeFetch=window.fetch.bind(window);installPortalFixtures();const fixtureFetch=window.fetch.bind(window); window.__homeRequests=[]; window.fetch=async(input,init)=>{const path=new URL(typeof input==='string'?input:input instanceof URL?input.href:input.url,location.origin).pathname;window.__homeRequests.push(path); if(path.endsWith('/home-overview'))return nativeFetch(input,init); const response=await fixtureFetch(input,init); if(path.endsWith('/subjects')){const body=await response.json();body.data.items.push({...body.data.items[0],id:'${elderB}',preferred_name:'爷爷'});return new Response(JSON.stringify(body),{headers:{'Content-Type':'application/json'}})} return response;}; createRoot(document.getElementById('root')).render(React.createElement(AuthProvider,null,React.createElement(CustomerPortal)));`; }, }], }); await server.listen(); const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' }); const checks = [], problems = []; try { const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); await page.clock.install(); let documentLoads = 0; page.on('domcontentloaded', () => { documentLoads += 1; }); page.setDefaultTimeout(15000); page.on('pageerror', error => problems.push(error.message)); const overview = (subjectID, summary, duration = 98000, count = 35) => ({ subject_id: subjectID, timezone: 'Asia/Shanghai', week_start: '2026-09-28', week_end: '2026-10-05', conversation_count: count, total_duration_ms: count ? duration : 0, daily: Array.from({ length: 7 }, (_, index) => ({ date: new Date(Date.UTC(2026, 8, 28 + index)).toISOString().slice(0, 10), conversation_count: index === 3 ? count : 0, duration_ms: index === 3 && count ? duration : 0 })), latest_conversation: { id: `latest-${subjectID}`, source: 'call_memory', status: 'FAILED', started_at: '2026-10-01T14:00:11Z', ended_at: '2026-10-01T14:01:49Z', duration_ms: duration, summary, summary_state: 'READY', summary_kind: 'memory' }, }); let responseA = overview(elderA, '奶奶本次聊到阳台新开的花。'); let responseB = overview(elderB, '爷爷本次聊到下棋。', 12000, 1); let failure = false, holdNext = false, heldResolve, releaseHeld; let held = new Promise(resolve => { heldResolve = resolve; }); const requests = []; await page.route('**/api/v1/subjects/*/home-overview', async route => { const subjectID = route.request().url().split('/subjects/')[1].split('/')[0]; requests.push(subjectID); const data = structuredClone(subjectID === elderA ? responseA : responseB); if (holdNext && subjectID === elderA) { holdNext = false; heldResolve(); await new Promise(resolve => { releaseHeld = resolve; }); } await route.fulfill(failure ? { status: 500, json: { ok: false, code: 'TEST_FAILURE', message: '近况暂时读取失败' } } : { json: { ok: true, data } }).catch(() => {}); }); await page.goto('http://127.0.0.1:5329/__home-test', { waitUntil: 'domcontentloaded', timeout: 30000 }); const latest = page.locator('.ch-latest'); const metrics = page.locator('.ch-metrics strong'); const refresh = () => page.getByRole('button', { name: '刷新陪伴近况', exact: true }).click(); await latest.getByText('奶奶本次聊到阳台新开的花。', { exact: true }).waitFor(); await server.waitForRequestsIdle(); assert.deepEqual(await metrics.allTextContents(), ['35', '1.6']); assert.match(await latest.innerText(), /聊天记忆摘要/); assert.match(await latest.innerText(), /10月1日 22:00 · 1分38秒/); assert.match(await latest.innerText(), /聊天中断/); assert.equal(requests.length, 1); const oldRequests = await page.evaluate(() => window.__homeRequests.filter(path => /\/subjects\/[^/]+\/conversations$|\/conversations\/[^/]+\/summaries$/.test(path))); assert.deepEqual(oldRequests, []); checks.push('actual CustomerPortal uses one overview request, no legacy list/summary requests, full aggregate and real FAILED latest chat'); await page.locator('.ch-sidebar').getByRole('link', { name: '聊天记录', exact: true }).click(); await page.clock.runFor(30050); assert.equal(requests.length, 1); responseA = overview(elderA, '返回首页后取到的新聊天。'); await page.locator('.ch-sidebar').getByRole('link', { name: '首页', exact: true }).click(); await latest.getByText('返回首页后取到的新聊天。', { exact: true }).waitFor(); assert.equal(requests.length, 2); checks.push('leaving home stops polling; returning refreshes the overview independently of legacy history'); holdNext = true; await refresh(); await held; await page.evaluate(() => { window.dispatchEvent(new Event('focus')); document.dispatchEvent(new Event('visibilitychange')); }); assert.equal(requests.length, 3); await page.getByRole('button', { name: '查看爷爷的陪伴近况', exact: true }).click(); assert.equal(await page.locator('.ch-sidebar').getByRole('link', { name: '首页', exact: true }).getAttribute('aria-current'), 'page'); await page.getByRole('heading', { name: '爷爷的陪伴近况', exact: true }).waitFor(); await latest.getByText('爷爷本次聊到下棋。', { exact: true }).waitFor(); assert.deepEqual(await metrics.allTextContents(), ['1', '<1']); assert.equal(await page.getByText('返回首页后取到的新聊天。', { exact: true }).count(), 0); releaseHeld(); await page.waitForTimeout(100); assert.match(await latest.innerText(), /爷爷本次聊到下棋/); assert.match(await latest.innerText(), /12秒/); checks.push('overlapping refreshes are deduplicated; switching elder clears prior data and ignores late response; short duration remains visible'); failure = true; await refresh(); await page.getByRole('alert').filter({ hasText: '暂时无法载入陪伴近况' }).waitFor(); assert.equal(await page.getByText('爷爷本次聊到下棋。', { exact: true }).count(), 0); assert.equal(await metrics.count(), 0); failure = false; responseB.latest_conversation.summary = '恢复后同步的最新聊天。'; const beforeRetry = requests.length; await page.getByRole('button', { name: '重新加载', exact: true }).click(); await latest.getByText('恢复后同步的最新聊天。', { exact: true }).waitFor(); assert.equal(requests.length, beforeRetry + 1); checks.push('failed refresh removes stale metrics and summary; retry makes one request and restores latest content'); responseB = overview(elderB, '上周聊天仍是最近一通。', 12000, 0); responseB.latest_conversation.started_at = '2026-09-27T08:00:00Z'; await refresh(); await latest.getByText('上周聊天仍是最近一通。', { exact: true }).waitFor(); assert.deepEqual(await metrics.allTextContents(), ['0', '0']); checks.push('zero chats this week does not hide the latest chat from the previous week'); responseB.latest_conversation.summary_state = 'INSUFFICIENT_TRANSCRIPT'; await refresh(); await latest.getByText('本次聊天文字不足,未生成总结。', { exact: true }).waitFor(); responseB.latest_conversation.summary_state = 'PROCESSING'; await refresh(); await latest.getByText('聊天已结束,摘要正在整理中。', { exact: true }).waitFor(); checks.push('insufficient text and processing have distinct accurate status text'); const longSummary = '定时刷新后的新聊天。爷爷聊起年轻时住过的院子、家门口的老树,以及和邻居一起下棋的往事。他说这些温暖的日常一直记在心里,最近也想和家人去公园走走,看看秋天的桂花,再聊一聊过去做过的拿手菜。'; responseB = overview(elderB, longSummary, 98000, 35); const beforePoll = requests.length; await page.clock.runFor(30050); await latest.getByText(longSummary, { exact: true }).waitFor(); assert.equal(requests.length, beforePoll + 1); checks.push('visible homepage refreshes every 30 seconds'); const evidence = process.env.HOME_OVERVIEW_OUTPUT_DIR; if (evidence) { await mkdir(evidence, { recursive: true }); await page.screenshot({ path: resolve(evidence, 'home-desktop.png'), fullPage: true }); } for (const width of [1440, 390]) { await page.setViewportSize({ width, height: width === 390 ? 844 : 1000 }); assert(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) < 3); assert(await page.locator('.ch-latest-summary').evaluate(element => element.clientHeight >= parseFloat(getComputedStyle(element).lineHeight) * 2 - 1), 'long summary must exercise two visible lines'); const boxes = await page.locator('.ch-weekly, .ch-latest').evaluateAll(panels => panels.map(panel => ({ panel: panel.getBoundingClientRect().bottom, content: Math.max(...Array.from(panel.querySelectorAll('*')).map(child => child.getBoundingClientRect().bottom)) }))); assert(boxes.every(box => box.content <= box.panel + 1), `home card content exceeds panel at ${width}px: ${JSON.stringify(boxes)}`); } if (evidence) await page.screenshot({ path: resolve(evidence, 'home-mobile.png'), fullPage: true }); checks.push('desktop and mobile show two-line memory summary plus interruption label without horizontal or card content overflow'); assert.equal(documentLoads, 1, 'the test document must not reload during interactions'); assert.deepEqual(problems, []); console.log(JSON.stringify({ checks, problems }, null, 2)); } catch (error) { console.error(JSON.stringify({ checks, problems }, null, 2)); throw error; } finally { await browser.close(); await server.close(); if (dirname(cacheDir) !== resolve(tmpdir()) || !basename(cacheDir).startsWith('home-overview-vite-')) throw new Error('unexpected test cache path'); await rm(cacheDir, { recursive: true, force: true }); }