feat: expand companion platform, device controls, and memoir workflows
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:
Codex Local
2026-10-08 10:06:44 +08:00
parent 98fb2965d0
commit bf1233d5df
861 changed files with 94614 additions and 3209 deletions
+169
View File
@@ -0,0 +1,169 @@
// Uses the full portal, read-only synthetic responses, and a disposable Chrome
// profile. 200% is Chrome's actual page zoom, set only in this temporary profile.
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { resolve, join } 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 work = await mkdtemp(join(tmpdir(), 'story-preferences-layout-'));
const output = resolve(process.env.SUBJECT_LAYOUT_OUTPUT ?? '../.local-evidence/subject-preferences-layout-20261006');
await mkdir(join(work, 'empty-env'));
await mkdir(output, { recursive: true });
const server = await createServer({
configFile: false, root: resolve('web'), envDir: join(work, 'empty-env'), cacheDir: join(work, 'vite-cache'),
oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 },
plugins: [{
name: 'subject-preferences-layout-fixture',
configureServer(vite) { vite.middlewares.use('/__preferences-layout', (_request, response) => {
response.setHeader('Content-Type', 'text/html');
response.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/__preferences-layout.tsx"></script>');
}); },
resolveId(id) { if (id === '__preferences-layout.tsx') return '\0__preferences-layout.tsx'; },
load(id) { if (id === '\0__preferences-layout.tsx') return `
import React from 'react'; import {createRoot} from 'react-dom/client';
import {SaaSWorkspace} from '/src/pages/SaaSWorkspace.tsx';
import '/src/styles.css'; import '/src/cal-theme.css'; import '/src/customer-responsive.css';
const accounts=[{tenant_id:'qa-tenant',user_id:'qa-user',account_kind:'customer',tenant_name:'合成测试',display_name:'QA',policy_version:'local-v1',household_memberships:[]}];
createRoot(document.getElementById('root')).render(React.createElement(SaaSWorkspace,{accounts,accessToken:'synthetic-layout',section:'subjects',redesignedHome:true,showNavigation:false}));
`; },
}],
});
const errors = [], externalRequests = [], mutations = [], checks = [];
let context, page;
async function screenshot(path) {
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
// Capture the compositor surface without Playwright resetting viewport
// metrics, which would conflict with Chrome's independently configured zoom.
const session = await context.newCDPSession(page);
try {
const { data } = await session.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false });
await writeFile(path, Buffer.from(data, 'base64'));
} finally { await session.detach(); }
}
try {
await server.listen();
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
context = await chromium.launchPersistentContext(join(work, 'chrome-profile'), {
headless: true, viewport: { width: 1188, height: 761 }, serviceWorkers: 'block',
ignoreDefaultArgs: ['--hide-scrollbars'],
...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}),
});
for (const scenario of [
{ name: 'original-100', width: 1188, height: 761, zoom: 1 },
{ name: 'narrow-100', width: 389, height: 844, zoom: 1 },
{ name: 'original-200', width: 1188, height: 761, zoom: 2 },
]) {
if (scenario.zoom === 2) {
const settings = await context.newPage();
await settings.goto('chrome://settings/appearance');
await settings.locator('#zoomLevel').selectOption('2');
await settings.close();
}
page = await context.newPage();
await page.setViewportSize({ width: scenario.width, height: scenario.height });
page.setDefaultTimeout(15000);
page.on('pageerror', error => errors.push(error.message));
const subject = { id: 'qa-subject', household_id: 'qa-household', preferred_name: 'QA长者', language: 'zh-CN', timezone: 'Asia/Shanghai', status: 'active', version: 1,
profile_data: { background: '仅本地合成背景', interests: ['养花', '听京剧', '散步', '家乡故事', '做饭', '看电影'] },
conversation_preferences: { pace: 'slow', conversation_goal: '日常陪伴', preferred_topics: [], avoided_topics: [] },
voice_agent: { status: 'READY', message: '专属语音助手已就绪', personalization_included: false },
};
await page.route('**/*', async route => {
const request = route.request(), url = new URL(request.url()), path = url.pathname;
if (url.origin !== origin) { externalRequests.push(url.origin + path); return route.abort('blockedbyclient'); }
if (!path.startsWith('/api/')) return route.continue();
if (request.method() !== 'GET') {
mutations.push(`${request.method()} ${path}`);
return route.fulfill({ status: 400, json: { ok: false, error: { code: 'SYNTHETIC_REJECTED', message: 'This fixture permits reads only' } } });
}
if (path === '/api/v1/events') return route.fulfill({ contentType: 'text/event-stream', body: ': synthetic fixture\n\n' });
const data = path === '/api/v1/households' ? { items: [{ id: 'qa-household', tenant_id: 'qa-tenant', name: '合成测试家庭', status: 'active', role: 'owner', capabilities: ['household.read', 'subject.read', 'subject.write'], version: 1 }] }
: path === '/api/v1/households/qa-household/subjects' ? { items: [subject] }
: path === '/api/v1/subjects/qa-subject' ? subject : { items: [] };
return route.fulfill({ json: { ok: true, data, request_id: 'synthetic-layout' } });
});
await page.goto(`${origin}/__preferences-layout`);
const metrics = await page.evaluate(() => ({ innerWidth, innerHeight, clientWidth: document.documentElement.clientWidth, dpr: devicePixelRatio }));
assert.equal(metrics.innerWidth, scenario.width / scenario.zoom);
assert(Math.abs(metrics.innerHeight - scenario.height / scenario.zoom) <= 1);
assert.equal(metrics.dpr, scenario.zoom, 'verify real Chrome zoom rather than CSS scaling');
for (let cycle = 0; cycle < 2; cycle++) {
await page.locator('.cp-preparation').getByRole('button', { name: /交流偏好/ }).click();
const form = page.locator('.cp-profile-form');
const avoided = form.getByPlaceholder('填写希望避免的话题(选填),用顿号分隔');
await avoided.waitFor();
await avoided.evaluate(element => element.scrollIntoView({ block: 'center' }));
const geometry = await avoided.evaluate(element => {
const rect = el => { const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, bottom: r.bottom }; };
const r = element.getBoundingClientRect(), hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2);
const body = document.querySelector('.cp-page-detail .subject-detail-modal__body');
const note = document.querySelector('.subject-management > .workspace__readonly-note');
return { textarea: rect(element), centerHit: { tag: hit?.tagName, className: hit?.className },
body: rect(body), bodyMaxHeight: getComputedStyle(body).maxHeight,
form: rect(element.closest('form')), note: rect(note), notePosition: getComputedStyle(note).position,
pageFooter: rect(document.querySelector('.ch-footer')),
fields: rect(document.querySelector('.cp-profile-fields')), formActions: rect(document.querySelector('.cp-profile-actions')),
positions: ['.cp-page-detail', '.subject-detail-modal', '.subject-detail-modal__body', '.cp-profile-form', '.cp-profile-actions'].map(selector => ({ selector, position: getComputedStyle(document.querySelector(selector)).position })),
};
});
await writeFile(resolve(output, `${scenario.name}-${cycle}-geometry.json`), JSON.stringify({ metrics, ...geometry }, null, 2));
await screenshot(resolve(output, `${scenario.name}-${cycle}.png`));
assert.equal(geometry.centerHit.tag, 'TEXTAREA', 'avoided-topic center must hit the textarea, not the voice-agent banner');
assert.equal(geometry.bodyMaxHeight, 'none', 'inline profile content must use its full natural height');
assert(geometry.body.bottom >= geometry.form.bottom - 1, 'body must contain the full profile form');
assert(geometry.note.y >= geometry.form.bottom - 1, 'voice-agent banner must follow the form');
assert(geometry.pageFooter.y >= geometry.form.bottom - 1, 'page footer must follow the form');
assert(geometry.formActions.y >= geometry.fields.bottom - 1, 'save actions must not overlay preference fields');
assert.equal(geometry.notePosition, 'static');
assert(geometry.positions.every(item => item.position === 'static'), 'inline profile containers stay in normal document flow');
const fields = form.locator('.cp-profile-fields input, .cp-profile-fields select, .cp-profile-fields textarea');
assert.equal(await fields.count(), 6, 'all preference controls remain present');
for (const field of await fields.all()) {
await field.evaluate(element => element.scrollIntoView({ block: 'center' }));
assert(await field.evaluate(element => {
const r = element.getBoundingClientRect();
return [2, r.height / 2, r.height - 2].every(y => document.elementFromPoint(r.x + r.width / 2, r.y + y) === element);
}), 'preference input top, center and bottom must remain reachable above the footer');
}
await avoided.evaluate(element => element.scrollIntoView({ block: 'center' }));
const bounds = await avoided.boundingBox();
await page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
assert(await avoided.evaluate(element => document.activeElement === element));
await page.keyboard.insertText('仅本地测试草稿');
assert.equal(await avoided.inputValue(), '仅本地测试草稿');
assert.deepEqual(mutations, []);
const save = form.getByRole('button', { name: '保存资料', exact: true });
assert(await save.isEnabled());
await save.evaluate(element => element.scrollIntoView({ block: 'center' }));
assert(await save.evaluate(element => {
const r = element.getBoundingClientRect();
return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.closest('button') === element;
}), 'the save button stays reachable at the end of the form');
await screenshot(resolve(output, `${scenario.name}-${cycle}-bottom.png`));
await page.getByRole('button', { name: '关闭资料详情', exact: true }).click();
if (cycle === 0) await page.reload();
}
checks.push({ scenario: scenario.name, metrics, refreshReopen: true, inputHitTests: 'all six controls, top/center/bottom', centerClickTyping: true, contentContained: true, bannerAndPageFooterAfterForm: true, saveActionsAfterFields: true, saveButtonReachable: true, allProfilePositionsStatic: true });
await page.close();
}
assert.deepEqual(errors, []);
assert.deepEqual(externalRequests, []);
assert.deepEqual(mutations, []);
const result = { passed: true, checks, browserErrors: errors, externalRequests, mutations };
await writeFile(resolve(output, 'checks.json'), JSON.stringify(result, null, 2));
console.log(JSON.stringify(result));
} catch (error) {
if (page && !page.isClosed()) await screenshot(resolve(output, 'failure.png'));
throw error;
} finally {
await context?.close();
await server.close();
// work is an absolute mkdtemp child created by this test beneath tmpdir().
assert(resolve(work).startsWith(resolve(tmpdir()) + (process.platform === 'win32' ? '\\' : '/')));
await rm(work, { recursive: true, force: true });
}