// 通用 DOM 操作执行器:在给定 document 里按"用户看得见的文字"找到控件并填写/点击。 // // 本文件对宿主一无所知——不认识标签页、iframe、toast,也不 import 服务端模块。 // 它就是 page-operate 工具包的手:操作哪张 document 由宿主在事件桥那头决定。 // // 匹配一律走用户视角的文字:输入项认 label / aria-label / placeholder,按钮认可见 // 文字——模型转述的就是用户原话,不该要求它知道选择器或 DOM 结构。 // // 填值走原生 value setter + input/change 事件:页面可能是 React 受控组件 // (直接赋 value 会被受控状态吞掉),也可能是原生监听,这套写法两者都触发。 /** 失败原因分类。宿主与事件桥据此决定重试与否,不必去猜中文文案。 */ export type DomOperateFailureCode = /** 不认识的动作(既不是 fill 也不是 click)。 */ | 'unsupported-action' /** 没说要操作哪个控件。 */ | 'empty-target' /** 页面上找不到该控件——页面可能还没加载完,可重试。 */ | 'not-found' /** 匹配到多个控件,需要用户说得更具体;重试无意义。 */ | 'ambiguous' /** 找到了但当前不可用(disabled / aria-disabled)。 */ | 'disabled' /** 找到了控件,但要填的值它接不住(下拉框没有这一项)。 */ | 'invalid-value'; export type DomOperateResult = | { ok: true; detail: string } | { ok: false; code: DomOperateFailureCode; reason: string }; export interface DomOperateRequest { /** 'fill' | 'click',其余一律按 unsupported-action 回绝。 */ action: string; /** 控件名:输入项填标签文字,按钮填按钮文字。 */ target: string; /** action=fill 时要填入的内容。 */ value?: string; } const CLICKABLE_SELECTOR = 'button, input[type="button"], input[type="submit"], a, [role="button"]'; const FILLABLE_SELECTOR = 'input, textarea, select'; /** 单步执行:入参校验 → 找控件 → 动手。对 document 是纯函数,单测直接喂 JSDOM。 */ export function operateInDocument(doc: Document, request: DomOperateRequest): DomOperateResult { const target = request.target.trim(); if (!target) { return { ok: false, code: 'empty-target', reason: '未指定要操作的控件' }; } if (request.action === 'fill') { return fillControl(doc, target, (request.value ?? '').trim()); } if (request.action === 'click') { return clickControl(doc, target); } return { ok: false, code: 'unsupported-action', reason: '不支持的页面操作,目前只能填写输入项或点击按钮', }; } /** 「控件还没出现」类失败可重试(页面可能仍在加载);其余都是确定性失败。 */ export function isRetryableDomOperateResult(result: DomOperateResult): boolean { return !result.ok && result.code === 'not-found'; } // --------------------------------------------------------------------------- // fill // --------------------------------------------------------------------------- type FillableControl = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement; function fillControl(doc: Document, target: string, value: string): DomOperateResult { const found = findFillableControl(doc, target); if ('reason' in found) { return { ok: false, code: found.code, reason: found.reason }; } const control = found.control; if (control.disabled) { return { ok: false, code: 'disabled', reason: `输入项「${found.label}」当前不可编辑` }; } if (control instanceof getView(doc).HTMLSelectElement) { return fillSelect(control, found.label, value); } setNativeValue(control, value); return { ok: true, detail: `已填写「${found.label}」` }; } function fillSelect( select: HTMLSelectElement, label: string, value: string, ): DomOperateResult { const keyword = normalizeText(value); const options = Array.from(select.options); const match = options.find((option) => option.value === value) ?? options.find((option) => normalizeText(option.textContent ?? '') === keyword) ?? options.find((option) => keyword && normalizeText(option.textContent ?? '').includes(keyword)); if (!match) { const names = options .map((option) => normalizeText(option.textContent ?? '')) .filter(Boolean) .join('、'); return { ok: false, code: 'invalid-value', reason: `下拉框「${label}」里没有「${value}」这一项${names ? `,可选:${names}` : ''}`, }; } setNativeValue(select, match.value); return { ok: true, detail: `已在「${label}」中选择「${normalizeText(match.textContent ?? '') || match.value}」`, }; } interface ControlMatch { control: FillableControl; /** 用户视角的名字(label 文字等),用于回执与错误提示。 */ label: string; } function findFillableControl( doc: Document, target: string, ): ControlMatch | { code: DomOperateFailureCode; reason: string } { const keyword = normalizeText(target); // 逐层降级:label 文字 → aria-label → placeholder。每层先精确后包含,命中即止, // 避免"用户名"同时按包含命中"用户名备注"时被跨层结果搅浑。 const tiers: ControlMatch[][] = [ matchByLabel(doc, keyword, true), matchByLabel(doc, keyword, false), matchByAttribute(doc, 'aria-label', keyword, true), matchByAttribute(doc, 'aria-label', keyword, false), matchByAttribute(doc, 'placeholder', keyword, true), matchByAttribute(doc, 'placeholder', keyword, false), ]; for (const matches of tiers) { const unique = dedupeByControl(matches); if (unique.length === 1) { return unique[0]; } if (unique.length > 1) { const names = unique.map((item) => `「${item.label}」`).join('、'); return { code: 'ambiguous', reason: `「${target}」匹配到多个输入项:${names},请说得更具体`, }; } } // 只说"没找到"等于把锅甩回给用户。把页面上真有的输入项名字一并报出来, // 用户一眼知道该怎么改口,模型下一轮也能自己纠正。 const available = listFillableNames(doc); return { code: 'not-found', reason: `当前页面上没有找到输入项「${target}」` + (available.length > 0 ? `,可填:${available.join('、')}` : ''), }; } /** 列出页面上可填的输入项名字,给"没找到"的提示用;只取前若干个,别把提示撑爆。 */ function listFillableNames(doc: Document, limit = 8): string[] { const names: string[] = []; const seen = new Set(); for (const label of Array.from(doc.querySelectorAll('label'))) { if (names.length >= limit) { break; } const text = normalizeText(label.textContent ?? ''); const control = resolveLabelControl(doc, label); if (!text || seen.has(text) || !control || !isVisibleControl(control) || control.disabled) { continue; } seen.add(text); names.push(`「${text}」`); } return names; } function matchByLabel(doc: Document, keyword: string, exact: boolean): ControlMatch[] { const matches: ControlMatch[] = []; for (const label of Array.from(doc.querySelectorAll('label'))) { const text = normalizeText(label.textContent ?? ''); if (!text || !textMatches(text, keyword, exact)) { continue; } const control = resolveLabelControl(doc, label); if (control && isVisibleControl(control)) { matches.push({ control, label: text }); } } return matches; } function resolveLabelControl(doc: Document, label: HTMLLabelElement): FillableControl | null { const forId = label.getAttribute('for'); const byFor = forId ? doc.getElementById(forId) : null; const candidate = byFor ?? label.querySelector(FILLABLE_SELECTOR); return isFillable(doc, candidate) ? candidate : null; } function matchByAttribute( doc: Document, attribute: string, keyword: string, exact: boolean, ): ControlMatch[] { const matches: ControlMatch[] = []; for (const element of Array.from(doc.querySelectorAll(`[${attribute}]`))) { const text = normalizeText(element.getAttribute(attribute) ?? ''); if (!text || !textMatches(text, keyword, exact)) { continue; } if (isFillable(doc, element) && isVisibleControl(element)) { matches.push({ control: element, label: text }); } } return matches; } function dedupeByControl(matches: ControlMatch[]): ControlMatch[] { const seen = new Set(); return matches.filter((item) => { if (seen.has(item.control)) { return false; } seen.add(item.control); return true; }); } function isFillable(doc: Document, element: Element | null): element is FillableControl { const view = getView(doc); if (element instanceof view.HTMLInputElement) { // hidden/checkbox/radio/file 不是"把文字填进去"语义,宁可报找不到也不误伤。 return !['hidden', 'checkbox', 'radio', 'file', 'button', 'submit', 'reset', 'image'] .includes(element.type); } return element instanceof view.HTMLTextAreaElement || element instanceof view.HTMLSelectElement; } /** * React 受控组件兼容写法:经原生 setter 赋值再冒泡 input/change, * 受控与原生监听两边都能感知;对 select 只需 change。 */ function setNativeValue(control: FillableControl, value: string): void { const view = getView(control.ownerDocument); const proto = control instanceof view.HTMLTextAreaElement ? view.HTMLTextAreaElement.prototype : control instanceof view.HTMLSelectElement ? view.HTMLSelectElement.prototype : view.HTMLInputElement.prototype; const setter = Object.getOwnPropertyDescriptor(proto, 'value')?.set; if (setter) { setter.call(control, value); } else { control.value = value; } if (!(control instanceof view.HTMLSelectElement)) { control.dispatchEvent(new view.Event('input', { bubbles: true })); } control.dispatchEvent(new view.Event('change', { bubbles: true })); } // --------------------------------------------------------------------------- // click // --------------------------------------------------------------------------- function clickControl(doc: Document, target: string): DomOperateResult { const keyword = normalizeText(target); const candidates = Array.from(doc.querySelectorAll(CLICKABLE_SELECTOR)) .filter((element) => isVisibleControl(element)) .map((element) => ({ element, text: clickableText(doc, element) })) .filter((item) => Boolean(item.text)); const exact = candidates.filter((item) => item.text === keyword); const pool = exact.length > 0 ? exact : candidates.filter((item) => item.text.includes(keyword)); if (pool.length === 0) { // 同 fill:把页面上真有的按钮报出来,比干巴巴一句"没找到"有用得多。 const names = candidates.slice(0, 8).map((item) => `「${item.text}」`); return { ok: false, code: 'not-found', reason: `当前页面上没有找到按钮「${target}」` + (names.length > 0 ? `,可点:${names.join('、')}` : ''), }; } if (pool.length > 1) { const names = pool.map((item) => `「${item.text}」`).join('、'); return { ok: false, code: 'ambiguous', reason: `「${target}」匹配到多个按钮:${names},请说得更具体`, }; } const element = pool[0].element; if (isDisabled(element)) { return { ok: false, code: 'disabled', reason: `按钮「${pool[0].text}」当前不可点击` }; } element.click(); return { ok: true, detail: `已点击「${pool[0].text}」` }; } function clickableText(doc: Document, element: HTMLElement): string { const view = getView(doc); if (element instanceof view.HTMLInputElement) { return normalizeText(element.value); } return normalizeText(element.textContent ?? '') || normalizeText(element.getAttribute('aria-label') ?? ''); } // --------------------------------------------------------------------------- // shared helpers // --------------------------------------------------------------------------- /** 可见性只查确定性信号(hidden 属性 / 内联 display:none):JSDOM 没有布局,offsetParent 不可用。 */ function isVisibleControl(element: Element): boolean { for (let node: Element | null = element; node; node = node.parentElement) { if ((node as HTMLElement).hidden || (node as HTMLElement).style?.display === 'none') { return false; } } return true; } function isDisabled(element: HTMLElement): boolean { return (element as HTMLElement & { disabled?: boolean }).disabled === true || element.getAttribute('aria-disabled') === 'true'; } function textMatches(text: string, keyword: string, exact: boolean): boolean { return exact ? text === keyword : Boolean(keyword) && text.includes(keyword); } function normalizeText(value: string): string { return value.replace(/\s+/g, ' ').trim(); } /** JSDOM 里的元素 instanceof 必须用其所属 window 的构造器,不能用全局的。 */ function getView(doc: Document): Window & typeof globalThis { return (doc.defaultView ?? globalThis) as Window & typeof globalThis; }