From c4b1aee9c967494aeb035d5c666f7dbcfc5cc5a2 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 12:16:56 +0300 Subject: [PATCH] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0=D0=BF?= =?UTF-8?q?=20C.7=20=E2=80=94=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20nav/navigation.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перенос navigation-функций из browser.mjs (~240 LOC): - getPageState, getSections, navigateSection, getCommands - openCommand, switchTab - openFile (Ctrl+O + security dialog flow) - navigateLink (Shift+F11 e1cib paste) - E1CIB_TYPE_MAP, E1CIB_APP_TYPES, normalizeE1cibUrl (приватные) Цикл с browser.mjs (getFormState, pasteText) — статический ESM-импорт, разрешается во время вызова (binding live). core/session.mjs продолжает импортить getPageState из browser.mjs через re-export. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 243 +---------------- .../web-test/scripts/nav/navigation.mjs | 255 ++++++++++++++++++ 2 files changed, 262 insertions(+), 236 deletions(-) create mode 100644 .claude/skills/web-test/scripts/nav/navigation.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 64f03f11..fc4d5f78 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -154,242 +154,13 @@ export { fetchErrorStack } from './core/errors.mjs'; /* getPage moved to core/state.mjs */ -/** - * Get current page state: active section, tabs. - * Combined into a single evaluate call. - */ -export async function getPageState() { - ensureConnected(); - const { sections, tabs } = await page.evaluate(`({ - sections: ${readSectionsScript()}, - tabs: ${readTabsScript()} - })`); - const activeSection = sections.find(s => s.active)?.name || null; - const activeTab = tabs.find(t => t.active)?.name || null; - return { activeSection, activeTab, sections, tabs }; -} - -/** Read section panel + commands in a single evaluate call. */ -export async function getSections() { - ensureConnected(); - const { sections, commands } = await page.evaluate(`({ - sections: ${readSectionsScript()}, - commands: ${readCommandsScript()} - })`); - const activeSection = sections.find(s => s.active)?.name || null; - return { activeSection, sections, commands }; -} - -/** Navigate to a section by name. Returns new state with commands. */ -export async function navigateSection(name) { - ensureConnected(); - await dismissPendingErrors(); - if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {} - const result = await page.evaluate(navigateSectionScript(name)); - if (result?.error) { - const avail = result.available?.filter(Boolean); - if (avail?.length === 0) throw new Error(`navigateSection: "${name}" not found. Section panel is in icon-only mode — text labels are hidden. Switch to "Text" or "Picture and text" display mode in 1C settings (View → Section Panel → Display Mode)`); - throw new Error(`navigateSection: "${name}" not found. Available: ${avail?.join(', ') || 'none'}`); - } - - await waitForStable(); - const { sections, commands } = await page.evaluate(`({ - sections: ${readSectionsScript()}, - commands: ${readCommandsScript()} - })`); - return { navigated: result, sections, commands }; -} - -/** Read commands of the current section. */ -export async function getCommands() { - ensureConnected(); - return await page.evaluate(readCommandsScript()); -} - -/** Open a command from function panel by name. Returns new form state. */ -export async function openCommand(name) { - ensureConnected(); - await dismissPendingErrors(); - if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {} - const formBefore = await page.evaluate(detectFormScript()); - const result = await page.evaluate(openCommandScript(name)); - if (result?.error) throw new Error(`openCommand: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`); - - await waitForStable(formBefore); - return await returnFormState(); -} - -/** Switch to an open tab by name (fuzzy match). Returns updated form state. */ -export async function switchTab(name) { - ensureConnected(); - const result = await page.evaluate(switchTabScript(name)); - if (result?.error) throw new Error(`switchTab: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`); - await waitForStable(); - return await getFormState(); -} - -// English → Russian metadata type mapping for e1cib navigation links -const E1CIB_TYPE_MAP = { - 'catalog': 'Справочник', 'catalogs': 'Справочник', - 'document': 'Документ', 'documents': 'Документ', - 'commonmodule': 'ОбщийМодуль', - 'enum': 'Перечисление', 'enums': 'Перечисление', - 'dataprocessor': 'Обработка', 'dataprocessors': 'Обработка', - 'report': 'Отчет', 'reports': 'Отчет', - 'accumulationregister': 'РегистрНакопления', - 'informationregister': 'РегистрСведений', - 'accountingregister': 'РегистрБухгалтерии', - 'calculationregister': 'РегистрРасчета', - 'chartofaccounts': 'ПланСчетов', - 'chartofcharacteristictypes': 'ПланВидовХарактеристик', - 'chartofcalculationtypes': 'ПланВидовРасчета', - 'businessprocess': 'БизнесПроцесс', - 'task': 'Задача', - 'exchangeplan': 'ПланОбмена', - 'constant': 'Константа', -}; - -// Types that open via e1cib/app/ (reports and data processors have their own app forms) -const E1CIB_APP_TYPES = new Set(['Отчет', 'Обработка']); - -function normalizeE1cibUrl(url) { - // Already a full e1cib link - if (url.startsWith('e1cib/')) return url; - // "ТипОбъекта.Имя" or "EnglishType.Имя" — translate type, pick list/ or app/ prefix - const dot = url.indexOf('.'); - if (dot > 0) { - const typePart = url.substring(0, dot); - const namePart = url.substring(dot + 1); - const ruType = E1CIB_TYPE_MAP[typePart.toLowerCase()] || typePart; - const prefix = E1CIB_APP_TYPES.has(ruType) ? 'e1cib/app' : 'e1cib/list'; - return `${prefix}/${ruType}.${namePart}`; - } - return `e1cib/list/${url}`; -} - -/** - * Open an external data processor or report (EPF/ERF) via File → Open menu. - * Handles the security confirmation dialog on first open. - * @param {string} filePath - path to EPF/ERF file (absolute or relative to cwd) - * @returns {Promise} form state of the opened processor/report - */ -export async function openFile(filePath) { - ensureConnected(); - await dismissPendingErrors(); - const absPath = resolveProjectPath(filePath.replace(/\\/g, '/')); - - const MAX_ATTEMPTS = 2; // 1st may trigger security dialog, 2nd is the real open - for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { - const formBefore = await page.evaluate(detectFormScript()); - - // 1. Ctrl+O opens 1C's "Выбор файлов" dialog - await page.keyboard.press('Control+o'); - - // 2. Wait for the file selection dialog - const dialogOk = await waitForCondition(`(() => { - const ok = document.querySelector('#fileSelectDialogOk'); - return ok && ok.offsetWidth > 0 ? true : false; - })()`, 3000); - if (!dialogOk) throw new Error("File selection dialog did not open (Ctrl+O)"); - - // 3. Click "выберите с диска" to trigger the native OS file picker - let fileChooser; - try { - [fileChooser] = await Promise.all([ - page.waitForEvent('filechooser', { timeout: 5000 }), - page.click('a.underline.pointer'), - ]); - } catch (e) { - // Try closing the dialog before throwing - await page.keyboard.press('Escape'); - throw new Error(`File chooser did not appear: ${e.message}`); - } - - // 4. Set the file path and click OK - await fileChooser.setFiles(absPath); - await page.waitForTimeout(500); - await page.click('#fileSelectDialogOk'); - await waitForStable(formBefore); - - // 5. Check for security dialog - const err = await checkForErrors(); - if (err?.confirmation) { - // Security confirmation — click the positive button (Продолжить/Да/OK) - const positiveBtn = err.confirmation.buttons.find(b => - /продолжить|да|ok|yes|открыть/i.test(b) - ) || err.confirmation.buttons[0]; - if (positiveBtn) { - const btns = await page.$$(`#form${err.confirmation.formNum}_container a.press.pressButton`); - for (const b of btns) { - const txt = (await b.textContent())?.trim(); - if (txt === positiveBtn) { await b.click(); break; } - } - await waitForStable(formBefore); - } - // After confirmation, check if EPF form appeared or a follow-up dialog showed. - // Check form change FIRST — avoids confusing a small EPF form with a modal dialog. - const formAfter = await page.evaluate(detectFormScript()); - if (formAfter != null && formAfter !== formBefore) { - // New form appeared — but is it the EPF or an informational dialog? - // Informational "re-open" dialogs are tiny (< 20 elements). - const elCount = await page.evaluate(`document.querySelectorAll('[id^="form${formAfter}_"]').length`); - if (elCount < 20) { - // Likely an info dialog — check and dismiss - const err2 = await checkForErrors(); - if (err2?.modal) { - await dismissPendingErrors(); - await waitForStable(formBefore); - continue; // retry open cycle - } - } - // It's the real EPF form - const state = await getFormState(); - state.opened = { file: absPath, attempt: attempt + 1 }; - return state; - } - // Form didn't appear — retry - continue; - } - - // No security dialog — check if form appeared - if (err?.modal) { - throw new Error(`Error opening file: ${err.modal.message}`); - } - const formAfter = await page.evaluate(detectFormScript()); - if (formAfter != null && formAfter !== formBefore) { - const state = await getFormState(); - state.opened = { file: absPath, attempt: attempt + 1 }; - return state; - } - } - - throw new Error(`Form did not open after ${MAX_ATTEMPTS} attempts for: ${absPath}`); -} - -/** Navigate to a 1C navigation link via Shift+F11 dialog. Returns new form state. */ -export async function navigateLink(url) { - ensureConnected(); - await dismissPendingErrors(); - const link = normalizeE1cibUrl(url); - const formBefore = await page.evaluate(detectFormScript()); - - // Copy link to clipboard, press Shift+F11 (opens "Go to link" dialog with clipboard content) - await pasteText(link, { confirm: 'Shift+F11', postDelay: 200 }); - await waitForStable(); - - // Click "Перейти" in the navigation dialog - const dialog = await page.evaluate(detectFormScript()); - if (dialog != null && dialog !== formBefore) { - const btns = await page.$$(`#form${dialog}_container a.press`); - for (const b of btns) { - const txt = (await b.textContent())?.trim(); - if (txt === 'Перейти') { await b.click(); break; } - } - } - - await waitForStable(formBefore); - return await returnFormState(); -} +// ============================================================ +// Navigation — extracted to nav/navigation.mjs +// ============================================================ +export { + getPageState, getSections, navigateSection, getCommands, + openCommand, switchTab, openFile, navigateLink, +} from './nav/navigation.mjs'; /** Read current form state. Single evaluate call via combined script. */ export async function getFormState() { diff --git a/.claude/skills/web-test/scripts/nav/navigation.mjs b/.claude/skills/web-test/scripts/nav/navigation.mjs new file mode 100644 index 00000000..ecce3152 --- /dev/null +++ b/.claude/skills/web-test/scripts/nav/navigation.mjs @@ -0,0 +1,255 @@ +// web-test nav/navigation v1.16 — Section navigation, openCommand, switchTab, navigateLink (Shift+F11), openFile. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { + page, ensureConnected, highlightMode, resolveProjectPath, +} from '../core/state.mjs'; +import { + readSectionsScript, readTabsScript, readCommandsScript, + navigateSectionScript, openCommandScript, switchTabScript, + detectFormScript, +} from '../dom.mjs'; +import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; +import { waitForStable, waitForCondition } from '../core/wait.mjs'; +import { highlight, unhighlight } from '../recording/highlight.mjs'; +import { returnFormState } from '../core/helpers.mjs'; +// pasteText + getFormState live in browser.mjs (move to forms/ in a later stage). +// Static import — ESM cycle that resolves at call time. +import { pasteText, getFormState } from '../browser.mjs'; + +/** + * Get current page state: active section, tabs. + * Combined into a single evaluate call. + */ +export async function getPageState() { + ensureConnected(); + const { sections, tabs } = await page.evaluate(`({ + sections: ${readSectionsScript()}, + tabs: ${readTabsScript()} + })`); + const activeSection = sections.find(s => s.active)?.name || null; + const activeTab = tabs.find(t => t.active)?.name || null; + return { activeSection, activeTab, sections, tabs }; +} + +/** Read section panel + commands in a single evaluate call. */ +export async function getSections() { + ensureConnected(); + const { sections, commands } = await page.evaluate(`({ + sections: ${readSectionsScript()}, + commands: ${readCommandsScript()} + })`); + const activeSection = sections.find(s => s.active)?.name || null; + return { activeSection, sections, commands }; +} + +/** Navigate to a section by name. Returns new state with commands. */ +export async function navigateSection(name) { + ensureConnected(); + await dismissPendingErrors(); + if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {} + const result = await page.evaluate(navigateSectionScript(name)); + if (result?.error) { + const avail = result.available?.filter(Boolean); + if (avail?.length === 0) throw new Error(`navigateSection: "${name}" not found. Section panel is in icon-only mode — text labels are hidden. Switch to "Text" or "Picture and text" display mode in 1C settings (View → Section Panel → Display Mode)`); + throw new Error(`navigateSection: "${name}" not found. Available: ${avail?.join(', ') || 'none'}`); + } + + await waitForStable(); + const { sections, commands } = await page.evaluate(`({ + sections: ${readSectionsScript()}, + commands: ${readCommandsScript()} + })`); + return { navigated: result, sections, commands }; +} + +/** Read commands of the current section. */ +export async function getCommands() { + ensureConnected(); + return await page.evaluate(readCommandsScript()); +} + +/** Open a command from function panel by name. Returns new form state. */ +export async function openCommand(name) { + ensureConnected(); + await dismissPendingErrors(); + if (highlightMode) try { await highlight(name); await page.waitForTimeout(500); await unhighlight(); } catch {} + const formBefore = await page.evaluate(detectFormScript()); + const result = await page.evaluate(openCommandScript(name)); + if (result?.error) throw new Error(`openCommand: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`); + + await waitForStable(formBefore); + return await returnFormState(); +} + +/** Switch to an open tab by name (fuzzy match). Returns updated form state. */ +export async function switchTab(name) { + ensureConnected(); + const result = await page.evaluate(switchTabScript(name)); + if (result?.error) throw new Error(`switchTab: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`); + await waitForStable(); + return await getFormState(); +} + +// English → Russian metadata type mapping for e1cib navigation links +const E1CIB_TYPE_MAP = { + 'catalog': 'Справочник', 'catalogs': 'Справочник', + 'document': 'Документ', 'documents': 'Документ', + 'commonmodule': 'ОбщийМодуль', + 'enum': 'Перечисление', 'enums': 'Перечисление', + 'dataprocessor': 'Обработка', 'dataprocessors': 'Обработка', + 'report': 'Отчет', 'reports': 'Отчет', + 'accumulationregister': 'РегистрНакопления', + 'informationregister': 'РегистрСведений', + 'accountingregister': 'РегистрБухгалтерии', + 'calculationregister': 'РегистрРасчета', + 'chartofaccounts': 'ПланСчетов', + 'chartofcharacteristictypes': 'ПланВидовХарактеристик', + 'chartofcalculationtypes': 'ПланВидовРасчета', + 'businessprocess': 'БизнесПроцесс', + 'task': 'Задача', + 'exchangeplan': 'ПланОбмена', + 'constant': 'Константа', +}; + +// Types that open via e1cib/app/ (reports and data processors have their own app forms) +const E1CIB_APP_TYPES = new Set(['Отчет', 'Обработка']); + +function normalizeE1cibUrl(url) { + // Already a full e1cib link + if (url.startsWith('e1cib/')) return url; + // "ТипОбъекта.Имя" or "EnglishType.Имя" — translate type, pick list/ or app/ prefix + const dot = url.indexOf('.'); + if (dot > 0) { + const typePart = url.substring(0, dot); + const namePart = url.substring(dot + 1); + const ruType = E1CIB_TYPE_MAP[typePart.toLowerCase()] || typePart; + const prefix = E1CIB_APP_TYPES.has(ruType) ? 'e1cib/app' : 'e1cib/list'; + return `${prefix}/${ruType}.${namePart}`; + } + return `e1cib/list/${url}`; +} + +/** + * Open an external data processor or report (EPF/ERF) via File → Open menu. + * Handles the security confirmation dialog on first open. + * @param {string} filePath - path to EPF/ERF file (absolute or relative to cwd) + * @returns {Promise} form state of the opened processor/report + */ +export async function openFile(filePath) { + ensureConnected(); + await dismissPendingErrors(); + const absPath = resolveProjectPath(filePath.replace(/\\/g, '/')); + + const MAX_ATTEMPTS = 2; // 1st may trigger security dialog, 2nd is the real open + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + const formBefore = await page.evaluate(detectFormScript()); + + // 1. Ctrl+O opens 1C's "Выбор файлов" dialog + await page.keyboard.press('Control+o'); + + // 2. Wait for the file selection dialog + const dialogOk = await waitForCondition(`(() => { + const ok = document.querySelector('#fileSelectDialogOk'); + return ok && ok.offsetWidth > 0 ? true : false; + })()`, 3000); + if (!dialogOk) throw new Error("File selection dialog did not open (Ctrl+O)"); + + // 3. Click "выберите с диска" to trigger the native OS file picker + let fileChooser; + try { + [fileChooser] = await Promise.all([ + page.waitForEvent('filechooser', { timeout: 5000 }), + page.click('a.underline.pointer'), + ]); + } catch (e) { + // Try closing the dialog before throwing + await page.keyboard.press('Escape'); + throw new Error(`File chooser did not appear: ${e.message}`); + } + + // 4. Set the file path and click OK + await fileChooser.setFiles(absPath); + await page.waitForTimeout(500); + await page.click('#fileSelectDialogOk'); + await waitForStable(formBefore); + + // 5. Check for security dialog + const err = await checkForErrors(); + if (err?.confirmation) { + // Security confirmation — click the positive button (Продолжить/Да/OK) + const positiveBtn = err.confirmation.buttons.find(b => + /продолжить|да|ok|yes|открыть/i.test(b) + ) || err.confirmation.buttons[0]; + if (positiveBtn) { + const btns = await page.$$(`#form${err.confirmation.formNum}_container a.press.pressButton`); + for (const b of btns) { + const txt = (await b.textContent())?.trim(); + if (txt === positiveBtn) { await b.click(); break; } + } + await waitForStable(formBefore); + } + // After confirmation, check if EPF form appeared or a follow-up dialog showed. + // Check form change FIRST — avoids confusing a small EPF form with a modal dialog. + const formAfter = await page.evaluate(detectFormScript()); + if (formAfter != null && formAfter !== formBefore) { + // New form appeared — but is it the EPF or an informational dialog? + // Informational "re-open" dialogs are tiny (< 20 elements). + const elCount = await page.evaluate(`document.querySelectorAll('[id^="form${formAfter}_"]').length`); + if (elCount < 20) { + // Likely an info dialog — check and dismiss + const err2 = await checkForErrors(); + if (err2?.modal) { + await dismissPendingErrors(); + await waitForStable(formBefore); + continue; // retry open cycle + } + } + // It's the real EPF form + const state = await getFormState(); + state.opened = { file: absPath, attempt: attempt + 1 }; + return state; + } + // Form didn't appear — retry + continue; + } + + // No security dialog — check if form appeared + if (err?.modal) { + throw new Error(`Error opening file: ${err.modal.message}`); + } + const formAfter = await page.evaluate(detectFormScript()); + if (formAfter != null && formAfter !== formBefore) { + const state = await getFormState(); + state.opened = { file: absPath, attempt: attempt + 1 }; + return state; + } + } + + throw new Error(`Form did not open after ${MAX_ATTEMPTS} attempts for: ${absPath}`); +} + +/** Navigate to a 1C navigation link via Shift+F11 dialog. Returns new form state. */ +export async function navigateLink(url) { + ensureConnected(); + await dismissPendingErrors(); + const link = normalizeE1cibUrl(url); + const formBefore = await page.evaluate(detectFormScript()); + + // Copy link to clipboard, press Shift+F11 (opens "Go to link" dialog with clipboard content) + await pasteText(link, { confirm: 'Shift+F11', postDelay: 200 }); + await waitForStable(); + + // Click "Перейти" in the navigation dialog + const dialog = await page.evaluate(detectFormScript()); + if (dialog != null && dialog !== formBefore) { + const btns = await page.$$(`#form${dialog}_container a.press`); + for (const b of btns) { + const txt = (await b.textContent())?.trim(); + if (txt === 'Перейти') { await b.click(); break; } + } + } + + await waitForStable(formBefore); + return await returnFormState(); +}