Files

126 lines
8.1 KiB
JavaScript
Raw Permalink Normal View History

// 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 });
}