import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { LearningControlClient } from '../tools/learning/control.mjs'; const escape = value => String(value).replace(/[&<>"']/g, character => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[character]); // A new isolated synthetic surface, unrelated to any open user tab or account. export async function startLearningFixture() { let receivedWrites = 0; const server = createServer((request, response) => { if (request.method === 'POST') receivedWrites += 1; const url = new URL(request.url, 'http://127.0.0.1'); const keyword = url.searchParams.get('q') || '建材'; const noteId = url.searchParams.get('note') || 'note-1'; response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }); const content = url.pathname === '/viewport-search' ? `

整页滚动合成结果

首屏合成笔记整页合成笔记1整页合成笔记2
` : url.pathname === '/failure' ? `

失败取证夹具

尚未点击

` : url.pathname === '/batch-detail' ? `

有界多批合成评论

${[1, 2, 3, 4].map(index => `
合成作者${index}

跨批合成需求${index}

`).join('')}
` : url.pathname === '/empty-detail' ? `

明确空态合成页面

暂无符合条件的评论

` : url.pathname === '/detail' ? `

${escape(keyword)} · ${escape(noteId)}

${[1, 2].map(index => `
本机合成作者${index}

${escape(keyword)} ${escape(noteId)} 第${index}条需求

作者来源
`).join('')}
` : `

检索完成

${[1, 2].map(index => `
${escape(keyword)} 合成笔记${index}
`).join('')}
`; response.end(`隔离学习工具验收
${content}
`); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); return { base: `http://127.0.0.1:${server.address().port}`, receivedWrites: () => receivedWrites, close: () => new Promise(resolve => server.close(resolve)) }; } export async function acceptLearningTools({ client, apiBase, fixtureBase, username, password }) { const bridge = new LearningControlClient({ baseUrl: apiBase, username, password }); assert.equal((await bridge.execute({ operation: 'health' })).status, 'ok'); const task = await bridge.execute({ operation: 'tasks.create', data: { name: '本机动态对象与结构化读取', platform: 'demo', entry_url: fixtureBase, account_key: 'bounded-tools-fixture', goal: '隔离工具验收,不访问真实平台', parameters: { keyword: '建材', target_note: 'note-2', target_comment: 'comment-2' }, } }); assert.equal((await bridge.execute({ operation: 'tasks.get', task_id: task.id })).id, task.id); await bridge.execute({ operation: 'browser.open', task_id: task.id }); await bridge.execute({ operation: 'browser.ready', task_id: task.id, data: { identity_label: '本机合成维护者' } }); await bridge.execute({ operation: 'browser.capture', task_id: task.id, data: { note: '本机夹具首次观察' } }); const action = data => client.request('POST', `/api/learning/tasks/${task.id}/browser/action`, data); await action({ kind: 'fill', selector: '[data-testid="search-input"]', parameter: 'keyword' }); await action({ kind: 'press', selector: '[data-testid="search-input"]', key: 'Enter' }); await action({ kind: 'wait', selector: '[data-testid="results-title"]', state: 'text', text: '检索完成', timeout_ms: 2000 }); await action({ kind: 'click', selector: '[data-testid="note-link"]', target: { field: 'attribute', attribute: 'data-note-id', parameter: 'target_note' } }); const fields = [ { name: 'comment_id', source: 'attribute', attribute: 'data-comment-id', required: true }, { name: 'author', selector: '.author', source: 'text', required: true }, { name: 'body', selector: '.body', source: 'text', required: true }, { name: 'source', selector: '.source', source: 'href', required: true }, ]; const extraction = await action({ kind: 'extract', selector: '[data-testid="comment-row"]', output_name: 'comments', fields, limit: 10 }); assert.equal(extraction.result.count, 2); assert.equal(extraction.result.items[1].comment_id, 'comment-2'); assert.match(extraction.result.items[1].body, /建材 note-2/); assert.equal(extraction.result.items[1].source, fixtureBase + '/people/2'); assert.equal(extraction.result.coverage, 'bounded'); assert.equal(extraction.result.truncated, false); await action({ kind: 'extract', selector: '[data-testid="comment-row"]', target: { field: 'attribute', attribute: 'data-comment-id', parameter: 'target_comment' }, output_name: 'selected_comment', fields, limit: 10 }); await action({ kind: 'scroll', selector: 'body', direction: 'down', pixels: 120, max_scrolls: 1 }); const candidate = await bridge.execute({ operation: 'candidates.create', task_id: task.id }); assert.equal(candidate.manifest.schema_version, 2); assert.equal(candidate.manifest.steps.length, 7); assert.equal(candidate.manifest.steps[1].kind, 'press'); assert.equal(candidate.manifest.steps[1].key, 'Enter'); assert.equal(candidate.manifest.steps[3].target.parameter, 'target_note'); const checked = await bridge.execute({ operation: 'candidates.check', candidate_id: candidate.id }); assert.equal(checked.status, 'passed', JSON.stringify(checked.errors)); const parameters = { keyword: '门窗', target_note: 'note-1', target_comment: 'comment-1' }; const expected_outputs = { comments: ['门窗 note-1 第1条需求', '门窗 note-1 第2条需求'], selected_comment: '门窗 note-1 第1条需求' }; const runtime = await bridge.execute({ operation: 'candidates.verify', candidate_id: candidate.id, data: { parameters, expected_outputs, sample_name: '本机变参正式模式', mode: 'runtime', baseline_note: '隔离夹具:合成通知为空,合成功能正常;不代表平台状态', observation_window_minutes: 1, } }); assert.equal(runtime.status, 'passed', JSON.stringify(runtime.errors)); assert.equal(runtime.mode, 'runtime'); assert.ok(Date.parse(runtime.started_at) <= Date.parse(runtime.finished_at)); assert.ok(Date.parse(runtime.finished_at) < Date.parse(runtime.observation_due_at)); assert.equal(runtime.actual_outputs.selected_comment.items[0].comment_id, 'comment-1'); assert.equal(runtime.actual_outputs.selected_comment.count, 1); const pending = await bridge.execute({ operation: 'candidates.get', candidate_id: candidate.id }); assert.equal(pending.quality.state, 'awaiting_observation'); const append = data => bridge.execute({ operation: 'quality.append', validation_id: runtime.id, data }); await append({ state: 'waiting', note: '本机合成样本等待复查', source: 'manual', process_review: 'passed' }); await client.request('POST', `/api/learning/validations/${runtime.id}/quality-observations`, { state: 'clear', note: '尚未到期,不应通过', source: 'manual', process_review: 'passed', }, 409); const alert = await append({ state: 'alert', note: '本机合成后续通知,用于验证版本回退', source: 'official_message', process_review: 'passed', notice_ref: 'synthetic-local-notice-1' }); assert.equal(alert.verification, 'operator_reported'); const afterAlert = await bridge.execute({ operation: 'candidates.get', candidate_id: candidate.id }); assert.equal(afterAlert.digest, candidate.digest); assert.equal(afterAlert.quality.state, 'review_required'); assert.equal(afterAlert.quality.published, false); const observed = afterAlert.validations.find(item => item.id === runtime.id); assert.deepEqual(observed.quality_observations.map(item => item.state), ['waiting', 'alert']); assert.equal(observed.status, 'passed', 'Later feedback must not rewrite the original execution result'); const precheck = await bridge.execute({ operation: 'candidates.publish_check', candidate_id: candidate.id }); assert.equal(precheck.ready, false); await bridge.execute({ operation: 'browser.close', task_id: task.id }); const failureTask = await bridge.execute({ operation: 'tasks.create', data: { name: '本机失败取证与请求脱敏', platform: 'demo', entry_url: fixtureBase + '/failure', account_key: 'failure-evidence-fixture', parameters: {}, goal: '只读保护失败须留证,不能假定动作未发生', } }); await bridge.execute({ operation: 'browser.open', task_id: failureTask.id }); await bridge.execute({ operation: 'browser.ready', task_id: failureTask.id, data: { identity_label: '失败夹具维护者' } }); await bridge.execute({ operation: 'browser.capture', task_id: failureTask.id }); const failure = await client.request('POST', `/api/learning/tasks/${failureTask.id}/browser/action`, { kind: 'click', selector: '[data-testid="blocked-load"]' }, 409); assert.equal(failure.detail.code, 'WRITE_REQUEST_BLOCKED'); const failed = await bridge.execute({ operation: 'tasks.get', task_id: failureTask.id }); assert.equal(failed.steps.length, 0); assert.equal(failed.candidates.length, 0); const failureEvidence = failed.observations.filter(item => item.source === 'action_failure'); assert.ok(failureEvidence.length >= 2, 'Both sides of a partially executed failure must be retained'); assert.ok(failureEvidence.some(item => item.text.includes('局部点击已发生'))); assert.ok(failureEvidence.every(item => item.screenshot_url)); assert.ok(!JSON.stringify(failureEvidence).includes('HiddenDiagnosticSecret71')); assert.ok(!JSON.stringify(failureEvidence).includes('HiddenDiagnosticBody71')); assert.ok(JSON.stringify(failureEvidence).includes('/synthetic-write')); await bridge.execute({ operation: 'browser.close', task_id: failureTask.id }); return { task_id: task.id, candidate_id: candidate.id, validation_id: runtime.id, digest: candidate.digest, steps: candidate.manifest.steps.length, structured_count: runtime.actual_outputs.comments.count, parameterized_target: true, local_control_bridge: true, premature_clear_rejected: true, late_alert_review_required: true, search_enter: true, same_origin_popup: true, failure_task_id: failureTask.id, failure_evidence: failureEvidence.length }; } export async function acceptLearningToolsUI({ page, client, uiBase, tools }) { const select = async (control, name) => { await control.click(); await page.locator('.ant-select-dropdown:visible').getByText(name, { exact: true }).click(); }; await client.request('POST', `/api/learning/tasks/${tools.task_id}/browser/open`, {}); await client.request('POST', `/api/learning/tasks/${tools.task_id}/browser/ready`, { identity_label: '隔离 UI 维护者' }); await client.request('POST', `/api/learning/tasks/${tools.task_id}/browser/capture`, {}); await page.goto(`${uiBase}/#/tasks/${tools.task_id}`); const initialForm = page.getByTestId('learning-action-form'); await initialForm.waitFor(); await page.getByTestId('login-detection-notice').getByText('页面登录检测:登录状态不确定', { exact: true }).waitFor(); await select(initialForm.getByRole('combobox', { name: '操作类型', exact: true }), '提交搜索(回车)'); await initialForm.getByRole('textbox', { name: '选中的页面定位', exact: true }).fill('[data-testid="search-input"]'); const pressedResponse = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(`/tasks/${tools.task_id}/browser/action`)); await initialForm.getByRole('button', { name: '执行并记录步骤', exact: true }).click(); const pressed = await pressedResponse; assert.equal(pressed.status(), 200, await pressed.text()); assert.equal((await pressed.json()).step.key, 'Enter'); await page.goto(`${uiBase}/#/candidates/${tools.candidate_id}`); await page.getByRole('heading', { name: '本机动态对象与结构化读取', exact: true }).waitFor(); await page.getByRole('button', { name: '配置测试', exact: true }).click(); const dialog = page.getByRole('dialog', { name: '配置固定版本测试', exact: true }); await dialog.getByText('探索试跑:保留学习记录', { exact: true }).waitFor(); await dialog.getByRole('button', { name: /取.*消/ }).click(); await dialog.waitFor({ state: 'hidden' }); await page.getByRole('button', { name: '正式模式试跑', exact: true }).click(); await dialog.getByText('正式模式:独立检查操作质量', { exact: true }).waitFor(); await dialog.getByLabel('运行前基线', { exact: true }).fill('本机 UI 合成样本:通知为空,功能正常'); await dialog.getByLabel('任务后观察窗口(分钟)', { exact: true }).fill('1'); await dialog.getByLabel('测试样本名称', { exact: true }).fill('UI 正式模式与质量登记'); await dialog.getByLabel('输入参数', { exact: true }).fill(JSON.stringify({ keyword: '家具', target_note: 'note-1', target_comment: 'comment-1' })); await dialog.getByLabel('核对结果', { exact: true }).fill(JSON.stringify({ comments: ['家具 note-1 第1条需求', '家具 note-1 第2条需求'], selected_comment: '家具 note-1 第1条需求' })); const verificationResponse = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(`/candidates/${tools.candidate_id}/verify`)); await dialog.getByRole('button', { name: '执行真实试跑', exact: true }).click(); const verifiedResponse = await verificationResponse; assert.equal(verifiedResponse.status(), 200); const verified = await verifiedResponse.json(); assert.equal(verified.status, 'passed', JSON.stringify(verified.errors)); assert.equal(verified.mode, 'runtime'); assert.equal(verified.baseline_note, '本机 UI 合成样本:通知为空,功能正常'); assert.equal(verified.observation_window_minutes, 1); await dialog.waitFor({ state: 'hidden' }); const quality = page.getByTestId('quality-observations'); await quality.waitFor(); await quality.getByTestId('runtime-review-todo').getByText('待账号本人复查官方消息和功能', { exact: true }).waitFor(); await select(quality.getByRole('combobox', { name: '本次观察结论', exact: true }), '状态不确定'); await select(quality.getByRole('combobox', { name: '操作过程核对', exact: true }), '操作过程核对通过'); await quality.getByLabel('检查情况与关联依据', { exact: true }).fill('UI 合成反馈:关联尚未确认,应保持待复核'); const observationResponse = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(`/validations/${verified.id}/quality-observations`)); await quality.getByTestId('save-quality-observation').click(); assert.equal((await observationResponse).status(), 201); await quality.getByText('UI 合成反馈:关联尚未确认,应保持待复核', { exact: true }).waitFor(); await quality.locator('.quality-section-heading').getByText('待复核', { exact: true }).waitFor(); const afterQuality = await client.request('GET', `/api/learning/candidates/${tools.candidate_id}`); assert.equal(afterQuality.quality.state, 'review_required'); const afterUI = afterQuality.validations.find(item => item.id === verified.id); assert.equal(afterUI.quality_observations[0].state, 'uncertain'); await page.getByRole('button', { name: /关闭|close/i }).last().click(); await page.goto(`${uiBase}/#/tasks/${tools.task_id}`); const form = page.getByTestId('learning-action-form'); await form.waitFor(); const act = async () => { const received = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(`/tasks/${tools.task_id}/browser/action`)); await form.getByRole('button', { name: '执行并记录步骤', exact: true }).click(); const response = await received; assert.equal(response.status(), 200, await response.text()); const data = await response.json(); await form.getByRole('button', { name: '执行并记录步骤', exact: true }).waitFor({ state: 'visible' }); await form.getByRole('textbox', { name: '选中的页面定位', exact: true }).waitFor(); return data; }; await select(form.getByRole('combobox', { name: '操作类型', exact: true }), '有限滚动'); await form.getByRole('textbox', { name: '选中的页面定位', exact: true }).fill('body'); await select(form.getByLabel('方向', { exact: true }), '向上'); await form.getByLabel('每次像素', { exact: true }).fill('120'); await form.getByLabel('滚动停止条件(容器内 CSS)', { exact: true }).fill('[data-testid="comments"]'); const scrolled = await act(); assert.equal(scrolled.step.direction, 'up'); assert.equal(scrolled.step.pixels, 120); assert.equal(scrolled.step.max_scrolls, 1); assert.equal(scrolled.step.until_selector, '[data-testid="comments"]'); assert.ok(!Object.hasOwn(scrolled.step, 'complete_only')); await select(form.getByRole('combobox', { name: '操作类型', exact: true }), '等待页面状态'); await form.getByRole('textbox', { name: '选中的页面定位', exact: true }).fill('[data-testid="comment-row"]'); await form.getByLabel('至少可见数量', { exact: true }).fill('2'); await form.getByLabel('最长等待毫秒', { exact: true }).fill('2000'); const waited = await act(); assert.equal(waited.step.state, 'visible'); assert.equal(waited.step.timeout_ms, 2000); assert.equal(waited.step.min_count, 2); assert.ok(!Object.hasOwn(waited.step, 'until_selector'), 'Scroll-only form values must not leak into waits'); await select(form.getByRole('combobox', { name: '操作类型', exact: true }), '结构化提取'); await form.getByRole('textbox', { name: '选中的页面定位', exact: true }).fill('[data-testid="comment-row"]'); await form.getByLabel('输出名称', { exact: true }).fill('ui_comment'); await form.getByRole('checkbox', { name: '只读完整可见行', exact: true }).check(); await form.getByLabel('字段名称', { exact: true }).fill('body'); await form.getByLabel('行内定位', { exact: true }).fill('.body'); await form.getByRole('checkbox', { name: '按当前对象精确匹配', exact: true }).check(); await select(form.getByLabel('匹配依据', { exact: true }), '只读属性'); await form.getByLabel('匹配属性', { exact: true }).fill('data-comment-id'); await select(form.getByLabel('绑定对象参数', { exact: true }), 'target_comment = comment-2'); const extracted = await act(); assert.deepEqual(extracted.step.target, { field: 'attribute', attribute: 'data-comment-id', parameter: 'target_comment' }); assert.equal(extracted.step.complete_only, true); for (const key of ['row_selector', 'batch', 'allow_empty', 'empty_state', 'min_count']) assert.ok(!Object.hasOwn(extracted.step, key), `Optional ${key} must be absent from the legacy extraction`); assert.ok(!Object.hasOwn(extracted.step, 'until_selector'), 'Scroll-only form values must not leak into extraction'); assert.equal(extracted.result.count, 1); assert.equal(extracted.result.items[0].body, '家具 note-1 第2条需求'); await client.request('POST', `/api/learning/tasks/${tools.task_id}/browser/close`, {}); const original = await client.request('GET', `/api/learning/tasks/${tools.task_id}`); const fixtureBase = new URL(original.entry_url).origin; async function openFixtureTask(path, name, account) { const task = await client.request('POST', '/api/learning/tasks', { name, platform: 'demo', entry_url: fixtureBase + path, account_key: account, goal: '隔离UI新契约验收', parameters: {} }, 201); await client.request('POST', `/api/learning/tasks/${task.id}/browser/open`, {}); await client.request('POST', `/api/learning/tasks/${task.id}/browser/ready`, { identity_label: '本机新契约维护者' }); await client.request('POST', `/api/learning/tasks/${task.id}/browser/capture`, {}); await page.goto(`${uiBase}/#/tasks/${task.id}`); const form = page.getByTestId('learning-action-form'); await form.waitFor(); return { task, form }; } async function submitFixtureAction(taskId, form) { const received = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(`/tasks/${taskId}/browser/action`)); await form.getByRole('button', { name: '执行并记录步骤', exact: true }).click(); const response = await received; assert.equal(response.status(), 200, await response.text()); return response.json(); } const batch = await openFixtureTask('/batch-detail', 'UI有限多批与稳定标识', 'ui-batch-fixture'); await select(batch.form.getByRole('combobox', { name: '操作类型', exact: true }), '结构化提取'); await batch.form.getByRole('textbox', { name: '选中的页面定位', exact: true }).fill('[data-testid="batch-container"]'); await batch.form.getByLabel('容器内结果行(CSS,可选)', { exact: true }).fill('[data-testid="batch-row"]'); await batch.form.getByLabel('输出名称', { exact: true }).fill('ui_batch_comments'); await batch.form.getByLabel('最多提取项数', { exact: true }).fill('10'); await batch.form.getByLabel('字段名称', { exact: true }).fill('body'); await batch.form.getByLabel('行内定位', { exact: true }).fill('.body'); await batch.form.getByRole('button', { name: '增加提取字段', exact: true }).click(); const idField = batch.form.locator('.extraction-field').nth(1); await idField.getByLabel('字段名称', { exact: true }).fill('comment_id'); await select(idField.getByLabel('取值来源', { exact: true }), '只读属性'); await idField.getByLabel('属性名称', { exact: true }).fill('data-comment-id'); await batch.form.getByRole('checkbox', { name: '允许明确空态结果', exact: true }).check(); await batch.form.getByRole('checkbox', { name: '有限多批读取并去重', exact: true }).check(); assert.equal(await batch.form.getByRole('checkbox', { name: '允许明确空态结果', exact: true }).isChecked(), false); assert.equal(await batch.form.getByRole('checkbox', { name: '只读完整可见行', exact: true }).isChecked(), true); assert.equal(await batch.form.getByRole('checkbox', { name: '按当前对象精确匹配', exact: true }).count(), 0); await batch.form.getByLabel('批次滚动容器定位', { exact: true }).fill('[data-testid="batch-container"]'); await batch.form.getByLabel('每批滚动像素', { exact: true }).fill('110'); await batch.form.getByLabel('最多读取批数', { exact: true }).fill('3'); await select(batch.form.getByLabel('跨批去重字段', { exact: true }), 'comment_id (data-comment-id)'); const batched = await submitFixtureAction(batch.task.id, batch.form); assert.deepEqual(batched.step.batch, { scroll_selector: '[data-testid="batch-container"]', pixels: 110, max_batches: 3, dedupe_field: 'comment_id' }); assert.equal(batched.step.row_selector, '[data-testid="batch-row"]'); assert.equal(batched.step.complete_only, true); for (const key of ['target', 'allow_empty', 'empty_state']) assert.ok(!Object.hasOwn(batched.step, key)); assert.deepEqual(batched.result.items.map(item => item.comment_id), ['batch-1', 'batch-2', 'batch-3', 'batch-4']); assert.equal(batched.result.count, 4); assert.ok(batched.result.batch_summary, 'Batched UI extraction must expose its bounded collection summary'); assert.equal(batched.result.batch_summary.batches_read, 3); assert.ok(batched.result.batch_summary.duplicates_removed >= 1, 'Overlapping batches must deduplicate stable IDs'); await select(batch.form.getByRole('combobox', { name: '操作类型', exact: true }), '等待页面状态'); await batch.form.getByRole('textbox', { name: '选中的页面定位', exact: true }).fill('[data-testid="batch-container"]'); const afterBatchWait = await submitFixtureAction(batch.task.id, batch.form); for (const key of ['row_selector', 'batch', 'allow_empty', 'empty_state', 'complete_only', 'min_count']) assert.ok(!Object.hasOwn(afterBatchWait.step, key), `Extract-only ${key} must not leak into a wait`); await client.request('POST', `/api/learning/tasks/${batch.task.id}/browser/close`, {}); const empty = await openFixtureTask('/empty-detail', 'UI明确空态读取', 'ui-empty-fixture'); await select(empty.form.getByRole('combobox', { name: '操作类型', exact: true }), '结构化提取'); await empty.form.getByRole('textbox', { name: '选中的页面定位', exact: true }).fill('[data-testid="empty-container"]'); await empty.form.getByLabel('容器内结果行(CSS,可选)', { exact: true }).fill('[data-testid="empty-row"]'); await empty.form.getByLabel('输出名称', { exact: true }).fill('ui_empty'); await empty.form.getByRole('checkbox', { name: '有限多批读取并去重', exact: true }).check(); await empty.form.getByRole('checkbox', { name: '允许明确空态结果', exact: true }).check(); assert.equal(await empty.form.getByRole('checkbox', { name: '有限多批读取并去重', exact: true }).isChecked(), false); await empty.form.getByLabel('容器内空态定位', { exact: true }).fill('[data-testid="empty-message"]'); await empty.form.getByLabel('空态精确文字', { exact: true }).fill('暂无符合条件的评论'); const emptied = await submitFixtureAction(empty.task.id, empty.form); assert.equal(emptied.step.allow_empty, true); assert.equal(emptied.step.row_selector, '[data-testid="empty-row"]'); assert.deepEqual(emptied.step.empty_state, { selector: '[data-testid="empty-message"]', text: '暂无符合条件的评论' }); assert.ok(!Object.hasOwn(emptied.step, 'batch')); assert.equal(emptied.result.count, 0); assert.equal(emptied.result.empty_state.confirmed, true); const emptyCandidate = await client.request('POST', `/api/learning/tasks/${empty.task.id}/candidates`, { name: 'UI空态断言候选' }, 201); const emptyCheck = await client.request('POST', `/api/learning/candidates/${emptyCandidate.id}/check`, {}); assert.equal(emptyCheck.status, 'passed', JSON.stringify(emptyCheck.errors)); await page.goto(`${uiBase}/#/candidates/${emptyCandidate.id}`); await page.getByRole('button', { name: '配置测试', exact: true }).click(); await dialog.getByLabel('测试样本名称', { exact: true }).fill('UI显式空态固定断言'); await dialog.getByLabel('核对结果', { exact: true }).fill(JSON.stringify({ ui_empty: { state: 'empty' } })); const emptyResponse = page.waitForResponse(response => response.request().method() === 'POST' && response.url().endsWith(`/candidates/${emptyCandidate.id}/verify`)); await dialog.getByRole('button', { name: '执行真实试跑', exact: true }).click(); const emptyVerifiedResponse = await emptyResponse; assert.equal(emptyVerifiedResponse.status(), 200, await emptyVerifiedResponse.text()); const emptyVerified = await emptyVerifiedResponse.json(); assert.equal(emptyVerified.status, 'passed', JSON.stringify(emptyVerified.errors)); assert.deepEqual(emptyVerified.expected_outputs.ui_empty, { state: 'empty' }); assert.equal(emptyVerified.actual_outputs.ui_empty.empty_state.confirmed, true); await client.request('POST', `/api/learning/tasks/${empty.task.id}/browser/close`, {}); const viewport = await openFixtureTask('/viewport-search', 'UI整页有界定位', 'ui-viewport-fixture'); const viewportObserved = await client.request('GET', `/api/learning/tasks/${viewport.task.id}`); const viewportTargets = viewportObserved.observations.at(-1).elements.find(item => item.collection && item.selector.endsWith('a.note-link')); assert.ok(viewportTargets, 'The fixture must establish the actual target collection in its initial observation'); await select(viewport.form.getByRole('combobox', { name: '操作类型', exact: true }), '有限滚动'); await viewport.form.getByRole('textbox', { name: '选中的页面定位', exact: true }).fill('#viewport-results'); await viewport.form.getByLabel('每次像素', { exact: true }).fill('320'); await viewport.form.getByLabel('最多次数', { exact: true }).fill('5'); await viewport.form.getByRole('checkbox', { name: '按动态目标停止滚动', exact: true }).check(); await viewport.form.getByRole('checkbox', { name: '滚动整页寻找容器内目标', exact: true }).check(); await viewport.form.getByLabel('容器内目标集合', { exact: true }).fill(viewportTargets.selector); await viewport.form.getByLabel('滚动目标精确值', { exact: true }).fill('整页合成笔记1'); await viewport.form.getByLabel('动态查找最长毫秒', { exact: true }).fill('5000'); const viewportAction = await submitFixtureAction(viewport.task.id, viewport.form); assert.equal(viewportAction.step.scroll_scope, 'viewport'); assert.ok(viewportAction.result.scrolls > 0, 'The synthetic target must require an actual viewport movement'); assert.equal(viewportAction.result.target_reached, true); assert.ok(viewportAction.result.movements.some(item => item.after > item.before)); await select(viewport.form.getByRole('combobox', { name: '操作类型', exact: true }), '读取内容'); await viewport.form.getByRole('textbox', { name: '选中的页面定位', exact: true }).fill(viewportTargets.selector); await viewport.form.getByLabel('输出名称', { exact: true }).fill('ui_viewport_note'); await viewport.form.getByRole('checkbox', { name: '按当前对象精确匹配', exact: true }).check(); await viewport.form.getByLabel('本次对象的精确值', { exact: true }).fill('整页合成笔记1'); const viewportRead = await submitFixtureAction(viewport.task.id, viewport.form); assert.equal(viewportRead.step.kind, 'read'); assert.ok(!Object.hasOwn(viewportRead.step, 'scroll_scope'), 'Scroll options must not leak into the read action'); const selectedSteps = [viewportAction.step.id, viewportRead.step.id]; const viewportCandidate = await client.request('POST', `/api/learning/tasks/${viewport.task.id}/candidates`, { name: 'UI显式选步整页定位与读取', step_ids: selectedSteps }, 201); assert.deepEqual(viewportCandidate.manifest.source_step_ids, selectedSteps); assert.equal(viewportCandidate.manifest.steps[0].scroll_scope, 'viewport'); assert.equal(viewportCandidate.manifest.steps.length, 2); const viewportCheck = await client.request('POST', `/api/learning/candidates/${viewportCandidate.id}/check`, {}); assert.equal(viewportCheck.status, 'passed', JSON.stringify(viewportCheck.errors)); assert.equal(viewportCandidate.quality.published, false); const viewportVerified = await client.request('POST', `/api/learning/candidates/${viewportCandidate.id}/verify`, { mode: 'exploration', parameters: {}, expected_outputs: { ui_viewport_note: '整页合成笔记1' }, sample_name: '独立选步整页定位与读取' }); assert.equal(viewportVerified.status, 'passed', JSON.stringify(viewportVerified.errors)); assert.ok(viewportVerified.seek_results[0].scrolls > 0); await page.getByRole('tab', { name: /链路步骤/ }).click(); await page.getByText(/整页 · 向下 320px/).waitFor(); await client.request('POST', `/api/learning/tasks/${viewport.task.id}/browser/close`, {}); await page.goto(`${uiBase}/#/tasks/${tools.failure_task_id}`); await page.getByTestId('action-failure-notice').getByText('WRITE_REQUEST_BLOCKED', { exact: false }).waitFor(); return { runtime_form: true, observation_save_and_refresh: true, scroll_form: true, wait_form: true, extract_form: true, parameter_only_target: true, search_enter_form: true, failure_notice: true, conditional_scroll_form: true, complete_visible_rows_form: true, action_specific_options: true, formal_direct_entry: true, runtime_review_todo: true, visible_count_wait_form: true, bounded_batch_form: true, positive_empty_form: true, empty_assertion_form: true, viewport_scroll_form: true, viewport_movements: viewportAction.result.movements, selected_step_candidate: true, validation_id: verified.id }; }