feat: expand companion platform, device controls, and memoir workflows
ci / verify (push) Canceled after 0s
ci / verify (push) Canceled after 0s
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.
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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(); }
|
||||
Reference in New Issue
Block a user