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