assistant/scripts/learning-tools-acceptance.mjs

378 lines
34 KiB
JavaScript
Raw Permalink 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 { createServer } from 'node:http';
import { LearningControlClient } from '../tools/learning/control.mjs';
const escape = value => String(value).replace(/[&<>"']/g, character => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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'
? `<section id="viewport-results"><h1>整页滚动合成结果</h1><a class="note-link" href="/detail?note=note-0">首屏合成笔记</a><div style="height:1100px" aria-hidden="true"></div><a class="note-link" href="/detail?note=note-1">整页合成笔记1</a><div style="height:500px" aria-hidden="true"></div><a class="note-link" href="/detail?note=note-2">整页合成笔记2</a><div style="height:700px" aria-hidden="true"></div></section>`
: url.pathname === '/failure'
? `<h1>失败取证夹具</h1><button data-testid="blocked-load" onclick="document.querySelector('[data-testid=status]').textContent='已点击,等待请求'; fetch('/synthetic-write?token=HiddenDiagnosticSecret71',{method:'POST',body:'HiddenDiagnosticBody71'}).catch(()=>document.querySelector('[data-testid=status]').textContent='局部点击已发生,请求被阻止')">加载测试</button><p data-testid="status">尚未点击</p>`
: url.pathname === '/batch-detail'
? `<h1>有界多批合成评论</h1><section data-testid="batch-container" style="height:240px;overflow:auto;border:1px solid;padding:8px;box-sizing:border-box">${[1, 2, 3, 4].map(index => `<article data-testid="batch-row" data-comment-id="batch-${index}" style="height:110px;box-sizing:border-box;margin:0;padding:12px"><span class="author">合成作者${index}</span><p class="body" style="margin:8px 0">跨批合成需求${index}</p></article>`).join('')}</section>`
: url.pathname === '/empty-detail'
? `<h1>明确空态合成页面</h1><section data-testid="empty-container"><p data-testid="empty-message">暂无符合条件的评论</p></section>`
: url.pathname === '/detail'
? `<h1 data-testid="detail-title">${escape(keyword)} · ${escape(noteId)}</h1><section data-testid="comments">${[1, 2].map(index => `<article data-testid="comment-row" data-comment-id="comment-${index}"><span class="author">本机合成作者${index}</span><p class="body">${escape(keyword)} ${escape(noteId)} 第${index}条需求</p><a class="source" href="/people/${index}">作者来源</a></article>`).join('')}</section><div style="height:1200px" aria-hidden="true"></div>`
: `<h1 data-testid="results-title">检索完成</h1><form method="get" action="/"><label>关键词<input data-testid="search-input" type="search" name="q" value="${escape(keyword)}"></label><button data-testid="search-button" type="submit">搜索</button></form><section>${[1, 2].map(index => `<article><a data-testid="note-link" data-note-id="note-${index}" target="_blank" href="/detail?q=${encodeURIComponent(keyword)}&note=note-${index}">${escape(keyword)} 合成笔记${index}</a></article>`).join('')}</section>`;
response.end(`<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>隔离学习工具验收</title><style>body{font:18px sans-serif;padding:20px}article{padding:12px;margin:10px 0;border:1px solid #ccc}input,button{font:inherit}a{display:inline-block;padding:8px}</style><main>${content}</main></html>`);
});
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 };
}