163 lines
9.8 KiB
JavaScript
163 lines
9.8 KiB
JavaScript
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('<!doctype html><meta charset="UTF-8"><div id="root"></div><script type="module" src="/@id/__profile-test.tsx"></script>');
|
||
|
|
});
|
||
|
|
},
|
||
|
|
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 });
|
||
|
|
}
|
||
|
|
});
|