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(); }