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,125 @@
|
||||
// Real form, synthetic read-only API fixtures, and no external requests.
|
||||
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-subject-validation-'));
|
||||
const output = resolve(process.env.SUBJECT_VALIDATION_OUTPUT ?? '../.local-evidence/subject-create-validation-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-create-validation-fixture',
|
||||
configureServer(vite) { vite.middlewares.use('/__subject-validation', (_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/__subject-validation.tsx"></script>');
|
||||
}); },
|
||||
resolveId(id) { if (id === '__subject-validation.tsx') return '\0__subject-validation.tsx'; },
|
||||
load(id) { if (id === '\0__subject-validation.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-validation',section:'subjects',redesignedHome:true,showNavigation:false}));
|
||||
`; },
|
||||
}],
|
||||
});
|
||||
const errors = [], externalRequests = [], mutations = [], checks = [];
|
||||
let browser, page;
|
||||
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 } : {}) });
|
||||
for (const viewport of [{ width: 1440, height: 1024 }, { width: 389, height: 844 }]) {
|
||||
const context = await browser.newContext({ viewport, serviceWorkers: 'block' });
|
||||
page = await context.newPage();
|
||||
page.setDefaultTimeout(15000);
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
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 }] } : { items: [] };
|
||||
return route.fulfill({ json: { ok: true, data, request_id: 'synthetic-validation' } });
|
||||
});
|
||||
await page.goto(`${origin}/__subject-validation`);
|
||||
await page.getByRole('button', { name: /添加长者|新增长者/ }).first().click();
|
||||
const dialog = page.locator('dialog.subject-create-dialog');
|
||||
await dialog.waitFor();
|
||||
const name = dialog.getByPlaceholder('请输入长者的称呼,如:爷爷、张奶奶');
|
||||
const background = dialog.getByPlaceholder('例如:退休前是教师,年轻时在天津生活过');
|
||||
const goal = dialog.getByPlaceholder('例如:多聊年轻时的工作经历,让长者心情放松');
|
||||
const next = dialog.getByRole('button', { name: '下一步', exact: true });
|
||||
assert(await next.isDisabled(), 'empty name remains required');
|
||||
await name.fill(' ');
|
||||
assert(await next.isDisabled(), 'whitespace name cannot proceed');
|
||||
await name.fill('合成新长者');
|
||||
assert(await next.isEnabled(), 'a name alone must enable Next when background is empty');
|
||||
assert.equal(await name.evaluate(element => element.required), true);
|
||||
assert.equal(await background.evaluate(element => element.required), false);
|
||||
assert.equal(await goal.evaluate(element => element.required), false);
|
||||
assert.match(await dialog.innerText(), /称呼(必填)/);
|
||||
assert.match(await dialog.innerText(), /背景资料(选填)/);
|
||||
assert.match(await dialog.innerText(), /可稍后补充/);
|
||||
await background.fill('仅本地合成背景');
|
||||
assert(await next.isEnabled());
|
||||
await background.fill('');
|
||||
await goal.fill('');
|
||||
assert(await next.isEnabled(), 'clearing optional fields must not block Next');
|
||||
assert(await dialog.locator('form').evaluate(element => element.checkValidity()));
|
||||
const interests = dialog.getByPlaceholder('例如:京剧、养花、做饭(用逗号分隔,最多8项)');
|
||||
await interests.fill('1,2,3,4,5,6,7,8,9');
|
||||
await dialog.getByRole('alert').getByText(/最多填写 8 项/).waitFor();
|
||||
await dialog.locator('form').evaluate(element => element.requestSubmit());
|
||||
assert(await dialog.isVisible(), 'invalid optional list is still rejected');
|
||||
assert.equal(await page.locator('dialog.creation-consent-dialog').count(), 0);
|
||||
await interests.fill('');
|
||||
const bounds = await dialog.boundingBox();
|
||||
assert(bounds.x >= 0 && bounds.x + bounds.width <= viewport.width + 1);
|
||||
assert(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth + 1));
|
||||
await page.screenshot({ path: resolve(output, `form-${viewport.width}.png`), fullPage: true });
|
||||
await next.click();
|
||||
const consent = page.locator('dialog.creation-consent-dialog');
|
||||
await consent.waitFor();
|
||||
assert.match(await consent.innerText(), /为合成新长者开通授权/);
|
||||
assert.equal(await consent.locator('input:checked').count(), 0);
|
||||
assert.deepEqual(mutations, [], 'advancing does not create records or authorizations');
|
||||
await consent.getByRole('button', { name: '返回资料', exact: true }).click();
|
||||
await dialog.waitFor();
|
||||
assert.equal(await name.inputValue(), '合成新长者');
|
||||
assert.equal(await background.inputValue(), '');
|
||||
assert.equal(await goal.inputValue(), '');
|
||||
assert(await next.isEnabled());
|
||||
await name.fill('');
|
||||
assert(await next.isDisabled(), 'clearing the name restores the required-field block');
|
||||
await dialog.getByRole('button', { name: '取消', exact: true }).click();
|
||||
checks.push(`${viewport.width}px: required name, optional background/goal, fill/clear, list validation, authorization handoff, back/cancel, layout, zero mutations`);
|
||||
await context.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 page.screenshot({ path: resolve(output, 'failure.png'), fullPage: true });
|
||||
throw error;
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await server.close();
|
||||
await rm(work, { recursive: true, force: true });
|
||||
}
|
||||
Reference in New Issue
Block a user