177 lines
12 KiB
JavaScript
177 lines
12 KiB
JavaScript
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('<!doctype html><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><div id="root"></div><script type="module" src="/@id/__home-module.tsx"></script>');
|
||
|
|
});
|
||
|
|
},
|
||
|
|
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 });
|
||
|
|
}
|