assistant/scripts/test-demand-analysis.mjs

216 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import { setTimeout as delay } from 'node:timers/promises';
import { chromium } from 'playwright';
import { pythonPath, root } from './server.mjs';
import { stopOwnedChild } from './processes.mjs';
// Own random schema, ports, profile, synthetic text/media, and loopback-only model.
// server.mjs loads .env: remove every inherited model setting before constructing fixtures.
const environment = Object.fromEntries(Object.entries(process.env).filter(([key]) => !key.startsWith('ASSISTANT_MODEL_')));
assert.match(process.env.ASSISTANT_TEST_DATABASE_URL || '', /^postgres(?:ql)?:\/\//, 'Set the dedicated PostgreSQL test database before running this isolated acceptance.');
const output = path.join(root, '.tmp', `demand-analysis-acceptance-${Date.now()}`);
fs.mkdirSync(output, { recursive: true });
const note = { id: 'a'.repeat(24), title: '装修求助:新房准备装修', body: '准备装修,需要找施工团队。', author: '合成作者' };
const comments = ['已经装完了,分享一下。', null, '准备装修,预算 20 万。'];
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII=', 'base64');
const secret = 'fixture-key-never-expose-' + randomUUID();
const fallbackSecret = 'fixture-fallback-never-expose-' + randomUUID();
const rawError = 'PRIVATE_PROVIDER_RESPONSE_NEVER_EXPOSE';
let fixtureRequests = 0;
let receivedWrites = 0;
let providerMode = 'success';
const modelCalls = [];
const providerErrors = [];
const fixture = createServer((request, response) => {
fixtureRequests += 1;
if (request.method !== 'GET') receivedWrites += 1;
const url = new URL(request.url, 'http://127.0.0.1');
if (url.pathname === '/media.png') { response.writeHead(200, { 'Content-Type': 'image/png' }); response.end(png); return; }
const detail = url.pathname === `/explore/${note.id}`;
const content = detail ? `<section data-testid="note-detail"><h1 class="title">${note.title}</h1><p class="body">${note.body}</p><span class="author">${note.author}</span></section><section data-testid="comments" style="height:360px;overflow:auto">${comments.map((text, index) => `<article class="comment" id="comment-${index + 1}" style="height:100px;box-sizing:border-box"><span class="author">合成评论作者${index + 1}</span><div class="content">${text ? `<span class="text">${text}</span>` : ''}${index > 0 ? '<img class="image" src="/media.png" width="32" height="32">' : ''}</div></article>`).join('')}</section>` : `<h1>隔离装修需求样本</h1><form action="/"><label>搜索<input data-testid="search" type="search" name="q"></label><button>搜索</button></form><section data-testid="results"><article class="card"><a data-testid="note-link" href="/explore/${note.id}">${note.title}</a><span class="author">${note.author}</span></article></section>`;
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
response.end(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>隔离装修需求样本</title><style>body{font:16px/24px sans-serif;margin:18px}h1{font-size:24px}.author,.text{display:block}a{display:block}input,button{font:inherit}.content{min-height:45px}.comment{border-bottom:1px solid #ddd}</style><main>${content}</main></html>`);
});
const provider = createServer(async (request, response) => {
try {
assert.equal(request.method, 'POST'); assert.equal(request.url, '/v1/chat/completions');
const key = request.headers.authorization;
assert.ok(key === `Bearer ${secret}` || key === `Bearer ${fallbackSecret}`);
const slot = key === `Bearer ${secret}` ? 'primary' : 'fallback';
let raw = ''; for await (const chunk of request) { raw += chunk; assert.ok(raw.length < 100000); }
const body = JSON.parse(raw); const input = JSON.parse(body.messages.find(message => message.role === 'user').content);
assert.equal(body.model, 'fixture-demand-model'); assert.ok(Array.isArray(input.sources));
assert.deepEqual(Object.keys(input).sort(), ['business_context', 'sources']);
assert.equal(input.business_context.region, null); assert.equal(input.sources.length, 3); // note + two text-bearing comments; image-only excluded
for (const source of input.sources) {
assert.deepEqual(Object.keys(source).sort(), ['context_refs', 'media_uninterpreted', 'source_ref', 'text']);
assert.deepEqual(source.context_refs, source.source_ref === 'note' ? [] : ['note']);
}
assert.ok(!raw.includes('合成作者') && !raw.includes('合成评论作者') && !raw.includes('http://') && !raw.includes('.image') && !raw.includes('comment-'));
modelCalls.push({ slot, mode: providerMode, source_refs: input.sources.map(source => source.source_ref) });
response.setHeader('Content-Type', 'application/json');
if (providerMode === 'allfail' || (providerMode === 'fallback' && slot === 'primary')) {
response.writeHead(503); response.end(JSON.stringify({ error: { message: `${rawError} ${secret} ${fallbackSecret}`, type: 'raw_provider_failure' } })); return;
}
const results = input.sources.map(source => ({ source_ref: source.source_ref, demand: source.text.includes('准备装修') ? 'explicit' : 'discussion', evidence: [source.text], role: source.text.includes('准备装修') ? 'suspected_demand_side' : 'participant', role_evidence: [source.text], expressions: [{ type: source.text.includes('准备装修') ? 'self_need' : 'case_share', evidence: [source.text] }], business_relevance: { value: 'relevant', evidence: [source.text] }, stage: source.text.includes('准备装修') ? { value: '准备装修', evidence: '准备装修' } : null, region: null, budget: source.text.includes('预算') ? { value: '20 万', evidence: '预算 20 万' } : null }));
for (const result of results) result.consultation = { value: 'no', evidence: result.evidence, context_evidence: [] };
if (providerMode === 'invalid') results[0].evidence = ['模型凭空写出的不存在原文'];
response.end(JSON.stringify({ id: 'fixture-response-' + modelCalls.length, model: 'fixture-demand-model', choices: [{ message: { role: 'assistant', content: JSON.stringify({ results }) }, finish_reason: 'stop' }], usage: { prompt_tokens: 120, completion_tokens: 80, total_tokens: 200, prompt_cache_hit_tokens: 10, prompt_cache_miss_tokens: 110 } }));
} catch { providerErrors.push('Provider fixture contract assertion failed'); response.writeHead(500, { 'Content-Type': 'application/json' }); response.end('{"error":{"message":"fixture contract rejected"}}'); }
});
async function listen(server) { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); return `http://127.0.0.1:${server.address().port}`; }
async function unusedPort() { const server = createServer(); const base = await listen(server); await new Promise(resolve => server.close(resolve)); return Number(new URL(base).port); }
const apiPort = await unusedPort(); let uiPort = await unusedPort(); if (uiPort === apiPort) uiPort = await unusedPort();
const apiBase = `http://127.0.0.1:${apiPort}`; const uiBase = `http://127.0.0.1:${uiPort}`;
Object.assign(environment, { ASSISTANT_DATABASE_URL: process.env.ASSISTANT_TEST_DATABASE_URL || '', ASSISTANT_DATABASE_SCHEMA: 'assistant_learning_demand_' + randomUUID().replaceAll('-', ''), ASSISTANT_LEARNING_HOME: path.join(output, 'learning'), PYTHONUTF8: '1', ASSISTANT_NODE: process.execPath, ASSISTANT_ALLOWED_ORIGINS: uiBase, ASSISTANT_API_TARGET: apiBase, ASSISTANT_BROWSER_HEADLESS: '1', ASSISTANT_BROWSER_CHANNEL: process.env.ASSISTANT_TEST_BROWSER_CHANNEL || (process.platform === 'win32' ? 'msedge' : 'chromium'), ASSISTANT_INSTANCE_ID: randomUUID() });
const children = new Set(); const errors = []; let browser; let page; let fixtureStarted = false; let providerStarted = false;
function launch(command, args, name) {
const child = spawn(command, args, { cwd: root, env: environment, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); child.assistantGraceful = name.startsWith('server'); children.add(child);
const log = fs.createWriteStream(path.join(output, name + '.log')); child.stdout.pipe(log); child.stderr.pipe(log); child.on('error', error => errors.push(`${name}: ${error.message}`)); return child;
}
async function stop(child) { await stopOwnedChild(child, { graceful: child.assistantGraceful }); children.delete(child); }
async function ready(base, child) {
for (let index = 0; index < 100; index += 1) { if (child.exitCode !== null) throw Error('Owned demand service exited'); try { const response = await fetch(base + '/api/health', { signal: AbortSignal.timeout(1500) }); if (response.ok) { const health = await response.json(); if (health.instance_id === environment.ASSISTANT_INSTANCE_ID) return health; } } catch {} await delay(250); }
throw Error('Owned demand service was not ready');
}
class Client {
cookies = new Map(); csrf = '';
async request(method, route, body, expected = 200) {
const response = await fetch(apiBase + route, { method, redirect: 'error', signal: AbortSignal.timeout(90000), headers: { Accept: 'application/json', Cookie: [...this.cookies].map(([key, value]) => `${key}=${value}`).join('; '), Origin: apiBase, ...(body === undefined ? {} : { 'Content-Type': 'application/json', 'X-CSRF-Token': this.csrf }) }, body: body === undefined ? undefined : JSON.stringify(body) });
for (const cookie of response.headers.getSetCookie()) { const [pair] = cookie.split(';', 1); const at = pair.indexOf('='); this.cookies.set(pair.slice(0, at), pair.slice(at + 1)); }
const data = await response.json(); assert.equal(response.status, expected, `Unexpected status for ${method} ${route}`); if (data.csrf_token) this.csrf = data.csrf_token; return data;
}
}
function assertNoSecrets(value) { const text = typeof value === 'string' ? value : JSON.stringify(value); for (const forbidden of [secret, fallbackSecret, rawError]) assert.ok(!text.includes(forbidden), 'Private model material was exposed'); }
try {
const fixtureBase = await listen(fixture); fixtureStarted = true; const providerBase = await listen(provider); providerStarted = true;
// Override all eight supported model settings; the child must never use .env model credentials.
Object.assign(environment, { ASSISTANT_MODEL_BASE_URL: providerBase + '/v1', ASSISTANT_MODEL_NAME: 'fixture-demand-model', ASSISTANT_MODEL_API_KEY: secret, ASSISTANT_MODEL_FALLBACK_API_KEY: fallbackSecret, ASSISTANT_MODEL_PROVIDER: 'isolated-fixture', ASSISTANT_MODEL_TIMEOUT_SECONDS: '10', ASSISTANT_MODEL_THINKING: 'disabled', ASSISTANT_MODEL_MAX_OUTPUT_TOKENS: '4096' });
for (const key of ['BASE_URL', 'NAME', 'API_KEY', 'FALLBACK_API_KEY', 'PROVIDER', 'TIMEOUT_SECONDS', 'THINKING', 'MAX_OUTPUT_TOKENS']) assert.equal(typeof environment[`ASSISTANT_MODEL_${key}`], 'string');
assert.equal(new URL(environment.ASSISTANT_MODEL_BASE_URL).hostname, '127.0.0.1');
const backendArgs = ['-m', 'scripts.serve', '--port', String(apiPort)]; let backend = launch(pythonPath(), backendArgs, 'server'); const health = await ready(apiBase, backend);
assert.equal(health.database_backend, 'postgresql');
const client = new Client(); await client.request('GET', '/api/auth/status'); await client.request('POST', '/api/auth/login', { username: 'admin', password: '123456' });
async function task(name, account, parameters) { const created = await client.request('POST', '/api/learning/tasks', { name, platform: 'demo', entry_url: fixtureBase, account_key: account, goal: '隔离模型判断,不对真实用户联系', parameters }, 201); await client.request('POST', `/api/learning/tasks/${created.id}/browser/open`, {}); await client.request('POST', `/api/learning/tasks/${created.id}/browser/ready`, { identity_label: '需求合成维护者' }); await client.request('POST', `/api/learning/tasks/${created.id}/browser/capture`, {}); return created; }
async function freeze(taskId, name) { const candidate = await client.request('POST', `/api/learning/tasks/${taskId}/candidates`, { name }, 201); const checked = await client.request('POST', `/api/learning/candidates/${candidate.id}/check`, {}); assert.equal(checked.status, 'passed'); return candidate; }
async function searchActions(taskId) { const act = body => client.request('POST', `/api/learning/tasks/${taskId}/browser/action`, body); await act({ kind: 'fill', selector: '[data-testid="search"]', parameter: 'keyword' }); await act({ kind: 'press', selector: '[data-testid="search"]', key: 'Enter' }); await act({ kind: 'wait', selector: '[data-testid="note-link"]', state: 'visible', min_count: 1, timeout_ms: 3000 }); return act; }
const searchTask = await task('需求夹具搜索', 'demand-search', { keyword: '装修' });
await (await searchActions(searchTask.id))({ kind: 'extract', selector: '[data-testid="results"]', row_selector: '.card', output_name: 'search_results', complete_only: true, limit: 5, fields: [{ name: 'title', selector: '[data-testid="note-link"]', source: 'text', required: false }, { name: 'author', selector: '.author', source: 'text', required: false }, { name: 'url', selector: '[data-testid="note-link"]', source: 'href', required: true }] });
const searchCandidate = await freeze(searchTask.id, '需求夹具搜索候选');
const run = await client.request('POST', '/api/discovery/runs', { search_candidate_id: searchCandidate.id, keyword: '装修', service: '装修服务', target_customer: '有装修需求的业主', region: '', max_selected: 1 }, 201); assert.equal(run.status, 'completed'); assert.equal(run.selected.length, 1);
await client.request('POST', `/api/learning/tasks/${searchTask.id}/browser/close`, {});
const detailTask = await task('需求夹具七步阅读', 'demand-detail', { keyword: '装修', note_title: note.title }); const act = await searchActions(detailTask.id);
await act({ kind: 'click', selector: '[data-testid="note-link"]', target: { field: 'text', parameter: 'note_title' } }); await act({ kind: 'wait', selector: '[data-testid="note-detail"]', state: 'visible', timeout_ms: 3000 });
await act({ kind: 'extract', selector: '[data-testid="note-detail"]', output_name: 'note_detail', limit: 1, fields: ['title', 'body', 'author'].map(name => ({ name, selector: '.' + name, source: 'text', required: true })) });
await act({ kind: 'extract', selector: '[data-testid="comments"]', row_selector: '.comment', output_name: 'comments', complete_only: true, limit: 3, batch: { scroll_selector: '[data-testid="comments"]', pixels: 100, max_batches: 2, dedupe_field: 'comment_id' }, fields: [{ name: 'comment_id', source: 'attribute', attribute: 'id', required: true }, { name: 'author', selector: '.author', source: 'text', required: true }, { name: 'body', selector: '.content', source: 'content', text_selector: '.text', media_selector: 'img.image', max_media: 1, required: true }] });
const detailCandidate = await freeze(detailTask.id, '需求夹具七步候选');
const reading = await client.request('POST', `/api/discovery/runs/${run.id}/readings`, { detail_candidate_id: detailCandidate.id, note_id: note.id }, 201); assert.equal(reading.status, 'completed', JSON.stringify(reading.error_codes)); assert.equal(reading.comments.count, 3); assert.deepEqual(reading.comments.items.map(item => item.body.kind), ['text', 'media', 'mixed']);
await client.request('POST', `/api/learning/tasks/${detailTask.id}/browser/close`, {}); const platformRequestsBeforeAnalysis = fixtureRequests;
const config = await client.request('GET', '/api/analysis/config'); assert.equal(config.configured, true); assert.equal(config.model, 'fixture-demand-model'); assert.deepEqual(config.key_slots, ['primary', 'fallback']); assertNoSecrets(config); assert.ok(!Object.hasOwn(config, 'base_url'));
const frontend = launch(process.execPath, [path.join(root, 'apps/admin/node_modules/vite/bin/vite.js'), '--config', path.join(root, 'apps/admin/vite.config.ts'), '--host', '127.0.0.1', '--port', String(uiPort), '--strictPort'], 'admin'); await ready(uiBase, frontend);
browser = await chromium.launch({ headless: true, channel: environment.ASSISTANT_BROWSER_CHANNEL }); page = await browser.newPage({ viewport: { width: 1460, height: 1050 } }); page.on('pageerror', error => errors.push(error.message));
await page.goto(`${uiBase}/#/discovery/${run.id}?reading=${reading.id}`); await page.getByTestId('auth-username').fill('admin'); await page.getByTestId('auth-password').fill('123456'); await page.getByTestId('auth-submit').click();
const card = page.getByTestId('reading-demand-analysis'); await card.waitFor(); await card.getByTestId('demand-model-config').getByText('fixture-demand-model', { exact: true }).waitFor();
const drawer = page.getByTestId('demand-analysis-drawer');
const history = card.getByTestId('demand-analysis-history');
async function closeAnalysis() {
if (await drawer.isVisible()) await drawer.getByRole('button', { name: '关闭分析详情', exact: true }).click();
await drawer.waitFor({ state: 'hidden' });
}
async function visibleInViewport(locator) {
await locator.waitFor({ state: 'visible' });
// A present offscreen inline detail was the original regression. Verify actual pixels,
// including clipping by the drawer scroll container, rather than only DOM presence.
await page.waitForFunction(element => {
const rect = element.getBoundingClientRect();
let top = 0, left = 0, right = innerWidth, bottom = innerHeight;
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
const style = getComputedStyle(parent);
const bounds = parent.getBoundingClientRect();
if (/(auto|scroll|hidden|clip)/.test(style.overflowY)) { top = Math.max(top, bounds.top); bottom = Math.min(bottom, bounds.bottom); }
if (/(auto|scroll|hidden|clip)/.test(style.overflowX)) { left = Math.max(left, bounds.left); right = Math.min(right, bounds.right); }
}
return rect.width > 0 && rect.height > 0 && rect.top >= top - 1 && rect.bottom <= bottom + 1 && rect.left >= left - 1 && rect.right <= right + 1;
}, await locator.elementHandle());
}
async function shownAnalysis(analysis) {
await drawer.waitFor({ state: 'visible' });
await drawer.locator(`[data-testid="demand-analysis-detail"][data-analysis-id="${analysis.id}"]`).waitFor({ state: 'visible' });
await visibleInViewport(drawer.getByText('逐项需求与原文依据', { exact: true }));
assert.equal(await drawer.getByTestId('demand-analysis-usage').isVisible(), false, 'Technical details should be collapsed when an analysis is opened.');
assertNoSecrets(await drawer.innerText());
}
async function openHistory(analysis) {
await closeAnalysis();
await history.locator(`tr[data-row-key="${analysis.id}"]`).getByRole('button', { name: '查看分析', exact: true }).click();
await shownAnalysis(analysis);
}
async function openTechnical() {
const usage = drawer.getByTestId('demand-analysis-usage');
if (!await usage.isVisible()) await drawer.getByTestId('demand-analysis-technical').getByText('模型配置、用量与请求记录', { exact: true }).click();
await usage.waitFor({ state: 'visible' });
}
async function analyze(mode) { await closeAnalysis(); providerMode = mode; const received = page.waitForResponse(reply => reply.request().method() === 'POST' && reply.url().endsWith(`/api/discovery/readings/${reading.id}/analyses`), { timeout: 90000 }); void received.catch(() => {}); await card.getByTestId('demand-analysis-start').click(); const response = await received; assert.equal(response.status(), 201); assert.deepEqual(response.request().postDataJSON(), {}); const analysis = await response.json(); assertNoSecrets(analysis); await shownAnalysis(analysis); await page.waitForFunction(() => !document.querySelector('[data-testid="demand-analysis-start"]')?.disabled); return analysis; }
const first = await analyze('success'); assert.equal(first.status, 'completed'); assert.equal(first.result_version, 'demand-result-v3'); assert.equal(first.results.length, 4); assert.equal(first.sources.length, 4); assert.equal(first.attempts.length, 1); assert.equal(first.usage.total_tokens, 200);
const noteSourceText = first.sources.find(source => source.source_ref === 'note').text;
assert.ok(noteSourceText.includes(note.title) && noteSourceText.includes(note.body));
const firstCallCount = modelCalls.length; const reused = await analyze('success'); assert.deepEqual(reused, first); assert.equal(modelCalls.length, firstCallCount); await card.getByTestId('demand-analysis-reuse').getByText('相同配置复用已完成结果,本次没有新增模型调用。', { exact: true }).waitFor();
const media = first.results.find(result => first.sources.find(source => source.source_ref === result.source_ref)?.text === ''); assert.equal(media.demand, 'insufficient'); assert.equal(media.media_uninterpreted, true); assert.deepEqual(media.evidence, []);
await drawer.locator('[data-testid="demand-source-result"][data-source-ref="note"]').locator(':scope > .ant-space').first().getByText('明确需求', { exact: true }).waitFor(); await drawer.locator(`[data-testid="demand-source-result"][data-source-ref="${media.source_ref}"]`).locator(':scope > .ant-space').first().getByText('信息不足', { exact: true }).waitFor();
await visibleInViewport(drawer.locator('[data-source-ref="note"]').getByText('已读原文:' + noteSourceText, { exact: true }));
await page.screenshot({ path: path.join(output, 'demand-analysis-success.png'), fullPage: false });
await openTechnical(); await drawer.getByTestId('demand-analysis-usage').getByText('总 Token:200', { exact: true }).waitFor();
await openHistory(first); await visibleInViewport(drawer.locator('[data-source-ref="note"]').getByText('已读原文:' + noteSourceText, { exact: true })); await page.screenshot({ path: path.join(output, 'demand-analysis-grounded-sources.png'), fullPage: false });
async function changeConfig(mode) { await closeAnalysis(); await stop(backend); assert.equal(backend.exitCode, 0); environment.ASSISTANT_MODEL_PROVIDER = `isolated-fixture-${mode}`; backend = launch(pythonPath(), backendArgs, 'server-config-' + mode); await ready(apiBase, backend); await card.getByRole('button', { name: '刷新分析记录', exact: true }).click(); await card.getByTestId('demand-model-config').getByText(environment.ASSISTANT_MODEL_PROVIDER, { exact: true }).waitFor(); }
await changeConfig('fallback');
const backup = await analyze('fallback'); assert.equal(backup.status, 'completed'); assert.deepEqual(backup.attempts.map(attempt => [attempt.slot, attempt.status]), [['primary', 'failed'], ['fallback', 'succeeded']]); await openTechnical(); assert.equal(await drawer.getByTestId('demand-model-attempt').count(), 2);
assert.equal(backup.attempts[0].usage, null); assert.equal(backup.usage.total_tokens, 200);
await changeConfig('allfail');
const failed = await analyze('allfail'); assert.equal(failed.status, 'failed'); assert.equal(failed.attempts.length, 2); assert.deepEqual(failed.results, []); await drawer.getByTestId('demand-analysis-failure').waitFor(); await page.screenshot({ path: path.join(output, 'demand-analysis-failed-history.png'), fullPage: false });
assert.equal(failed.usage, null); await openTechnical(); await drawer.getByTestId('demand-analysis-usage').getByText('总 Token:未知', { exact: true }).waitFor();
await changeConfig('invalid');
const invalid = await analyze('invalid'); assert.equal(invalid.status, 'failed'); assert.deepEqual(invalid.results, []); // fabricated evidence is never a successful demand result
const records = await client.request('GET', `/api/discovery/readings/${reading.id}/analyses`); assert.equal(records.items.length, 4); assertNoSecrets(records);
for (const record of records.items) assert.deepEqual(record.cost, { status: 'unknown', amount: null, currency: null });
for (const record of [first, backup, failed, invalid]) assert.deepEqual(await client.request('GET', `/api/discovery/analyses/${record.id}`), record);
assert.deepEqual(await client.request('GET', `/api/discovery/readings/${reading.id}`), reading); assert.deepEqual(await client.request('GET', `/api/discovery/runs/${run.id}`), run);
const callsBeforeViewing = modelCalls.length;
// Match the user's history action exactly: open, close, repeat the SAME row,
// then another row, with the requested record shown in the visible drawer.
await openHistory(first); await closeAnalysis();
assert.deepEqual(await client.request('GET', `/api/discovery/readings/${reading.id}/analyses`), records);
await openHistory(first);
await openHistory(backup); await openTechnical(); assert.equal(await drawer.getByTestId('demand-model-attempt').count(), 2);
await openHistory(failed); await visibleInViewport(drawer.getByTestId('demand-analysis-failure'));
await openHistory(first);
await page.setViewportSize({ width: 980, height: 760 });
await visibleInViewport(drawer.getByText('逐项需求与原文依据', { exact: true }));
await visibleInViewport(drawer.locator('[data-source-ref="note"]').getByText('已读原文:' + noteSourceText, { exact: true }));
const smallScreenGeometry = await drawer.evaluate(element => {
const content = element.closest('.ant-drawer-content') || element.querySelector('.ant-drawer-content') || element;
const rect = content.getBoundingClientRect();
const targets = [content, ...content.querySelectorAll('[data-testid="demand-analysis-detail"], [data-testid="demand-analysis-results"], [data-testid="demand-source-result"]')];
return { viewport: { width: innerWidth, height: innerHeight }, bounds: { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }, horizontal_overflows: targets.filter(target => target.scrollWidth > target.clientWidth + 2).map(target => target.getAttribute('data-testid') || 'drawer-content') };
});
assert.ok(smallScreenGeometry.bounds.left >= -1 && smallScreenGeometry.bounds.right <= 981 && smallScreenGeometry.bounds.top >= -1 && smallScreenGeometry.bounds.bottom <= 761);
assert.deepEqual(smallScreenGeometry.horizontal_overflows, []);
await page.screenshot({ path: path.join(output, 'demand-analysis-small-screen-visible-original.png'), fullPage: false });
await closeAnalysis();
assert.equal(modelCalls.length, callsBeforeViewing);
assert.deepEqual(await client.request('GET', `/api/discovery/readings/${reading.id}/analyses`), records);
assertNoSecrets(await card.innerText());
const callCount = modelCalls.length; await page.reload(); await history.locator('tbody tr[data-row-key]').first().waitFor(); await drawer.waitFor({ state: 'hidden' }); await delay(100); assert.equal(modelCalls.length, callCount);
await stop(backend); assert.equal(backend.exitCode, 0); backend = launch(pythonPath(), backendArgs, 'server-restart'); await ready(apiBase, backend);
assert.deepEqual(await client.request('GET', `/api/discovery/readings/${reading.id}/analyses`), records); assert.deepEqual(await client.request('GET', `/api/discovery/readings/${reading.id}`), reading); await page.reload(); await history.locator('tbody tr[data-row-key]').first().waitFor(); await drawer.waitFor({ state: 'hidden' }); await openHistory(failed); await drawer.getByTestId('demand-analysis-failure').waitFor(); assertNoSecrets(await drawer.innerText());
await page.screenshot({ path: path.join(output, 'demand-analysis-restored.png'), fullPage: false });
assert.equal(fixtureRequests, platformRequestsBeforeAnalysis); assert.equal(receivedWrites, 0); assert.deepEqual(errors, []); assert.deepEqual(providerErrors, []);
const summary = { result: 'passed', database_backend: health.database_backend, discovery_run_id: run.id, reading_id: reading.id, analysis_ids: [first.id, backup.id, failed.id, invalid.id], safe_config_status: true, text_evidence_grounded: true, pure_media_insufficient: true, mixed_media_uninterpreted: true, primary_success: true, bounded_fallback: true, failure_history_retained: true, fabricated_evidence_rejected: true, actual_usage_unknown_cost: true, reading_and_discovery_immutable: true, completed_result_reused_without_model_call: true, create_opens_visible_drawer: true, actual_history_button_opens_correct_analysis: true, same_row_reopens_after_close: true, another_history_row_shows_its_own_analysis: true, closing_preserves_history: true, viewing_no_model_retry: true, small_screen_geometry: smallScreenGeometry, history_reload_no_model_retry: true, restart_persistence: true, no_private_material: true, model_calls: modelCalls.length, no_browser_requests_during_analysis: true, received_writes: receivedWrites, errors };
fs.writeFileSync(path.join(output, 'summary.json'), JSON.stringify(summary, null, 2)); console.log(JSON.stringify({ ...summary, output }));
} catch (error) { if (page && !page.isClosed()) { await page.screenshot({ path: path.join(output, 'failure.png'), fullPage: false }).catch(() => {}); const cardText = await page.getByTestId('reading-demand-analysis').innerText().catch(() => 'Analysis card not present'); const drawerText = await page.getByTestId('demand-analysis-drawer').innerText().catch(() => 'Analysis drawer not present'); const visible = cardText + '\n\n' + drawerText; assertNoSecrets(visible); fs.writeFileSync(path.join(output, 'failure-ui.txt'), visible); } console.error('Demand analysis acceptance artifacts: ' + output); throw error; }
finally { if (browser) await browser.close().catch(() => {}); for (const child of [...children]) await stop(child); if (fixtureStarted) await new Promise(resolve => fixture.close(resolve)); if (providerStarted) await new Promise(resolve => provider.close(resolve)); }