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 ? `${note.title}
${note.body}
${note.author}${comments.map((text, index) => ``).join('')}` : `
隔离装修需求样本
`;
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
response.end(`隔离装修需求样本${content}`);
});
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)); }