155 lines
18 KiB
JavaScript
155 lines
18 KiB
JavaScript
// Isolated UI acceptance: all application API requests are fulfilled in memory.
|
|
// It never attaches to a user's browser or calls a platform, model or real API.
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { chromium } from 'playwright';
|
|
import { createServer } from '../apps/admin/node_modules/vite/dist/node/index.js';
|
|
|
|
const root = path.resolve(import.meta.dirname, '..');
|
|
const output = path.join(root, '.tmp', `routes-ui-${Date.now()}`);
|
|
fs.mkdirSync(output, { recursive: true });
|
|
const checks = [], errors = [], forbidden = [], requests = [], records = [];
|
|
let server, browser, page, nextMode = 'completed', rejectVerify = false;
|
|
const profile = 'playwright-xhs-native-ui-v1';
|
|
const candidate = (id, ability, extra = {}) => ({ candidate_id: id, task_id: 'learn_route', name: id, version: 1, digest: (ability === 'search_notes' ? 'a' : 'b').repeat(64), platform: 'xiaohongshu', execution_profile: profile, source_origin: 'https://www.xiaohongshu.com', ability, static_status: 'passed', static_errors: [], trial_status: 'not_run', maintenance_status: 'experimental', published: false, ...extra });
|
|
const candidates = [candidate('search_ok', 'search_notes'), candidate('search_failed', 'search_notes', { static_status: 'failed' }), candidate('search_stale', 'search_notes', { static_errors: ['current contract invalid'] }), candidate('detail_ok', 'read_note_comments'), candidate('detail_wrong_task', 'read_note_comments', { task_id: 'other' }), candidate('detail_legacy', 'read_note_comments', { execution_profile: null }), candidate('detail_wrong_origin', 'read_note_comments', { source_origin: 'https://example.test' })];
|
|
const ref = id => { const item = candidates.find(item => item.candidate_id === id); return { candidate_id: id, digest: item.digest, task_id: item.task_id, name: item.name, version: item.version }; };
|
|
const plan = values => ({ route_version: 'learned-read-route-v1', platform: values.platform, task_type: values.task_type, published: false, mode: 'exploration', model_calls: 0, task_id: 'learn_route', execution_profile: profile, search: ref(values.search_candidate_id), detail: ref(values.detail_candidate_id) });
|
|
const bounded = (items, source, extra = {}) => ({ items, count: items.length, truncated: true, source_url: source, coverage: 'bounded', ...extra });
|
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
const settleDrawer = locator => locator.evaluate(async element => {
|
|
let previous = '', stable = 0;
|
|
for (let frame = 0; frame < 120; frame++) {
|
|
await new Promise(resolve => requestAnimationFrame(resolve));
|
|
const target = element.querySelector('.ant-drawer-content') || element, rect = target.getBoundingClientRect();
|
|
const stamp = [rect.left, rect.right, rect.top, rect.bottom].map(value => value.toFixed(2)).join(',');
|
|
const animated = element.getAnimations({ subtree: true }).some(animation => animation.playState === 'running');
|
|
stable = stamp === previous && !animated ? stable + 1 : 0; previous = stamp;
|
|
if (frame > 22 && stable >= 4) return;
|
|
}
|
|
throw Error('Drawer did not stabilize');
|
|
});
|
|
const closeResult = async () => { await page.getByTestId('route-result-drawer').locator('button.ant-drawer-close').click(); await page.getByTestId('route-result-drawer').waitFor({ state: 'hidden' }); };
|
|
try {
|
|
server = await createServer({ configFile: path.join(root, 'apps/admin/vite.config.ts'), server: { host: '127.0.0.1', port: 0, strictPort: false } });
|
|
await server.listen(); const base = `http://127.0.0.1:${server.httpServer.address().port}`;
|
|
browser = await chromium.launch({ channel: process.platform === 'win32' ? 'msedge' : 'chromium', headless: true });
|
|
page = await browser.newPage({ viewport: { width: 1280, height: 1100 } });
|
|
page.on('pageerror', error => errors.push(error.message));
|
|
await page.route('**/*', async route => {
|
|
const request = route.request(), url = new URL(request.url());
|
|
if (url.origin !== base) { forbidden.push(url.origin + url.pathname); return route.abort(); }
|
|
if (!url.pathname.startsWith('/api/')) return route.continue();
|
|
const body = request.postDataJSON(), item = { method: request.method(), path: url.pathname, body }; requests.push(item);
|
|
let data, status = 200;
|
|
if (url.pathname === '/api/auth/status') data = { initialized: true, authenticated: true, csrf_token: 'fixture', user: { username: 'fixture' } };
|
|
else if (url.pathname === '/api/learning/routes' && item.method === 'GET') data = { items: candidates, total: candidates.length };
|
|
else if (url.pathname === '/api/learning/validations' && item.method === 'GET') data = { items: [...records].reverse(), total: records.length };
|
|
else if (url.pathname === '/api/learning/routes/resolve' && item.method === 'POST') { await wait(200); data = plan(body); }
|
|
else if (url.pathname === '/api/learning/routes/verify' && item.method === 'POST') {
|
|
await wait(300);
|
|
if (rejectVerify) { data = { detail: { message: '候选摘要与已选择链路不同,请重新召回确认版本', code: 'ROUTE_DIGEST_MISMATCH' } }; status = 409; }
|
|
else {
|
|
const number = records.filter(record => record.kind === 'route').length + 1;
|
|
const searchId = `validation_search_${number}`, detailId = nextMode === 'failed' ? null : `validation_detail_${number}`;
|
|
const source = 'https://www.xiaohongshu.com/explore/abcdef123456abcdef123456';
|
|
const search = { id: searchId, candidate_id: 'search_ok', task_id: 'learn_route', kind: 'browser', mode: 'exploration', status: nextMode === 'failed' ? 'failed' : 'passed', created_at: '2026-10-25T08:00:00Z', model_calls: 0, actual_outputs: {}, errors: [] };
|
|
records.push(search);
|
|
if (detailId) records.push({ ...search, id: detailId, candidate_id: 'detail_ok', status: nextMode === 'completed' ? 'passed' : 'failed' });
|
|
data = { id: `validation_route_${number}`, candidate_id: 'search_ok', task_id: 'learn_route', kind: 'route', status: nextMode === 'completed' ? 'passed' : 'failed', route_status: nextMode, route: plan(body), keyword: body.keyword, selected: nextMode === 'failed' ? null : { index: 1, note_id: 'abcdef123456abcdef123456', note_title: '本机界面夹具笔记', note_url: source }, search_validation_id: searchId, detail_validation_id: detailId, actual_outputs: nextMode === 'failed' ? {} : { search_results: bounded([{ title: '本机界面夹具笔记', author: '示例作者', url: source }], 'https://www.xiaohongshu.com/search_result?keyword=fixture'), note_detail: bounded([{ title: '本机界面夹具笔记', body: '界面夹具正文,仅用于核对操作链输出。' }], source), ...(nextMode === 'completed' ? { comments: bounded([{ text: { kind: 'mixed', text: '这是夹具评论,不代表需求。', media: [{ kind: 'image', selector: '.fixture' }] }, author: '示例评论者' }], source, { batch_summary: { batches_read: 2, duplicates_removed: 1, stop_reason: 'batch_budget' } }) } : {}) }, errors: nextMode === 'completed' ? [] : ['夹具:读取阶段未完成'], error_codes: nextMode === 'completed' ? [] : ['FIXTURE_TIMEOUT'], model_calls: 0, published: false, mode: 'exploration', created_at: '2026-10-25T08:00:00Z' };
|
|
data.usable_outputs = structuredClone(data.actual_outputs);
|
|
if (nextMode === 'partial') data.actual_outputs.comments = bounded([{ text: '未绑定评论不能展示成可用结果。' }], source);
|
|
records.push(data); status = 201;
|
|
}
|
|
} else { forbidden.push(item.method + ' ' + item.path); return route.abort(); }
|
|
await route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(data) });
|
|
});
|
|
await page.goto(`${base}/#/routes`);
|
|
await page.getByRole('heading', { name: '链路复用', exact: true }).waitFor();
|
|
assert.equal(await page.getByTestId('nav-routes').getAttribute('aria-current'), 'page');
|
|
assert.equal(await page.getByTestId('nav-discovery').count(), 1);
|
|
assert.equal(requests.filter(item => item.method === 'POST').length, 0);
|
|
assert.equal(await page.getByTestId('route-resolve').isDisabled(), true);
|
|
checks.push('learning navigation includes independent route reuse; business sample retained; opening never executes');
|
|
await page.getByLabel('搜索能力版本', { exact: true }).click();
|
|
await page.locator('.ant-select-dropdown:visible').getByText(`search_ok · v1 · ${profile}`, { exact: true }).waitFor();
|
|
assert.equal(await page.locator('.ant-select-dropdown:visible').getByText(/search_failed|search_stale/).count(), 0);
|
|
await page.locator('.ant-select-dropdown:visible').getByText(`search_ok · v1 · ${profile}`, { exact: true }).click();
|
|
await page.locator('.ant-select-dropdown:visible').waitFor({ state: 'hidden' });
|
|
await page.getByLabel('详情与评论能力版本', { exact: true }).click();
|
|
await page.locator('.ant-select-dropdown:visible').getByText(`detail_ok · v1 · ${profile}`, { exact: true }).waitFor();
|
|
assert.equal(await page.locator('.ant-select-dropdown:visible').getByText(/detail_wrong_task|detail_legacy|detail_wrong_origin/).count(), 0);
|
|
await page.locator('.ant-select-dropdown:visible').getByText(`detail_ok · v1 · ${profile}`, { exact: true }).click();
|
|
await page.locator('.ant-select-dropdown:visible').waitFor({ state: 'hidden' });
|
|
checks.push('only current static-passed candidates and same task/profile/origin detail pair can be selected');
|
|
await page.getByTestId('route-keyword').fill('收纳');
|
|
await page.getByLabel('指定笔记编号', { exact: true }).fill('invalid');
|
|
assert.equal(await page.getByTestId('route-resolve').isDisabled(), true);
|
|
await page.getByLabel('指定笔记编号', { exact: true }).fill('');
|
|
await page.getByTestId('route-resolve').dblclick();
|
|
await page.getByTestId('route-plan').waitFor();
|
|
assert.equal(requests.filter(item => item.path.endsWith('/resolve')).length, 1);
|
|
assert.match(await page.getByTestId('route-plan').innerText(), new RegExp('a'.repeat(64)));
|
|
assert.equal(requests.filter(item => item.path.endsWith('/verify')).length, 0);
|
|
checks.push('parameter validation plus explicit recall locks both candidate digests, with no browser execution and double-click single POST');
|
|
await page.getByTestId('route-keyword').fill('厨房收纳');
|
|
assert.equal(await page.getByTestId('route-plan').count(), 0);
|
|
await page.getByTestId('route-resolve').click(); await page.getByTestId('route-plan').waitFor();
|
|
checks.push('changing task input invalidates previously locked route before execution');
|
|
await page.getByTestId('route-verify').dblclick();
|
|
await page.getByTestId('route-result').waitFor();
|
|
assert.equal(requests.filter(item => item.path.endsWith('/verify')).length, 1);
|
|
const payload = requests.find(item => item.path.endsWith('/verify')).body;
|
|
assert.equal(payload.keyword, '厨房收纳'); assert.equal(payload.search_digest, 'a'.repeat(64)); assert.equal(payload.detail_digest, 'b'.repeat(64));
|
|
assert.equal(Object.hasOwn(payload, 'note_id'), false);
|
|
assert.match(await page.getByTestId('route-result').innerText(), /链路完成[\s\S]*0 次[\s\S]*未正式发布/);
|
|
assert.match(await page.getByTestId('route-comments-output').innerText(), /范围已截断[\s\S]*batch_budget[\s\S]*这是夹具评论[\s\S]*媒体内容未解释/);
|
|
assert.equal(await page.getByTestId('route-note-output').getByRole('link', { name: '本次页面来源' }).getAttribute('href'), 'https://www.xiaohongshu.com/explore/abcdef123456abcdef123456');
|
|
checks.push('explicit execution sends immutable pair and changed keyword once, showing bounded original content and source without demand classification');
|
|
await page.getByTestId('route-search-validation').click(); await page.getByTestId('route-linked-validation').getByTestId('validation-record').waitFor();
|
|
assert.match(await page.getByTestId('route-linked-validation').innerText(), /validation_search_1/);
|
|
await page.getByTestId('route-linked-validation').locator('button.ant-drawer-close').click();
|
|
await page.getByTestId('route-linked-validation').waitFor({ state: 'hidden' });
|
|
await closeResult();
|
|
checks.push('route result opens associated actual search validation directly without new execution');
|
|
nextMode = 'partial'; await page.getByTestId('route-keyword').fill('客厅收纳'); await page.getByTestId('route-resolve').click(); await page.getByTestId('route-plan').waitFor(); await page.getByTestId('route-verify').click(); await page.getByTestId('route-result').waitFor();
|
|
assert.match(await page.getByTestId('route-errors').innerText(), /FIXTURE_TIMEOUT/);
|
|
assert.match(await page.getByTestId('route-note-output').innerText(), /界面夹具正文/);
|
|
assert.match(await page.getByTestId('route-comments-output').innerText(), /没有取得此项输出/);
|
|
assert.equal((await page.getByTestId('route-comments-output').innerText()).includes('未绑定评论'), false);
|
|
await closeResult(); checks.push('partial route preserves available body, identifies missing comments and keeps failure diagnostic history');
|
|
nextMode = 'failed'; await page.getByTestId('route-resolve').click(); await page.getByTestId('route-plan').waitFor(); await page.getByTestId('route-verify').click(); await page.getByTestId('route-result').waitFor();
|
|
assert.equal(await page.getByTestId('route-detail-validation').count(), 0);
|
|
assert.match(await page.getByTestId('route-result').innerText(), /链路未完成/);
|
|
await closeResult(); assert.equal(await page.getByTestId('route-history').getByRole('button', { name: '查看链路结果' }).count(), 3);
|
|
checks.push('failed search creates no fabricated detail stage and earlier completed/partial route records remain accessible');
|
|
rejectVerify = true; await page.getByTestId('route-resolve').click(); await page.getByTestId('route-plan').waitFor(); await page.getByTestId('route-verify').click(); await page.getByTestId('route-error').waitFor();
|
|
assert.match(await page.getByTestId('route-error').innerText(), /候选摘要/);
|
|
assert.equal(await page.getByTestId('route-plan').count(), 0);
|
|
const afterError = requests.filter(item => item.path.endsWith('/verify')).length; await wait(500); assert.equal(requests.filter(item => item.path.endsWith('/verify')).length, afterError);
|
|
checks.push('HTTP conflict invalidates lock and shows actionable error without automatic retries or login confirmation');
|
|
await page.setViewportSize({ width: 390, height: 900 });
|
|
await page.getByTestId('route-resolve').click(); await page.getByTestId('route-plan').waitFor(); await page.getByTestId('route-plan').scrollIntoViewIfNeeded();
|
|
const planGeometry = await page.getByTestId('route-plan').evaluate(element => ({ left: element.getBoundingClientRect().left, right: element.getBoundingClientRect().right, overflow: element.scrollWidth > element.clientWidth + 2 }));
|
|
assert.ok(planGeometry.left >= -1 && planGeometry.right <= 391); assert.equal(planGeometry.overflow, false);
|
|
await page.screenshot({ path: path.join(output, 'route-plan-mobile.png'), fullPage: false });
|
|
await page.evaluate(() => { location.hash = '/routes?validation=validation_route_1'; }); await page.getByTestId('route-result').waitFor();
|
|
await settleDrawer(page.getByTestId('route-result-drawer'));
|
|
await page.getByTestId('route-comments-output').scrollIntoViewIfNeeded();
|
|
const drawerGeometry = await page.getByTestId('route-result-drawer').evaluate(element => { const el = element.querySelector('.ant-drawer-content') || element; const rect = el.getBoundingClientRect(); return { left: rect.left, right: rect.right, overflow: el.scrollWidth > el.clientWidth + 2 }; });
|
|
assert.ok(drawerGeometry.left >= -1 && drawerGeometry.right <= 391); assert.equal(drawerGeometry.overflow, false);
|
|
await page.screenshot({ path: path.join(output, 'route-result-mobile.png'), fullPage: false });
|
|
checks.push('390px locked digests and source result remain within viewport; history URL opens stored result without executing');
|
|
await page.setViewportSize({ width: 1280, height: 1100 }); await settleDrawer(page.getByTestId('route-result-drawer')); await page.getByTestId('route-result-drawer').locator('.ant-drawer-body').evaluate(element => { element.scrollTop = 0; }); await page.screenshot({ path: path.join(output, 'route-result-desktop.png'), fullPage: false });
|
|
assert.deepEqual(errors, []); assert.deepEqual(forbidden, []);
|
|
assert.ok(requests.every(item => !/analysis|business|browser|confirm/.test(item.path)));
|
|
fs.writeFileSync(path.join(output, 'summary.json'), JSON.stringify({ passed: true, checks, page_errors: errors, forbidden, mock_requests: requests.length, mock_resolve_posts: requests.filter(item => item.path.endsWith('/resolve')).length, mock_verify_posts: requests.filter(item => item.path.endsWith('/verify')).length, platform_requests: 0, real_api_requests: 0, model_calls: 0, planGeometry, drawerGeometry }, null, 2));
|
|
console.log(JSON.stringify({ passed: true, checks: checks.length, report: path.relative(root, path.join(output, 'summary.json')) }));
|
|
} catch (error) {
|
|
const geometry = await page?.evaluate(() => [...document.querySelectorAll('.workspace,.page,.workbench-page,.surface-card,.form-grid,.ant-form-item,.ant-select')].map(element => { const rect = element.getBoundingClientRect(), style = getComputedStyle(element); return { name: element.className, width: rect.width, left: rect.left, grid: style.gridTemplateColumns, minWidth: style.minWidth, display: style.display }; })).catch(() => null);
|
|
fs.writeFileSync(path.join(output, 'summary.json'), JSON.stringify({ passed: false, checks, page_errors: errors, forbidden, geometry, error: error.stack }, null, 2));
|
|
await page?.screenshot({ path: path.join(output, 'failure.png'), fullPage: true }).catch(() => {});
|
|
console.log(JSON.stringify({ passed: false, checks: checks.length, report: path.relative(root, path.join(output, 'summary.json')), error: error.message })); process.exitCode = 1;
|
|
} finally { await browser?.close(); await server?.close(); }
|