import test from 'node:test'; import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import { readFile, mkdtemp, mkdir, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const webRoot = fileURLToPath(new URL('..', import.meta.url)); const requireBrowser = createRequire(process.env.PLAYWRIGHT_REQUIRE_FROM || import.meta.url); let chromium; try { ({ chromium } = requireBrowser('playwright')); } catch { /* Optional browser test dependency. */ } // Runs real React handlers against synthetic profile data. No API request is // allowed, including localhost API calls. The override tests a private copy // without changing the shared checkout; normal CI leaves it unset. test('profile entry targets and simple save preserve the draft and historical timezone', { skip: !chromium && 'Set PLAYWRIGHT_REQUIRE_FROM to a local Playwright installation.', }, async t => { const { createServer } = await import(new URL('../node_modules/vite/dist/node/index.js', import.meta.url)); const work = await mkdtemp(path.join(tmpdir(), 'story-profile-test-')); await mkdir(path.join(work, 'empty-env')); const sourceFile = path.join(webRoot, 'src/pages/SaaSWorkspace.tsx'); const source = await readFile(process.env.PROFILE_TEST_SOURCE || sourceFile, 'utf8'); const target = sourceFile.replaceAll('\\', '/'); const server = await createServer({ configFile: false, root: webRoot, envDir: path.join(work, 'empty-env'), cacheDir: path.join(work, 'vite-cache'), oxc: { jsx: { runtime: 'automatic' } }, logLevel: 'error', server: { host: '127.0.0.1', port: 0 }, plugins: [{ name: 'synthetic-profile-tests', enforce: 'pre', configureServer(vite) { vite.middlewares.use('/__profile-test', (_request, response) => { response.setHeader('Content-Type', 'text/html'); response.end('
'); }); }, resolveId(id) { if (id === '__profile-test.tsx') return '\0__profile-test.tsx'; }, load(id) { if (id.split('?')[0].replaceAll('\\', '/') === target) return source + '\nexport { SubjectPanel };\n'; if (id !== '\0__profile-test.tsx') return; return ` import React from 'react'; import { createRoot } from 'react-dom/client'; import { SubjectPanel } from '/src/pages/SaaSWorkspace.tsx'; const original = { id:'synthetic-elder', household_id:'synthetic-home', preferred_name:'测试长者', language:'zh-CN', timezone:'America/Los_Angeles', version:4, status:'active', profile_data:{background:'合成背景', interests:['养花']}, conversation_preferences:{pace:'slow',conversation_goal:'日常陪伴',preferred_topics:[],avoided_topics:[]} }; window.profileSaves=[]; window.profileVerifies=0; function App() { const [subject,setSubject]=React.useState(original), [attempt,setAttempt]=React.useState(); const save=(baseline,input)=>{ window.profileSaves.push({baseline,input}); if (new URL(location.href).searchParams.get('outcome') === 'uncertain') { setAttempt({status:'uncertain',householdID:baseline.household_id,baseVersion:baseline.version,input,message:'合成未知结果'}); } else { setSubject(current=>({...current,...input,version:current.version+1})); setAttempt({status:'saved',householdID:baseline.household_id,baseVersion:baseline.version,input}); } }; return React.createElement(SubjectPanel, { subjects:[subject],selectedID:subject.id,onSelect:()=>{},subject, facts:[],consents:[],consentState:'ready',loading:false,canWrite:true,actionBusy:false, onAdd:()=>{},onUpdateSubject:save,onConfirmFact:async()=>{},onWithdrawConsent:async()=>{}, onOpenConsent:()=>{},saveAttempt:attempt,onVerifySave:async()=>{window.profileVerifies++}, onUseLatest:()=>setAttempt(undefined) }); } createRoot(document.getElementById('root')).render(React.createElement(App)); `; }, }], }); let browser; 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 } : {}) }); const errors = [], blocked = []; const newPage = async (query = '') => { const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, serviceWorkers: 'block' }); page.setDefaultTimeout(10000); page.on('pageerror', error => errors.push(error.message)); await page.route('**/*', route => { const url = new URL(route.request().url()); if (url.origin !== origin || url.pathname.startsWith('/api/')) { blocked.push(url.origin + url.pathname); return route.abort('blockedbyclient'); } return route.continue(); }); await page.goto(origin + '/__profile-test' + query); await page.locator('.cp-preparation').waitFor(); return page; }; const nameField = page => page.locator('.cp-profile-fields label').filter({ has: page.locator('strong', { hasText: /^称呼$/ }) }).locator('input'); const profileHeading = page => page.locator('.cp-profile-fields > h2'); await t.test('basic and preference entries, repeated opening, and close/reopen select the requested page', async () => { const page = await newPage(); await page.locator('.cp-preparation button').filter({ hasText: '基本资料' }).click(); assert.equal(await profileHeading(page).innerText(), '基本资料'); await nameField(page).fill('未保存称呼'); await page.locator('.cp-profile-form .cp-tabs').getByRole('button', { name: '陪伴偏好', exact: true }).click(); assert.equal(await profileHeading(page).innerText(), '陪伴偏好'); // A repeat navigation event must work even while this editor is mounted. await page.locator('.cp-preparation button').filter({ hasText: '基本资料' }).dispatchEvent('click'); await nameField(page).waitFor(); assert.equal(await profileHeading(page).innerText(), '基本资料'); assert.equal(await nameField(page).inputValue(), '未保存称呼'); await page.getByRole('button', { name: '关闭资料详情', exact: true }).click(); await page.locator('.cp-preparation button').filter({ hasText: '交流偏好' }).click(); assert.equal(await profileHeading(page).innerText(), '陪伴偏好'); await page.getByRole('button', { name: '关闭资料详情', exact: true }).click(); await page.locator('.cp-identity button').click(); assert.equal(await profileHeading(page).innerText(), '基本资料'); assert.equal(await page.getByText('所在时区', { exact: true }).count(), 0); assert.doesNotMatch(await page.locator('body').innerText(), /America\/Los_Angeles|Asia\/Shanghai/); await page.close(); }); await t.test('save submits once without a confirmation and keeps the original timezone and baseline', async () => { const page = await newPage(); await page.locator('.cp-identity button').click(); await nameField(page).fill('更新后的称呼'); await page.getByRole('button', { name: '保存资料', exact: true }).click(); await page.locator('.cp-profile-actions').getByText('服务器已确认保存', { exact: true }).waitFor(); const saves = await page.evaluate(() => window.profileSaves); assert.equal(saves.length, 1); assert.equal(saves[0].baseline.version, 4); assert.equal(saves[0].input.preferred_name, '更新后的称呼'); assert.equal(saves[0].input.timezone, 'America/Los_Angeles'); assert.equal(await page.locator('.cp-profile-form input[type=checkbox]').count(), 0); assert.equal(await page.getByRole('button', { name: '保存资料', exact: true }).isDisabled(), true); assert.doesNotMatch(await page.locator('.cp-profile-form').innerText(), /synthetic-elder|v4|v5/); await page.close(); }); await t.test('an unknown result keeps its draft, blocks another submit, and offers only read verification', async () => { const page = await newPage('?outcome=uncertain'); await page.locator('.cp-identity button').click(); await nameField(page).fill('未知结果草稿'); await page.getByRole('button', { name: '保存资料', exact: true }).click(); const verify = page.getByRole('button', { name: '核验服务器资料(只读)', exact: true }); await verify.waitFor(); assert.equal(await page.getByRole('button', { name: '保存资料', exact: true }).isDisabled(), true); assert.equal(await nameField(page).inputValue(), '未知结果草稿'); await verify.click(); assert.deepEqual(await page.evaluate(() => ({ saves: window.profileSaves.length, verifies: window.profileVerifies })), { saves: 1, verifies: 1 }); await page.getByRole('button', { name: '关闭资料详情', exact: true }).click(); await page.locator('.cp-preparation button').filter({ hasText: '基本资料' }).click(); assert.equal(await nameField(page).inputValue(), '未知结果草稿'); assert.equal(await page.getByRole('button', { name: '保存资料', exact: true }).isDisabled(), true); await page.close(); }); assert.deepEqual(errors, []); assert.deepEqual(blocked, []); } finally { await browser?.close(); await server.close(); // This temporary directory was created by this test and is always in tmpdir. if (path.dirname(work) !== path.resolve(tmpdir())) throw new Error('Unsafe temporary cleanup path'); await rm(work, { recursive: true, force: true }); } });