Files
story/test/e2e/memory-visibility.mjs
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

132 lines
8.3 KiB
JavaScript

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('<!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/__memory-module.tsx"></script>');
});
},
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();
}