Files
story/test/e2e/subject-preferences-layout.mjs
T

170 lines
12 KiB
JavaScript
Raw Normal View History

// 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('<!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/__preferences-layout.tsx"></script>');
}); },
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 });
}