import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { resolve } from 'node:path'; 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 server = await createServer({ configFile: false, root: resolve('web'), oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 5328, strictPort: true }, plugins: [{ name: 'memory-visibility-test', configureServer(server) { server.middlewares.use('/__memory-test', (_req, res) => { res.setHeader('Content-Type', 'text/html'); res.end('
'); }); }, resolveId(id) { if (id === '__memory-module.tsx') return '\0__memory-module.tsx'; }, load(id) { if (id !== '\0__memory-module.tsx') return; return `import React from 'react';import {createRoot} from 'react-dom/client'; import {MemoryWorkspace} from '/src/pages/MemoryWorkspace.tsx'; import '/src/styles.css';import '/src/cal-theme.css';import '/src/customer-responsive.css';import '/src/components/home/customer-portal.css'; function App(){const [elder,setElder]=React.useState('elder-a');const [section,setSection]=React.useState('daily'); return React.createElement('div',{className:'companion-portal'},React.createElement('button',{onClick:()=>setElder('elder-b')},'切换长者'),React.createElement('button',{onClick:()=>setSection('memory')},'查看故事'),React.createElement('button',{onClick:()=>setSection('daily')},'查看日常'), React.createElement(MemoryWorkspace,{key:section,elderID:elder,accessToken:'synthetic',section}))}; createRoot(document.getElementById('root')).render(React.createElement(App));`; }, }], }); 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 } }); page.setDefaultTimeout(12000); page.on('pageerror', e => problems.push(e.message)); const record = (number, type = 'EVENT') => ({ memory_id: `memory-${number}`, record_version: 1, memory_type: type, content: `聊天总结${number}`, status: 'ACTIVE', proactive_policy: 'ALLOWED', sensitivity: 'normal', source_refs: [], created_at: new Date(Date.UTC(2026, 9, 1, 22, number)).toISOString(), updated_at: '2026-10-02T01:00:00Z', }); const baseStatus = { memory_revision: 27, viking_visible_revision: 27, privacy_epoch: 0, sync_state: 'IN_SYNC', critical_backlog: false, pending_count: 0, blocked_count: 0, upload_pending_count: 0, extracting_count: 0, synced_call_count: 27, insufficient_transcript_count: 0 }; let records = Array.from({ length: 27 }, (_, index) => record(27 - index)); let status = { ...baseStatus, sync_state: 'LAGGING_NORMAL', extracting_count: 1, insufficient_transcript_count: 2 }; let statusFails = false, queryFails = false, paginate = false, holdNextQuery = false, releaseQuery; let heldRequest; const held = new Promise(resolve => { heldRequest = resolve; }); await page.route('**/api/v1/memories/**', async route => { const body = route.request().postDataJSON(); const op = new URL(route.request().url()).pathname.split('/memories/')[1]; if (op === 'sync/status') { if (statusFails) return route.fulfill({ status: 500, json: { error: { code: 'SYNC_UNAVAILABLE', message: 'synthetic failure' } } }); return route.fulfill({ json: { data: status } }); } if (op === 'query') { if (queryFails) return route.fulfill({ status: 500, json: { error: { code: 'QUERY_UNAVAILABLE', message: 'synthetic failure' } } }); const items = body.elder_id === 'elder-b' ? [record(99)] : paginate ? (body.cursor ? [record(1)] : [record(3), record(2)]) : records; const data = { items, mutations: [], can_write: true, can_manage_access: false, next_cursor: paginate && !body.cursor ? 'page-2' : '' }; if (holdNextQuery && body.elder_id === 'elder-a') { holdNextQuery = false; heldRequest(); await new Promise(resolve => { releaseQuery = resolve; }); } await route.fulfill({ json: { data } }).catch(() => {}); } }); await page.goto('http://127.0.0.1:5328/__memory-test'); const rows = page.locator('.cp-memory-row'); await rows.nth(26).waitFor(); assert.equal(await rows.count(), 27); assert.deepEqual(await rows.locator('strong').allTextContents(), records.map(item => item.content)); assert.equal(await rows.first().locator('time').getAttribute('datetime'), records[0].created_at); await page.getByRole('status').filter({ hasText: '等待提取 1 通聊天' }).waitFor(); await page.getByText('2通聊天文字不足,未生成总结', { exact: true }).waitFor(); checks.push('27 records retain server order and creation timestamps; extraction and insufficient text coexist'); statusFails = true; records = [record(40)]; await page.getByRole('button', { name: /刷\s*新/ }).click(); await rows.first().filter({ hasText: '聊天总结40' }).waitFor(); await page.getByRole('status').filter({ hasText: '同步状态读取失败' }).waitFor(); assert.equal(await page.getByText('✓ 记忆已同步', { exact: true }).count(), 0); statusFails = false; status = baseStatus; await page.getByRole('button', { name: /刷\s*新/ }).click(); await page.getByRole('status').filter({ hasText: '✓ 记忆已同步' }).waitFor(); checks.push('successful query survives failed status and status recovers on refresh'); queryFails = true; await page.getByRole('button', { name: /刷\s*新/ }).click(); await page.getByRole('status').filter({ hasText: '记忆读取失败' }).waitFor(); queryFails = false; paginate = true; await page.getByRole('button', { name: /刷\s*新/ }).click(); await rows.first().filter({ hasText: '聊天总结3' }).waitFor(); assert.equal(await page.getByRole('alert').count(), 0); await page.getByRole('button', { name: '加载更多', exact: true }).click(); await rows.nth(2).waitFor(); assert.deepEqual(await rows.locator('strong').allTextContents(), ['聊天总结3', '聊天总结2', '聊天总结1']); await page.getByRole('button', { name: /刷\s*新/ }).click(); await rows.nth(2).waitFor(); assert.deepEqual(await rows.locator('strong').allTextContents(), ['聊天总结3', '聊天总结2', '聊天总结1']); checks.push('query recovery clears the error and pagination preserves server order across refresh'); await page.setViewportSize({ width: 390, height: 844 }); assert(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) < 3); assert.match(await rows.first().innerText(), /整理时间/); checks.push('mobile layout has no horizontal overflow and shows creation time'); paginate = false; holdNextQuery = true; await page.getByRole('button', { name: /刷\s*新/ }).click(); await held; await page.getByRole('button', { name: '切换长者', exact: true }).click(); await rows.first().filter({ hasText: '聊天总结99' }).waitFor(); releaseQuery(); await page.waitForTimeout(150); assert.deepEqual(await rows.locator('strong').allTextContents(), ['聊天总结99']); checks.push('late response from previous elder cannot replace the new elder records'); await page.getByRole('button', { name: '查看故事', exact: true }).click(); await page.getByText('自动整理的聊天总结请查看“日常”;这里保留人物关系、个人信息、偏好与人生故事。', { exact: true }).waitFor(); assert.equal(await rows.count(), 0); await page.getByRole('button', { name: '查看日常', exact: true }).click(); await rows.first().filter({ hasText: '聊天总结99' }).waitFor(); checks.push('daily/story guidance retains the existing record categories'); assert.deepEqual(problems, []); console.log(JSON.stringify({ checks, problems }, null, 2)); } finally { await browser.close(); await server.close(); }