Files
story/web/test/memory-ui.browser.mjs
T

164 lines
12 KiB
JavaScript
Raw Normal View History

import { createServer } from 'vite';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import fs from 'node:fs/promises';
import assert from 'node:assert/strict';
const require = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM ?? import.meta.url);
const playwright = require(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const evidence = path.resolve(process.env.QA_OUTPUT ?? path.join(root, '../.tmp-memory-ui'));
await fs.mkdir(path.join(evidence, 'empty-env'), { recursive: true });
const server = await createServer({ configFile: false, root, envDir: path.join(evidence, 'empty-env'), cacheDir: path.join(evidence, 'vite-cache'), esbuild: { jsx: 'automatic' }, server: { host: '127.0.0.1', port: 0, strictPort: false } });
await server.listen();
const chrome = process.env.CHROME_EXECUTABLE ?? process.env.CHROME_PATH;
const browser = await playwright.chromium.launch({ headless: true, ...(chrome ? { executablePath: chrome } : {}) });
const page = await browser.newPage({ viewport: { width: 1165, height: 1050 }, timezoneId: 'America/Los_Angeles' });
const errors = [], writes = [], calls = [];
page.on('pageerror', error => errors.push(error.message));
const record = { memory_id: '11111111-1111-4111-8111-111111111111', record_version: 7, content: '合成测试:喜欢在院子里散步', memory_type: 'EVENT', status: 'ACTIVE', proactive_policy: 'ALLOWED', sensitivity: 'normal', entity_key: 'existing-stable-key', source_refs: [], created_at: '2026-10-05T12:16:08Z', updated_at: '2026-10-05T12:16:08Z' };
let failWrite = false, denyWrite = false;
await page.route('**/*', async route => {
const req = route.request();
if (new URL(req.url()).hostname !== '127.0.0.1') throw new Error('Blocked non-local request');
if (!new URL(req.url()).pathname.startsWith('/api/')) return route.continue();
const body = req.postDataJSON();
calls.push({ url: req.url(), body });
let data;
if (req.url().endsWith('/query')) data = { items: body.elder_id === 'test-elder-a' ? [record] : [], mutations: [], next_cursor: '', can_write: true, can_manage_access: false };
else if (req.url().endsWith('/sync/status')) data = { sync_state: 'SYNCED', pending_count: 0, blocked_count: 0, upload_pending_count: 0, extracting_count: 0, synced_call_count: 1, insufficient_transcript_count: 0 };
else if (req.url().endsWith('/mutations')) {
writes.push(body);
if (denyWrite) return route.fulfill({ status: 403, contentType: 'application/json', body: JSON.stringify({ error: { code: 'MEMORY_CONSENT_REQUIRED', message: '需要有效的个性化记忆授权。' } }) });
if (failWrite) return route.fulfill({ status: 502, contentType: 'application/json', body: JSON.stringify({ error: { code: 'UPSTREAM_FAILURE', message: '合成测试失败' } }) });
data = { mutation_id: body.mutation_id };
} else throw new Error(`Unexpected local endpoint ${req.url()}`);
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ data }) });
});
try {
await page.goto(`${server.resolvedUrls.local[0]}test/memory-ui/`);
await page.getByRole('button', { name: '+ 添加记忆' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByLabel('类型', { exact: true }).waitFor();
await page.waitForTimeout(350);
const style = await dialog.getByLabel('类型', { exact: true }).evaluate(input => {
const css = getComputedStyle(input);
return { className: input.className, backgroundColor: css.backgroundColor, minHeight: css.minHeight, width: input.getBoundingClientRect().width, opacity: css.opacity, parent: input.parentElement.outerHTML };
});
await fs.writeFile(path.join(evidence, process.env.BASELINE ? 'before-style.json' : 'after-style.json'), JSON.stringify(style, null, 2));
await page.screenshot({ path: path.join(evidence, process.env.BASELINE ? 'before.png' : 'after.png') });
if (process.env.BASELINE) { console.log(JSON.stringify({ baseline: true, style })); }
else {
assert.equal(style.backgroundColor, 'rgba(0, 0, 0, 0)');
assert.equal(style.minHeight, '0px');
assert.equal(await dialog.locator('.ant-select-content').filter({ hasText: '经历事件' }).count(), 1);
assert.equal(await dialog.locator('.ant-select-content').filter({ hasText: '普通' }).count(), 1);
assert.equal(await dialog.locator('.ant-select-content').filter({ hasText: '可以主动提起' }).count(), 1);
assert.equal(await dialog.getByLabel('事项或事实标识', { exact: false }).count(), 0);
await dialog.getByLabel('类型', { exact: true }).focus();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
for (const label of ['敏感级别', '使用范围']) {
await dialog.getByLabel(label, { exact: true }).focus();
await page.keyboard.press('ArrowDown');
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
}
await dialog.getByLabel('记忆内容').fill('合成测试:键盘添加内容');
await dialog.getByRole('button', { name: '添加记忆', exact: true }).click();
await dialog.waitFor({ state: 'hidden' });
assert.equal(writes[0].action, 'ADD');
assert.notEqual(writes[0].patch.memory_type, 'EVENT');
assert.notEqual(writes[0].patch.sensitivity, 'normal');
assert.notEqual(writes[0].patch.proactive_policy, 'ALLOWED');
assert.match(writes[0].patch.entity_key, /^manual:[0-9a-f-]{36}$/);
await page.getByRole('button', { name: '编辑记忆', exact: true }).click();
await dialog.getByLabel('记忆内容').fill('合成测试:修改后仍保留稳定标识');
failWrite = true;
await dialog.getByRole('button', { name: '保存记忆', exact: true }).click();
await dialog.getByRole('alert').waitFor();
failWrite = false;
// Ant Design's invisible leaving spinner may remain in the accessible name
// after loading has ended. Wait for the actual button state and tolerate
// that decorative prefix, while continuing to use its accessible role.
await page.waitForFunction(() => {
const button = document.querySelector('.cp-memory-editor .ant-modal-footer .ant-btn-primary');
return button && !button.classList.contains('ant-btn-loading') && !button.disabled;
});
await fs.writeFile(path.join(evidence, 'retry-accessibility.txt'), await page.locator('.cp-memory-editor').ariaSnapshot());
await fs.writeFile(path.join(evidence, 'retry-controls.json'), JSON.stringify(await page.locator('.cp-memory-editor button').evaluateAll(els => els.map(el => ({ html: el.outerHTML, hidden: el.closest('[aria-hidden="true"]')?.outerHTML.substring(0, 200), inert: el.closest('[inert]')?.outerHTML.substring(0, 200) }))), null, 2));
await dialog.getByRole('button', { name: /^(?:loading )?保存记忆$/ }).click();
await dialog.waitFor({ state: 'hidden' });
assert.equal(writes[1].patch.entity_key, 'existing-stable-key');
assert.equal(writes[1].expected_record_versions[record.memory_id], 7);
assert.equal(writes[1].mutation_id, writes[2].mutation_id);
for (let i = 0; i < 2; i++) {
await page.getByRole('button', { name: '+ 添加记忆' }).click();
await dialog.getByLabel('记忆内容').fill('合成测试:取消草稿');
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
await page.getByRole('button', { name: '+ 添加记忆' }).waitFor();
assert.equal(await page.getByRole('button', { name: '+ 添加记忆' }).evaluate(el => el === document.activeElement), true);
}
const count = writes.length;
await page.getByRole('button', { name: '模拟无授权' }).click();
assert.equal(await page.getByRole('button', { name: '+ 添加记忆' }).isDisabled(), true);
assert.equal(await page.getByRole('button', { name: '编辑记忆', exact: true }).isDisabled(), true);
assert.equal(await page.getByRole('button', { name: '调整使用范围' }).isDisabled(), true);
await page.getByText('更多操作', { exact: true }).click();
assert.equal(await page.getByRole('button', { name: '删除记忆', exact: true }).isEnabled(), true);
await page.getByRole('button', { name: '删除记忆', exact: true }).click();
assert.equal(await dialog.getByRole('button', { name: '永久删除', exact: true }).isDisabled(), true);
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
assert.equal(writes.length, count);
await page.getByRole('button', { name: '模拟授权加载' }).click();
assert.equal(await page.getByRole('button', { name: '+ 添加记忆' }).isDisabled(), true);
await page.getByRole('button', { name: '模拟有效授权' }).click();
await page.getByRole('button', { name: '+ 添加记忆' }).click();
await dialog.getByLabel('记忆内容').fill('合成测试:编辑过程中授权变化');
await page.getByRole('button', { name: '模拟无授权' }).evaluate(el => el.click());
assert.equal(await dialog.getByRole('button', { name: '添加记忆', exact: true }).isDisabled(), true);
assert.equal(writes.length, count);
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
await page.getByRole('button', { name: '模拟有效授权' }).click();
await page.getByRole('button', { name: '+ 添加记忆' }).click();
await page.waitForTimeout(350);
await dialog.getByLabel('记忆内容').fill('合成测试:后端拒绝');
denyWrite = true;
await dialog.getByRole('button', { name: '添加记忆', exact: true }).click();
await dialog.getByRole('alert').waitFor();
assert.equal(await dialog.getByLabel('记忆内容').inputValue(), '合成测试:后端拒绝');
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
denyWrite = false;
await page.getByRole('button', { name: '+ 添加记忆' }).click();
await dialog.getByLabel('记忆内容').fill('合成测试:不能泄露到其他家庭');
// Simulate the parent changing its selected elder while a dialog is open.
await page.getByRole('button', { name: '切换合成家庭' }).evaluate(el => el.click());
await dialog.waitFor({ state: 'hidden' });
assert.equal(await page.getByText(record.content, { exact: true }).count(), 0);
assert.equal(writes.length, count + 1);
assert.equal(errors.length, 0, errors.join('\n'));
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole('button', { name: '+ 添加记忆' }).click();
await dialog.getByLabel('记忆内容').waitFor();
await page.waitForTimeout(350);
assert.equal(await dialog.locator('.ant-select-content').filter({ hasText: '经历事件' }).isVisible(), true);
const mobileBounds = await dialog.boundingBox();
assert.ok(mobileBounds.x >= 0 && mobileBounds.x + mobileBounds.width <= 390);
await page.screenshot({ path: path.join(evidence, 'after-mobile.png') });
const result = { passed: true, checks: ['selected labels visible', 'all three selects keyboard selection', 'ADD generated entity key', 'edit preserved key and version', 'retry idempotency', 'repeat Escape and focus', 'missing/loading consent blocks non-delete', 'consent change during edit', 'delete confirmation retained without sending', 'server consent rejection preserves draft', 'elder isolation', 'mobile render'], writes: writes.length, unexpectedRequests: calls.filter(call => !call.url.includes('/memories/')).length };
await fs.writeFile(path.join(evidence, 'result.json'), JSON.stringify(result, null, 2));
console.log(JSON.stringify(result));
}
} catch (error) {
await page.screenshot({ path: path.join(evidence, 'failure.png') });
await fs.writeFile(path.join(evidence, 'failure-dom.txt'), await page.locator('body').innerText());
console.log(JSON.stringify({ writes, errors }));
throw error;
} finally { await browser.close(); await server.close(); }