// Uses the full portal, read-only synthetic responses, and a disposable Chrome // profile. 200% is Chrome's actual page zoom, set only in this temporary profile. 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-preferences-layout-')); const output = resolve(process.env.SUBJECT_LAYOUT_OUTPUT ?? '../.local-evidence/subject-preferences-layout-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-preferences-layout-fixture', configureServer(vite) { vite.middlewares.use('/__preferences-layout', (_request, response) => { response.setHeader('Content-Type', 'text/html'); response.end('
'); }); }, resolveId(id) { if (id === '__preferences-layout.tsx') return '\0__preferences-layout.tsx'; }, load(id) { if (id === '\0__preferences-layout.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-layout',section:'subjects',redesignedHome:true,showNavigation:false})); `; }, }], }); const errors = [], externalRequests = [], mutations = [], checks = []; let context, page; async function screenshot(path) { await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))); // Capture the compositor surface without Playwright resetting viewport // metrics, which would conflict with Chrome's independently configured zoom. const session = await context.newCDPSession(page); try { const { data } = await session.send('Page.captureScreenshot', { format: 'png', captureBeyondViewport: false }); await writeFile(path, Buffer.from(data, 'base64')); } finally { await session.detach(); } } try { await server.listen(); const origin = `http://127.0.0.1:${server.httpServer.address().port}`; context = await chromium.launchPersistentContext(join(work, 'chrome-profile'), { headless: true, viewport: { width: 1188, height: 761 }, serviceWorkers: 'block', ignoreDefaultArgs: ['--hide-scrollbars'], ...(process.env.CHROME_EXECUTABLE ? { executablePath: process.env.CHROME_EXECUTABLE } : {}), }); for (const scenario of [ { name: 'original-100', width: 1188, height: 761, zoom: 1 }, { name: 'narrow-100', width: 389, height: 844, zoom: 1 }, { name: 'original-200', width: 1188, height: 761, zoom: 2 }, ]) { if (scenario.zoom === 2) { const settings = await context.newPage(); await settings.goto('chrome://settings/appearance'); await settings.locator('#zoomLevel').selectOption('2'); await settings.close(); } page = await context.newPage(); await page.setViewportSize({ width: scenario.width, height: scenario.height }); page.setDefaultTimeout(15000); page.on('pageerror', error => errors.push(error.message)); const subject = { id: 'qa-subject', household_id: 'qa-household', preferred_name: 'QA长者', language: 'zh-CN', timezone: 'Asia/Shanghai', status: 'active', version: 1, profile_data: { background: '仅本地合成背景', interests: ['养花', '听京剧', '散步', '家乡故事', '做饭', '看电影'] }, conversation_preferences: { pace: 'slow', conversation_goal: '日常陪伴', preferred_topics: [], avoided_topics: [] }, voice_agent: { status: 'READY', message: '专属语音助手已就绪', personalization_included: false }, }; 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 }] } : path === '/api/v1/households/qa-household/subjects' ? { items: [subject] } : path === '/api/v1/subjects/qa-subject' ? subject : { items: [] }; return route.fulfill({ json: { ok: true, data, request_id: 'synthetic-layout' } }); }); await page.goto(`${origin}/__preferences-layout`); const metrics = await page.evaluate(() => ({ innerWidth, innerHeight, clientWidth: document.documentElement.clientWidth, dpr: devicePixelRatio })); assert.equal(metrics.innerWidth, scenario.width / scenario.zoom); assert(Math.abs(metrics.innerHeight - scenario.height / scenario.zoom) <= 1); assert.equal(metrics.dpr, scenario.zoom, 'verify real Chrome zoom rather than CSS scaling'); for (let cycle = 0; cycle < 2; cycle++) { await page.locator('.cp-preparation').getByRole('button', { name: /交流偏好/ }).click(); const form = page.locator('.cp-profile-form'); const avoided = form.getByPlaceholder('填写希望避免的话题(选填),用顿号分隔'); await avoided.waitFor(); await avoided.evaluate(element => element.scrollIntoView({ block: 'center' })); const geometry = await avoided.evaluate(element => { const rect = el => { const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, bottom: r.bottom }; }; const r = element.getBoundingClientRect(), hit = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); const body = document.querySelector('.cp-page-detail .subject-detail-modal__body'); const note = document.querySelector('.subject-management > .workspace__readonly-note'); return { textarea: rect(element), centerHit: { tag: hit?.tagName, className: hit?.className }, body: rect(body), bodyMaxHeight: getComputedStyle(body).maxHeight, form: rect(element.closest('form')), note: rect(note), notePosition: getComputedStyle(note).position, pageFooter: rect(document.querySelector('.ch-footer')), fields: rect(document.querySelector('.cp-profile-fields')), formActions: rect(document.querySelector('.cp-profile-actions')), positions: ['.cp-page-detail', '.subject-detail-modal', '.subject-detail-modal__body', '.cp-profile-form', '.cp-profile-actions'].map(selector => ({ selector, position: getComputedStyle(document.querySelector(selector)).position })), }; }); await writeFile(resolve(output, `${scenario.name}-${cycle}-geometry.json`), JSON.stringify({ metrics, ...geometry }, null, 2)); await screenshot(resolve(output, `${scenario.name}-${cycle}.png`)); assert.equal(geometry.centerHit.tag, 'TEXTAREA', 'avoided-topic center must hit the textarea, not the voice-agent banner'); assert.equal(geometry.bodyMaxHeight, 'none', 'inline profile content must use its full natural height'); assert(geometry.body.bottom >= geometry.form.bottom - 1, 'body must contain the full profile form'); assert(geometry.note.y >= geometry.form.bottom - 1, 'voice-agent banner must follow the form'); assert(geometry.pageFooter.y >= geometry.form.bottom - 1, 'page footer must follow the form'); assert(geometry.formActions.y >= geometry.fields.bottom - 1, 'save actions must not overlay preference fields'); assert.equal(geometry.notePosition, 'static'); assert(geometry.positions.every(item => item.position === 'static'), 'inline profile containers stay in normal document flow'); const fields = form.locator('.cp-profile-fields input, .cp-profile-fields select, .cp-profile-fields textarea'); assert.equal(await fields.count(), 6, 'all preference controls remain present'); for (const field of await fields.all()) { await field.evaluate(element => element.scrollIntoView({ block: 'center' })); assert(await field.evaluate(element => { const r = element.getBoundingClientRect(); return [2, r.height / 2, r.height - 2].every(y => document.elementFromPoint(r.x + r.width / 2, r.y + y) === element); }), 'preference input top, center and bottom must remain reachable above the footer'); } await avoided.evaluate(element => element.scrollIntoView({ block: 'center' })); const bounds = await avoided.boundingBox(); await page.mouse.click(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); assert(await avoided.evaluate(element => document.activeElement === element)); await page.keyboard.insertText('仅本地测试草稿'); assert.equal(await avoided.inputValue(), '仅本地测试草稿'); assert.deepEqual(mutations, []); const save = form.getByRole('button', { name: '保存资料', exact: true }); assert(await save.isEnabled()); await save.evaluate(element => element.scrollIntoView({ block: 'center' })); assert(await save.evaluate(element => { const r = element.getBoundingClientRect(); return document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.closest('button') === element; }), 'the save button stays reachable at the end of the form'); await screenshot(resolve(output, `${scenario.name}-${cycle}-bottom.png`)); await page.getByRole('button', { name: '关闭资料详情', exact: true }).click(); if (cycle === 0) await page.reload(); } checks.push({ scenario: scenario.name, metrics, refreshReopen: true, inputHitTests: 'all six controls, top/center/bottom', centerClickTyping: true, contentContained: true, bannerAndPageFooterAfterForm: true, saveActionsAfterFields: true, saveButtonReachable: true, allProfilePositionsStatic: true }); await page.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 screenshot(resolve(output, 'failure.png')); throw error; } finally { await context?.close(); await server.close(); // work is an absolute mkdtemp child created by this test beneath tmpdir(). assert(resolve(work).startsWith(resolve(tmpdir()) + (process.platform === 'win32' ? '\\' : '/'))); await rm(work, { recursive: true, force: true }); }