From bb98d3c240707b6031295cf83b69eb718ae3f93d Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Thu, 16 Jul 2026 11:15:32 +0300 Subject: [PATCH] =?UTF-8?q?feat(web-test):=20closeForm=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=83=20=D0=BA=D0=B0=D1=81=D0=BA=D0=B0=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?,=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=81=D0=BC=D1=8B=D1=81=D0=BB=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closeForm умел только Escape. На реальном стенде Escape не закрывает ни модальную форму, ни даже обычный список — форма оставалась открытой, и resetState считал контекст грязным. - Каскад закрытия (closeCrossScript в dom/forms.mjs): Escape → крестик плавающего окна (ps, модалка поверх формы) → крестик страницы формы (VW_page, работает и при скрытой панели вкладок) → крестик активной вкладки (класс select, не первый попавшийся .openedClose). Порядок и якоря замерены живьём. Крестик формы бьёт вкладочный, потому что панель открытых может быть выключена настройкой. - nothingToClose: когда Escape не помог и крестика нет нигде — платформа сама говорит, что поверхность не закрывается, то есть это рабочий стол. Этот сигнал и питает «чисто» в resetState, без базового снимка и списков-исключений. - Семантика подтверждения по смыслу вопроса (pickConfirmationLabel): 1С теми же кнопками «Да/Нет» задаёт разные вопросы. «Сохранить изменения?» → save?Да:Нет (как было); «Закрыть согласование?» → Да=закрыть (иначе save:false жал «Нет» = «остаться», и модалка утекала в следующий тест). Решение по вопросительному предложению; неизвестная формулировка → прежнее поведение. - Ответственность DOM: поиск крестика вынесен в dom/forms.mjs (генератор скрипта для page.evaluate), close.mjs его только зовёт. Диагностика приведена к английскому, как остальной модуль; русским остаётся лишь цитата платформы. Проверено на свежей базе пилота: модалка «Комментарий и согласование» → closeForm({save:false}) = closed:true, answered:Да, viaCross:true, formCount 2→1 (критерий пилота); стопка документ→список→стол разбирается насквозь и останавливается на столе. Полный регресс 25/25, closeForm зовёт каждый тест. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/web-test/scripts/dom.mjs | 3 +- .claude/skills/web-test/scripts/dom/forms.mjs | 40 ++++++++++- .../web-test/scripts/engine/forms/close.mjs | 71 ++++++++++++++++--- 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index cb2e8c06..e145ba00 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -1,4 +1,4 @@ -// web-test dom v1.16 — facade re-exporting injectable DOM scripts from dom/ +// web-test dom v1.18 — facade re-exporting injectable DOM scripts from dom/ // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Facade: re-exports DOM selector & semantic mapping script generators. @@ -11,6 +11,7 @@ export { detectFormScript, + closeCrossScript, readFormScript, findClickTargetScript, findFieldButtonScript, diff --git a/.claude/skills/web-test/scripts/dom/forms.mjs b/.claude/skills/web-test/scripts/dom/forms.mjs index ca2ce1ec..a791925e 100644 --- a/.claude/skills/web-test/scripts/dom/forms.mjs +++ b/.claude/skills/web-test/scripts/dom/forms.mjs @@ -1,4 +1,4 @@ -// web-test dom/forms v1.7 — form detection, content read, click-target/field-button resolution +// web-test dom/forms v1.9 — form detection, content read, click-target/field-button resolution // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { DETECT_FORM_FN, READ_FORM_FN, ROW_CLICK_POINT_FN } from './_shared.mjs'; @@ -13,6 +13,44 @@ export function detectFormScript() { })()`; } +/** + * Id of the cross that closes the topmost closable surface, or null when there is nothing to + * close — which is exactly how the platform says "this is the desktop": the home page has no + * cross at all. That `null` is the reset-between-tests "clean" signal; no baseline snapshot and + * no exception list needed. Measured on a live stand: + * desktop, 1 form → form=0 formCount=1, no cross + * desktop, 3 forms → form=5 formCount=3 openForms=[5,6,7], no cross + * list open → cross VW_page2headerTopLine_cmd_CloseButton + * + * The id must leak out for the caller to click — Escape is not enough: on a real stand it closed + * neither a modal nor even a plain list. + * + * Order matters, and each step was measured: + * 1. floating window (`ps`) — a modal sits ON TOP of the form that opened it, so its cross + * wins. The index grows per window (ps0 → ps1 → …): a literal `ps0` breaks on the second + * modal of a session. + * 2. the form's own page cross — works whether or not the tab panel is switched on, which is + * why it beats the tab cross. With the panel hidden it is the ONLY path. + * 3. the ACTIVE tab's cross — last resort. `select` marks the active tab (same signal as + * dom/form-state.mjs); NOT `querySelector('.openedClose')` like closeModals does — that + * grabs the first tab in the DOM and would close someone else's form. + * Anchored on ids rather than title="Закрыть", so a non-Russian locale keeps working. + */ +export function closeCrossScript() { + return `(() => { + const vis = (e) => e.offsetWidth > 0; + const crosses = [...document.querySelectorAll('[id*="headerTopLine_cmd_CloseButton"]')].filter(vis); + const floating = crosses.filter(e => /ps\\d+headerTopLine_cmd_CloseButton$/.test(e.id)); + if (floating.length) return floating.pop().id; + const page = crosses.filter(e => /^VW_page\\d+headerTopLine_cmd_CloseButton$/.test(e.id)); + if (page.length) return page.pop().id; + const tab = [...document.querySelectorAll('[id^="openedCell_cmd_"]')] + .find(e => e.classList.contains('select') && vis(e)); + const tabCross = tab && tab.querySelector('.openedClose'); + return (tabCross && vis(tabCross)) ? tabCross.id : null; + })()`; +} + /** * Read full form state for a given form number. * Uses shared READ_FORM_FN. diff --git a/.claude/skills/web-test/scripts/engine/forms/close.mjs b/.claude/skills/web-test/scripts/engine/forms/close.mjs index 87cf095d..db42cd2d 100644 --- a/.claude/skills/web-test/scripts/engine/forms/close.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/close.mjs @@ -1,19 +1,40 @@ -// web-test forms/close v1.18 — Close current form via Escape, handle save-changes confirmation. +// web-test forms/close v1.20 — 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 { detectFormScript, closeCrossScript } from '../../dom.mjs'; import { dismissPendingErrors, checkForErrors, detectPlatformDialogs, closePlatformDialogs } from '../core/errors.mjs'; import { waitForStable } from '../core/wait.mjs'; import { returnFormState } from '../core/helpers.mjs'; import { getFormState } from './state.mjs'; /** - * Close the current form/dialog via Escape. + * Which button actually CLOSES the form on this confirmation? + * + * 1C asks two different questions with the same two buttons: + * «Данные были изменены. Сохранить изменения?» → Да = save+close, Нет = close without saving + * «Виза сохранена не будет. Закрыть согласование?» → Да = close, Нет = STAY IN THE FORM + * A hard-coded «Нет» for save:false is right for the first and exactly backwards for the second: + * the caller asks to close and gets a form that stays open (measured — that is how a modal leaked + * into the next test). + * + * Decide by the QUESTION, not by the whole message: both texts contain the root «сохран», but only + * the interrogative sentence says what the buttons mean. Unknown wording keeps the legacy answer. + */ +function pickConfirmationLabel(message, save) { + const question = (String(message || '').match(/[^.!?]*\?/g) || []).pop() || ''; + if (/сохранит/i.test(question)) return save ? 'Да' : 'Нет'; // "…Сохранить изменения?" + if (/закрыт/i.test(question)) return 'Да'; // "…Закрыть согласование?" — Да = закрыть + return save ? 'Да' : 'Нет'; // unknown → as before +} + +/** + * Close the current form/dialog: Escape first, the modal window cross if Escape does nothing. * @param {Object} [opts] - * @param {boolean} [opts.save] - Handle "Save changes?" confirmation automatically: - * true → click "Да" (save and close) - * false → click "Нет" (discard and close) + * @param {boolean} [opts.save] - Handle a confirmation automatically. The button is chosen by the + * MEANING of the question (see pickConfirmationLabel), not by a fixed label: + * true → save and close + * false → close without saving * undefined → return confirmation as hint for caller to decide */ export async function closeForm({ save } = {}) { @@ -29,11 +50,33 @@ export async function closeForm({ save } = {}) { const beforeForm = await page.evaluate(detectFormScript()); await page.keyboard.press('Escape'); await waitForStable(beforeForm); - const state = await getFormState(); - const err = await checkForErrors(); + let state = await getFormState(); + let err = await checkForErrors(); + let usedCross = false; + let nothingToClose = false; + + // Escape did nothing and raised no question. On a real stand that is the norm, not the exception: + // Escape closed neither a modal nor even a plain list there. Fall back to the cross a human would + // click. Only when nothing moved, so ordinary forms keep the cheap Escape path. + if (!err?.confirmation && state.form === beforeForm) { + const crossId = await page.evaluate(closeCrossScript()); + if (crossId) { + await page.click(`#${crossId}`).catch(() => {}); + await waitForStable(beforeForm); + state = await getFormState(); + err = await checkForErrors(); + usedCross = true; + } else { + // No cross anywhere: the platform itself says this surface is not closable — i.e. we are on + // the desktop. This is what tells resetState "clean" without knowing anything about the + // application: a home page with three forms on it looks exactly like an empty one here. + nothingToClose = true; + } + } + if (err?.confirmation) { if (save === true || save === false) { - const label = save ? 'Да' : 'Нет'; + const label = pickConfirmationLabel(err.confirmation.message, save); const btnSel = `#form${err.confirmation.formNum}_container a.press.pressButton`; const btns = await page.$$(btnSel); for (const b of btns) { @@ -46,11 +89,17 @@ export async function closeForm({ save } = {}) { } } const afterForm = await page.evaluate(detectFormScript()); - return returnFormState({ closed: afterForm !== beforeForm }); + // Report which button was pressed: on a "…Закрыть?" question it is «Да» even for save:false, + // and a silent surprise there is what cost the last investigation its afternoon. + return returnFormState({ closed: afterForm !== beforeForm, confirmationAnswered: label, closedViaCross: usedCross || undefined }); } state.confirmation = err.confirmation; state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel'; return state; } - return returnFormState({ closed: state.form !== beforeForm }); + return returnFormState({ + closed: state.form !== beforeForm, + closedViaCross: usedCross || undefined, + nothingToClose: nothingToClose || undefined, + }); }