164 lines
12 KiB
JavaScript
164 lines
12 KiB
JavaScript
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(); }
|