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) => `
合成评论作者${index + 1}
${text ? `${text}` : ''}${index > 0 ? '' : ''}
`).join('')}
` : `

隔离装修需求样本

${note.title}${note.author}
`; 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)); }