378 lines
34 KiB
JavaScript
378 lines
34 KiB
JavaScript
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'
|
||
? `<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)}¬e=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 };
|
||
}
|