From cbd580a0bdabd3c71b964b1b67a2e9be44c85487 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 13:04:09 +0300 Subject: [PATCH] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=20C.9=20=E2=80=94=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20forms/fill.mjs=20+=20forms/close.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit forms/fill.mjs (~140 LOC): fillFields, fillField forms/close.mjs (~50 LOC): closeForm clickElement остаётся в browser.mjs до C.10. Допиленные импорты после первого прохода: - fill.mjs: readFormScript, normYo (из dom/state — забыл при экстракции) - close.mjs: recorder (используется для паузы 500ms при confirmation во время записи) 03-fillfields регресс зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 187 +----------------- .../skills/web-test/scripts/forms/close.mjs | 60 ++++++ .../skills/web-test/scripts/forms/fill.mjs | 147 ++++++++++++++ 3 files changed, 216 insertions(+), 178 deletions(-) create mode 100644 .claude/skills/web-test/scripts/forms/close.mjs create mode 100644 .claude/skills/web-test/scripts/forms/fill.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 64102952..ef0d02ba 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -768,134 +768,11 @@ import { -/** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */ -export async function fillFields(fields) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('fillFields: no form found'); +// ============================================================ +// Fill fields — extracted to forms/fill.mjs +// ============================================================ +export { fillFields, fillField } from './forms/fill.mjs'; - // Resolve field names to element IDs - const resolved = await page.evaluate(resolveFieldsScript(formNum, fields)); - const results = []; - - for (const r of resolved) { - if (r.error) { - results.push(r); - continue; - } - // Auto-highlight the field input before filling - if (highlightMode && r.inputId) { - try { - await page.evaluate(({ id }) => { - const target = document.getElementById(id); - if (!target) return; - let div = document.getElementById('__web_test_highlight'); - if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); } - const r = target.getBoundingClientRect(); - div.style.cssText = 'position:fixed;pointer-events:none;z-index:999998;top:' + (r.y-4) + 'px;left:' + (r.x-4) + 'px;width:' + (r.width+8) + 'px;height:' + (r.height+8) + 'px;outline:3px solid #e74c3c;border-radius:4px;box-shadow:0 0 16px #e74c3c80'; - }, { id: r.inputId }); - await page.waitForTimeout(500); - await unhighlight(); - } catch {} - } - try { - // Auto-enable DCS checkbox if resolved via label - if (r.dcsCheckbox && !r.dcsCheckbox.checked) { - await page.click(`[id="${r.dcsCheckbox.inputId}"]`); - await waitForStable(); - } - const selector = `[id="${r.inputId}"]`; - // Clear field via Shift+F4 if value is empty (not applicable to checkbox/radio) - const rawValue = fields[r.field]; - const isEmpty = rawValue === '' || rawValue === null || rawValue === undefined; - if (isEmpty && !r.isCheckbox && !r.isRadio) { - await page.click(selector); - await page.waitForTimeout(200); - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(300); - await page.keyboard.press('Tab'); - await waitForStable(); - results.push({ field: r.field, ok: true, value: '', method: 'clear' }); - continue; - } - if (r.isCheckbox) { - // Checkbox: compare desired with current, toggle if mismatch - const desired = String(fields[r.field]).toLowerCase(); - const wantChecked = ['true', '1', 'да', 'yes', 'on'].includes(desired); - if (wantChecked !== r.checked) { - await page.click(selector); - await waitForStable(); - } - results.push({ field: r.field, ok: true, value: String(wantChecked), method: 'toggle' }); - } else if (r.isRadio) { - // Radio button: find option by label (fuzzy match) and click it - const desired = normYo(String(fields[r.field]).toLowerCase()); - const opt = r.options.find(o => normYo(o.label.toLowerCase()) === desired) - || r.options.find(o => normYo(o.label.toLowerCase()).includes(desired)); - if (opt) { - // Option 0 = base element (no suffix), options 1+ = #N#radio - const radioId = opt.index === 0 ? r.inputId : `${r.inputId}#${opt.index}#radio`; - await page.click(`[id="${radioId}"]`); - await waitForStable(); - results.push({ field: r.field, ok: true, value: opt.label, method: 'radio' }); - } else { - results.push({ field: r.field, error: 'option_not_found', available: r.options.map(o => o.label) }); - } - } else if (r.hasSelect) { - // Combobox/reference with DLB: DLB-first, then paste fallback - const refResult = await fillReferenceField(selector, r.field, fields[r.field], formNum); - results.push(refResult); - } else if (r.hasPick && r.isDate) { - // Date/time field with calendar CB — use paste (calendar is not a selection form) - await page.click(selector); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await pasteText(fields[r.field]); - await page.waitForTimeout(300); - await page.keyboard.press('Tab'); - await waitForStable(); - results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' }); - } else if (r.hasPick) { - // Reference field with CB (non-editable or editable ref): delegate to selectValue (F4 → selection form) - const svResult = await selectValue(r.field, String(fields[r.field])); - if (svResult?.error) { - results.push({ field: r.field, error: svResult.error, message: svResult.message }); - } else { - results.push({ field: r.field, ok: true, value: svResult.value || String(fields[r.field]), method: svResult.method || 'form' }); - } - } else { - // Plain field: clipboard paste + Tab to commit - // page.fill() sets DOM value but doesn't trigger 1C input events; - // clipboard paste (Ctrl+V) is a trusted event that 1C processes correctly. - await page.click(selector); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await pasteText(fields[r.field]); - await page.waitForTimeout(300); - await page.keyboard.press('Tab'); - await waitForStable(); - results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' }); - } - } catch (e) { - results.push({ field: r.field, error: e.message }); - } - if (highlightMode) try { await unhighlight(); } catch {} - } - - const formData = await page.evaluate(readFormScript(formNum)); - const failed = results.filter(r => r.error); - if (failed.length > 0) { - const details = failed.map(f => ` ${f.field}: ${f.message || f.error}${f.available ? ' (available: ' + f.available.join(', ') + ')' : ''}`).join('\n'); - throw new Error(`fillFields: ${failed.length} of ${results.length} field(s) failed:\n${details}`); - } - return { filled: results, form: formData }; -} - -/** Convenience alias: fill a single field. Same as fillFields({ name: value }). */ -export async function fillField(name, value) { - return fillFields({ [name]: value }); -} /** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists). * First argument can also be an object { row, column } to click a SpreadsheetDocument cell. */ @@ -1200,57 +1077,11 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi } } -/** - * Close the current form/dialog via Escape. - * @param {Object} [opts] - * @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically: - * true → click "Да" (save and close) - * false → click "Нет" (discard and close) - * undefined → return confirmation as hint for caller to decide - */ -export async function closeForm({ save } = {}) { - ensureConnected(); - await dismissPendingErrors(); - // If platform dialogs are open, close them instead of pressing Escape - const pd = await _detectPlatformDialogs(); - if (pd.length) { - await _closePlatformDialogs(); - await page.waitForTimeout(300); - const state = await getFormState(); - state.closed = true; - state.closedPlatformDialogs = pd; - return state; - } - const beforeForm = await page.evaluate(detectFormScript()); - await page.keyboard.press('Escape'); - await waitForStable(beforeForm); - const state = await getFormState(); - const err = await checkForErrors(); - if (err?.confirmation) { - if (save === true || save === false) { - const label = save ? 'Да' : 'Нет'; - const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`; - const btns = await page.$$(btnSel); - for (const b of btns) { - const txt = (await b.textContent()).trim(); - if (txt === label) { - if (recorder) await page.waitForTimeout(500); // show confirmation to viewer during recording - await b.click({ force: true }); - await waitForStable(beforeForm); - break; - } - } - const afterState = await getFormState(); - afterState.closed = afterState.form !== beforeForm; - return afterState; - } - state.confirmation = err.confirmation; - state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel'; - return state; - } - state.closed = state.form !== beforeForm; - return state; -} +// ============================================================ +// Close form — extracted to forms/close.mjs +// ============================================================ +export { closeForm } from './forms/close.mjs'; + /** diff --git a/.claude/skills/web-test/scripts/forms/close.mjs b/.claude/skills/web-test/scripts/forms/close.mjs new file mode 100644 index 00000000..6ff2b65d --- /dev/null +++ b/.claude/skills/web-test/scripts/forms/close.mjs @@ -0,0 +1,60 @@ +// web-test forms/close v1.16 — Close current form via Escape, handle save-changes confirmation. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page, recorder, ensureConnected } from '../core/state.mjs'; +import { detectFormScript } from '../dom.mjs'; +import { dismissPendingErrors, checkForErrors, _detectPlatformDialogs, _closePlatformDialogs } from '../core/errors.mjs'; +import { waitForStable } from '../core/wait.mjs'; +import { getFormState } from '../browser.mjs'; + +/** + * Close the current form/dialog via Escape. + * @param {Object} [opts] + * @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically: + * true → click "Да" (save and close) + * false → click "Нет" (discard and close) + * undefined → return confirmation as hint for caller to decide + */ +export async function closeForm({ save } = {}) { + ensureConnected(); + await dismissPendingErrors(); + // If platform dialogs are open, close them instead of pressing Escape + const pd = await _detectPlatformDialogs(); + if (pd.length) { + await _closePlatformDialogs(); + await page.waitForTimeout(300); + const state = await getFormState(); + state.closed = true; + state.closedPlatformDialogs = pd; + return state; + } + const beforeForm = await page.evaluate(detectFormScript()); + await page.keyboard.press('Escape'); + await waitForStable(beforeForm); + const state = await getFormState(); + const err = await checkForErrors(); + if (err?.confirmation) { + if (save === true || save === false) { + const label = save ? 'Да' : 'Нет'; + const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`; + const btns = await page.$$(btnSel); + for (const b of btns) { + const txt = (await b.textContent()).trim(); + if (txt === label) { + if (recorder) await page.waitForTimeout(500); // show confirmation to viewer during recording + await b.click({ force: true }); + await waitForStable(beforeForm); + break; + } + } + const afterState = await getFormState(); + afterState.closed = afterState.form !== beforeForm; + return afterState; + } + state.confirmation = err.confirmation; + state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel'; + return state; + } + state.closed = state.form !== beforeForm; + return state; +} diff --git a/.claude/skills/web-test/scripts/forms/fill.mjs b/.claude/skills/web-test/scripts/forms/fill.mjs new file mode 100644 index 00000000..c98573ea --- /dev/null +++ b/.claude/skills/web-test/scripts/forms/fill.mjs @@ -0,0 +1,147 @@ +// web-test forms/fill v1.16 — Fill form fields by name (text/checkbox/date/dropdown/reference). Delegates references to selectValue / fillReferenceField. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { + page, ensureConnected, ACTION_WAIT, highlightMode, normYo, +} from '../core/state.mjs'; +import { + detectFormScript, resolveFieldsScript, readFormScript, +} from '../dom.mjs'; +import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; +import { waitForStable, startNetworkMonitor } from '../core/wait.mjs'; +import { highlight, unhighlight } from '../recording/highlight.mjs'; +import { + fillReferenceField, selectValue, pickFromSelectionForm, + isTypeDialog, pickFromTypeDialog, +} from './select-value.mjs'; +// pasteText + getFormState live in browser.mjs. +import { pasteText, getFormState } from '../browser.mjs'; + +/** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */ +export async function fillFields(fields) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('fillFields: no form found'); + + // Resolve field names to element IDs + const resolved = await page.evaluate(resolveFieldsScript(formNum, fields)); + const results = []; + + for (const r of resolved) { + if (r.error) { + results.push(r); + continue; + } + // Auto-highlight the field input before filling + if (highlightMode && r.inputId) { + try { + await page.evaluate(({ id }) => { + const target = document.getElementById(id); + if (!target) return; + let div = document.getElementById('__web_test_highlight'); + if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); } + const r = target.getBoundingClientRect(); + div.style.cssText = 'position:fixed;pointer-events:none;z-index:999998;top:' + (r.y-4) + 'px;left:' + (r.x-4) + 'px;width:' + (r.width+8) + 'px;height:' + (r.height+8) + 'px;outline:3px solid #e74c3c;border-radius:4px;box-shadow:0 0 16px #e74c3c80'; + }, { id: r.inputId }); + await page.waitForTimeout(500); + await unhighlight(); + } catch {} + } + try { + // Auto-enable DCS checkbox if resolved via label + if (r.dcsCheckbox && !r.dcsCheckbox.checked) { + await page.click(`[id="${r.dcsCheckbox.inputId}"]`); + await waitForStable(); + } + const selector = `[id="${r.inputId}"]`; + // Clear field via Shift+F4 if value is empty (not applicable to checkbox/radio) + const rawValue = fields[r.field]; + const isEmpty = rawValue === '' || rawValue === null || rawValue === undefined; + if (isEmpty && !r.isCheckbox && !r.isRadio) { + await page.click(selector); + await page.waitForTimeout(200); + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(300); + await page.keyboard.press('Tab'); + await waitForStable(); + results.push({ field: r.field, ok: true, value: '', method: 'clear' }); + continue; + } + if (r.isCheckbox) { + // Checkbox: compare desired with current, toggle if mismatch + const desired = String(fields[r.field]).toLowerCase(); + const wantChecked = ['true', '1', 'да', 'yes', 'on'].includes(desired); + if (wantChecked !== r.checked) { + await page.click(selector); + await waitForStable(); + } + results.push({ field: r.field, ok: true, value: String(wantChecked), method: 'toggle' }); + } else if (r.isRadio) { + // Radio button: find option by label (fuzzy match) and click it + const desired = normYo(String(fields[r.field]).toLowerCase()); + const opt = r.options.find(o => normYo(o.label.toLowerCase()) === desired) + || r.options.find(o => normYo(o.label.toLowerCase()).includes(desired)); + if (opt) { + // Option 0 = base element (no suffix), options 1+ = #N#radio + const radioId = opt.index === 0 ? r.inputId : `${r.inputId}#${opt.index}#radio`; + await page.click(`[id="${radioId}"]`); + await waitForStable(); + results.push({ field: r.field, ok: true, value: opt.label, method: 'radio' }); + } else { + results.push({ field: r.field, error: 'option_not_found', available: r.options.map(o => o.label) }); + } + } else if (r.hasSelect) { + // Combobox/reference with DLB: DLB-first, then paste fallback + const refResult = await fillReferenceField(selector, r.field, fields[r.field], formNum); + results.push(refResult); + } else if (r.hasPick && r.isDate) { + // Date/time field with calendar CB — use paste (calendar is not a selection form) + await page.click(selector); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await pasteText(fields[r.field]); + await page.waitForTimeout(300); + await page.keyboard.press('Tab'); + await waitForStable(); + results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' }); + } else if (r.hasPick) { + // Reference field with CB (non-editable or editable ref): delegate to selectValue (F4 → selection form) + const svResult = await selectValue(r.field, String(fields[r.field])); + if (svResult?.error) { + results.push({ field: r.field, error: svResult.error, message: svResult.message }); + } else { + results.push({ field: r.field, ok: true, value: svResult.value || String(fields[r.field]), method: svResult.method || 'form' }); + } + } else { + // Plain field: clipboard paste + Tab to commit + // page.fill() sets DOM value but doesn't trigger 1C input events; + // clipboard paste (Ctrl+V) is a trusted event that 1C processes correctly. + await page.click(selector); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await pasteText(fields[r.field]); + await page.waitForTimeout(300); + await page.keyboard.press('Tab'); + await waitForStable(); + results.push({ field: r.field, ok: true, value: String(fields[r.field]), method: 'paste' }); + } + } catch (e) { + results.push({ field: r.field, error: e.message }); + } + if (highlightMode) try { await unhighlight(); } catch {} + } + + const formData = await page.evaluate(readFormScript(formNum)); + const failed = results.filter(r => r.error); + if (failed.length > 0) { + const details = failed.map(f => ` ${f.field}: ${f.message || f.error}${f.available ? ' (available: ' + f.available.join(', ') + ')' : ''}`).join('\n'); + throw new Error(`fillFields: ${failed.length} of ${results.length} field(s) failed:\n${details}`); + } + return { filled: results, form: formData }; +} + +/** Convenience alias: fill a single field. Same as fillFields({ name: value }). */ +export async function fillField(name, value) { + return fillFields({ [name]: value }); +}