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,162 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createRequire } from 'node:module';
|
||||
import { readFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const webRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const requireBrowser = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM || import.meta.url);
|
||||
let chromium;
|
||||
try { ({ chromium } = requireBrowser('playwright')); } catch { /* Optional browser test dependency. */ }
|
||||
|
||||
// Runs real React handlers against synthetic profile data. No API request is
|
||||
// allowed, including localhost API calls. The override tests a private copy
|
||||
// without changing the shared checkout; normal CI leaves it unset.
|
||||
test('profile entry targets and simple save preserve the draft and historical timezone', {
|
||||
skip: !chromium && 'Set PLAYWRIGHT_REQUIRE_FROM to a local Playwright installation.',
|
||||
}, async t => {
|
||||
const { createServer } = await import(new URL('../node_modules/vite/dist/node/index.js', import.meta.url));
|
||||
const work = await mkdtemp(path.join(tmpdir(), 'story-profile-test-'));
|
||||
await mkdir(path.join(work, 'empty-env'));
|
||||
const sourceFile = path.join(webRoot, 'src/pages/SaaSWorkspace.tsx');
|
||||
const source = await readFile(process.env.PROFILE_TEST_SOURCE || sourceFile, 'utf8');
|
||||
const target = sourceFile.replaceAll('\\', '/');
|
||||
const server = await createServer({
|
||||
configFile: false, root: webRoot, envDir: path.join(work, 'empty-env'), cacheDir: path.join(work, 'vite-cache'),
|
||||
oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 },
|
||||
plugins: [{
|
||||
name: 'synthetic-profile-tests', enforce: 'pre',
|
||||
configureServer(vite) {
|
||||
vite.middlewares.use('/__profile-test', (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html');
|
||||
response.end('<!doctype html><meta charset="UTF-8"><div id="root"></div><script type="module" src="/@id/__profile-test.tsx"></script>');
|
||||
});
|
||||
},
|
||||
resolveId(id) { if (id === '__profile-test.tsx') return '\0__profile-test.tsx'; },
|
||||
load(id) {
|
||||
if (id.split('?')[0].replaceAll('\\', '/') === target) return source + '\nexport { SubjectPanel };\n';
|
||||
if (id !== '\0__profile-test.tsx') return;
|
||||
return `
|
||||
import React from 'react'; import { createRoot } from 'react-dom/client';
|
||||
import { SubjectPanel } from '/src/pages/SaaSWorkspace.tsx';
|
||||
const original = { id:'synthetic-elder', household_id:'synthetic-home', preferred_name:'测试长者',
|
||||
language:'zh-CN', timezone:'America/Los_Angeles', version:4, status:'active',
|
||||
profile_data:{background:'合成背景', interests:['养花']},
|
||||
conversation_preferences:{pace:'slow',conversation_goal:'日常陪伴',preferred_topics:[],avoided_topics:[]} };
|
||||
window.profileSaves=[]; window.profileVerifies=0;
|
||||
function App() {
|
||||
const [subject,setSubject]=React.useState(original), [attempt,setAttempt]=React.useState();
|
||||
const save=(baseline,input)=>{
|
||||
window.profileSaves.push({baseline,input});
|
||||
if (new URL(location.href).searchParams.get('outcome') === 'uncertain') {
|
||||
setAttempt({status:'uncertain',householdID:baseline.household_id,baseVersion:baseline.version,input,message:'合成未知结果'});
|
||||
} else {
|
||||
setSubject(current=>({...current,...input,version:current.version+1}));
|
||||
setAttempt({status:'saved',householdID:baseline.household_id,baseVersion:baseline.version,input});
|
||||
}
|
||||
};
|
||||
return React.createElement(SubjectPanel, { subjects:[subject],selectedID:subject.id,onSelect:()=>{},subject,
|
||||
facts:[],consents:[],consentState:'ready',loading:false,canWrite:true,actionBusy:false,
|
||||
onAdd:()=>{},onUpdateSubject:save,onConfirmFact:async()=>{},onWithdrawConsent:async()=>{},
|
||||
onOpenConsent:()=>{},saveAttempt:attempt,onVerifySave:async()=>{window.profileVerifies++},
|
||||
onUseLatest:()=>setAttempt(undefined) });
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(React.createElement(App));
|
||||
`;
|
||||
},
|
||||
}],
|
||||
});
|
||||
let browser;
|
||||
try {
|
||||
await server.listen();
|
||||
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
|
||||
browser = await chromium.launch({ headless: true, ...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}) });
|
||||
const errors = [], blocked = [];
|
||||
const newPage = async (query = '') => {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, serviceWorkers: 'block' });
|
||||
page.setDefaultTimeout(10000);
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
await page.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.origin !== origin || url.pathname.startsWith('/api/')) {
|
||||
blocked.push(url.origin + url.pathname);
|
||||
return route.abort('blockedbyclient');
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await page.goto(origin + '/__profile-test' + query);
|
||||
await page.locator('.cp-preparation').waitFor();
|
||||
return page;
|
||||
};
|
||||
const nameField = page => page.locator('.cp-profile-fields label').filter({ has: page.locator('strong', { hasText: /^称呼$/ }) }).locator('input');
|
||||
const profileHeading = page => page.locator('.cp-profile-fields > h2');
|
||||
|
||||
await t.test('basic and preference entries, repeated opening, and close/reopen select the requested page', async () => {
|
||||
const page = await newPage();
|
||||
await page.locator('.cp-preparation button').filter({ hasText: '基本资料' }).click();
|
||||
assert.equal(await profileHeading(page).innerText(), '基本资料');
|
||||
await nameField(page).fill('未保存称呼');
|
||||
await page.locator('.cp-profile-form .cp-tabs').getByRole('button', { name: '陪伴偏好', exact: true }).click();
|
||||
assert.equal(await profileHeading(page).innerText(), '陪伴偏好');
|
||||
// A repeat navigation event must work even while this editor is mounted.
|
||||
await page.locator('.cp-preparation button').filter({ hasText: '基本资料' }).dispatchEvent('click');
|
||||
await nameField(page).waitFor();
|
||||
assert.equal(await profileHeading(page).innerText(), '基本资料');
|
||||
assert.equal(await nameField(page).inputValue(), '未保存称呼');
|
||||
await page.getByRole('button', { name: '关闭资料详情', exact: true }).click();
|
||||
await page.locator('.cp-preparation button').filter({ hasText: '交流偏好' }).click();
|
||||
assert.equal(await profileHeading(page).innerText(), '陪伴偏好');
|
||||
await page.getByRole('button', { name: '关闭资料详情', exact: true }).click();
|
||||
await page.locator('.cp-identity button').click();
|
||||
assert.equal(await profileHeading(page).innerText(), '基本资料');
|
||||
assert.equal(await page.getByText('所在时区', { exact: true }).count(), 0);
|
||||
assert.doesNotMatch(await page.locator('body').innerText(), /America\/Los_Angeles|Asia\/Shanghai/);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
await t.test('save submits once without a confirmation and keeps the original timezone and baseline', async () => {
|
||||
const page = await newPage();
|
||||
await page.locator('.cp-identity button').click();
|
||||
await nameField(page).fill('更新后的称呼');
|
||||
await page.getByRole('button', { name: '保存资料', exact: true }).click();
|
||||
await page.locator('.cp-profile-actions').getByText('服务器已确认保存', { exact: true }).waitFor();
|
||||
const saves = await page.evaluate(() => window.profileSaves);
|
||||
assert.equal(saves.length, 1);
|
||||
assert.equal(saves[0].baseline.version, 4);
|
||||
assert.equal(saves[0].input.preferred_name, '更新后的称呼');
|
||||
assert.equal(saves[0].input.timezone, 'America/Los_Angeles');
|
||||
assert.equal(await page.locator('.cp-profile-form input[type=checkbox]').count(), 0);
|
||||
assert.equal(await page.getByRole('button', { name: '保存资料', exact: true }).isDisabled(), true);
|
||||
assert.doesNotMatch(await page.locator('.cp-profile-form').innerText(), /synthetic-elder|v4|v5/);
|
||||
await page.close();
|
||||
});
|
||||
|
||||
await t.test('an unknown result keeps its draft, blocks another submit, and offers only read verification', async () => {
|
||||
const page = await newPage('?outcome=uncertain');
|
||||
await page.locator('.cp-identity button').click();
|
||||
await nameField(page).fill('未知结果草稿');
|
||||
await page.getByRole('button', { name: '保存资料', exact: true }).click();
|
||||
const verify = page.getByRole('button', { name: '核验服务器资料(只读)', exact: true });
|
||||
await verify.waitFor();
|
||||
assert.equal(await page.getByRole('button', { name: '保存资料', exact: true }).isDisabled(), true);
|
||||
assert.equal(await nameField(page).inputValue(), '未知结果草稿');
|
||||
await verify.click();
|
||||
assert.deepEqual(await page.evaluate(() => ({ saves: window.profileSaves.length, verifies: window.profileVerifies })), { saves: 1, verifies: 1 });
|
||||
await page.getByRole('button', { name: '关闭资料详情', exact: true }).click();
|
||||
await page.locator('.cp-preparation button').filter({ hasText: '基本资料' }).click();
|
||||
assert.equal(await nameField(page).inputValue(), '未知结果草稿');
|
||||
assert.equal(await page.getByRole('button', { name: '保存资料', exact: true }).isDisabled(), true);
|
||||
await page.close();
|
||||
});
|
||||
assert.deepEqual(errors, []);
|
||||
assert.deepEqual(blocked, []);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
// This temporary directory was created by this test and is always in tmpdir.
|
||||
if (path.dirname(work) !== path.resolve(tmpdir())) throw new Error('Unsafe temporary cleanup path');
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user