refactor(web-test): этап C.7 — выделить nav/navigation.mjs

Перенос 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) <noreply@anthropic.com>
This commit is contained in:
Nick Shirokov
2026-05-26 12:16:56 +03:00
parent 12c5cf5e66
commit c4b1aee9c9
2 changed files with 262 additions and 236 deletions
+7 -236
View File
@@ -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<object>} 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() {
@@ -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<object>} 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();
}