126 lines
8.1 KiB
JavaScript
126 lines
8.1 KiB
JavaScript
// 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 });
|
||
|
|
}
|