From e3a9be00365cd4f1709811c6edeacbf1b0365917 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Sun, 15 Mar 2026 11:57:55 +0300 Subject: [PATCH] feat(web-test): add FormNavigationPanel support, fix --no-record server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Navigation panel: getFormState() returns `navigation` array with form navigation links (e.g. "Основное", "Объекты метаданных"). clickElement() can now click navigation panel items (kind: navigation). DOM: `.navigationItem` inside parent `page{N}` container. 2. --no-record: move recording stub from client-side code injection to server-side sandbox export replacement. Stubs startRecording, stopRecording, addNarration, showCaption, hideCaption, showTitleSlide, hideTitleSlide as no-ops. Covers both direct calls and user wrappers like record()/finalize(). Co-Authored-By: Claude Opus 4.6 --- .claude/skills/web-test/SKILL.md | 6 ++-- .claude/skills/web-test/scripts/dom.mjs | 38 ++++++++++++++++++++++++- .claude/skills/web-test/scripts/run.mjs | 26 +++++++++++------ 3 files changed, 58 insertions(+), 12 deletions(-) diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 49f52928..fa72cdf0 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -121,11 +121,13 @@ Switch to an already-open tab/window (fuzzy match). ### Reading form state -#### `getFormState()` → `{ fields, buttons, tabs, table, tables, filters, reportSettings? }` +#### `getFormState()` → `{ fields, buttons, tabs, navigation?, table, tables, filters, reportSettings? }` Returns current form structure. This is the primary way to understand what's on screen. **fields** — each field has: `name`, `value`, `label?`, `actions?` (select, clear, open), `required?` (true for unfilled mandatory fields) +**navigation** — form navigation panel links (for objects with subordinate catalogs): `[{ name, active? }]`. Clickable via `clickElement()`. Only present when the form has a navigation panel (e.g. "Основное", "Объекты метаданных", "Подсистемы"). + **tables** — array of all visible grids: `[{ name, columns, rowCount, label? }]`. `label` is the visual group title shown on screen (e.g. "Входящие"), absent when grid has no visible title. Use `readTable()` for actual data. **table** — backward-compatible alias for the first grid: `{ present, columns, rowCount }`. @@ -197,7 +199,7 @@ Sections + all open tabs. ### Actions #### `clickElement(text, { dblclick?, table?, toggle? })` → form state -Click button, hyperlink, tab, or grid row (fuzzy match). +Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match). - `table` — scope button search to a specific grid's command panel (by name from `tables[]`): ```js diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index 93ac9a61..a610c0f9 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.0 — DOM selectors and semantic mapping for 1C web client +// web-test dom v1.1 — DOM selectors and semantic mapping for 1C web client // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * DOM selectors and semantic mapping for 1C:Enterprise web client. @@ -235,6 +235,25 @@ const READ_FORM_FN = `function readForm(p) { } if (filters.length) result.filters = filters; + // Navigation panel (FormNavigationPanel) — lives in parent page{N} container + const navigation = []; + const formEl = document.querySelector('[id^="' + p + '"]'); + if (formEl) { + let pageEl = formEl.parentElement; + while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement; + if (pageEl) { + pageEl.querySelectorAll('.navigationItem').forEach(el => { + if (el.offsetWidth === 0) return; + const nameEl = el.querySelector('.navigationItemName'); + const text = (nameEl?.innerText?.trim() || '').replace(/\\u00a0/g, ' '); + if (!text) return; + const nav = { name: text }; + if (el.classList.contains('select')) nav.active = true; + navigation.push(nav); + }); + } + } + // Iframes let iframeCount = 0; document.querySelectorAll('[id^="' + p + '"] iframe, iframe[id^="' + p + '"]').forEach(el => { @@ -245,6 +264,7 @@ const READ_FORM_FN = `function readForm(p) { if (fields.length) result.fields = fields; if (buttons.length) result.buttons = buttons; if (formTabs.length) result.tabs = formTabs; + if (navigation.length) result.navigation = navigation; if (texts.length) result.texts = texts; if (hyperlinks.length) result.hyperlinks = hyperlinks; @@ -637,6 +657,22 @@ export function findClickTargetScript(formNum, text, { tableName, gridSelector } items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab' }); }); + // Navigation panel items (FormNavigationPanel) — in parent page{N} + const formEl = document.querySelector('[id^="' + p + '"]'); + if (formEl) { + let pageEl = formEl.parentElement; + while (pageEl && !(pageEl.id && /^page\\d+$/.test(pageEl.id))) pageEl = pageEl.parentElement; + if (pageEl) { + pageEl.querySelectorAll('.navigationItem').forEach(el => { + if (el.offsetWidth === 0) return; + const nameEl = el.querySelector('.navigationItemName'); + const text = norm(nameEl?.innerText || ''); + if (!text) return; + items.push({ id: el.id, name: text, label: '', kind: 'navigation' }); + }); + } + } + // When table is specified, scope button search to grid's parent container if (gridSelector) { const gridEl = document.querySelector(gridSelector); diff --git a/.claude/skills/web-test/scripts/run.mjs b/.claude/skills/web-test/scripts/run.mjs index d21833d9..2cfcb52c 100644 --- a/.claude/skills/web-test/scripts/run.mjs +++ b/.claude/skills/web-test/scripts/run.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -// web-test run v1.0 — CLI runner for 1C web client automation +// web-test run v1.1 — CLI runner for 1C web client automation // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * CLI runner for 1C web client automation. @@ -74,7 +74,8 @@ async function handleRequest(req, res) { try { if (req.method === 'POST' && req.url === '/exec') { const code = await readBody(req); - const result = await executeScript(code); + const noRecord = req.headers['x-no-record'] === '1'; + const result = await executeScript(code, { noRecord }); json(res, result); } else if (req.method === 'GET' && req.url === '/shot') { @@ -100,7 +101,7 @@ async function handleRequest(req, res) { } } -async function executeScript(code) { +async function executeScript(code, { noRecord } = {}) { const output = []; const origLog = console.log; const origErr = console.error; @@ -117,6 +118,16 @@ async function executeScript(code) { exports.writeFileSync = writeFileSync; exports.readFileSync = readFileSync; + // --no-record: stub all recording/narration functions as no-ops + if (noRecord) { + const noop = async () => {}; + for (const fn of ['startRecording', 'stopRecording', 'addNarration', 'showCaption', 'hideCaption', 'showTitleSlide', 'hideTitleSlide']) { + exports[fn] = noop; + } + exports.isRecording = () => false; + exports.getCaptions = () => []; + } + // Wrap action functions to auto-detect 1C errors (modal, balloon) // and stop execution immediately with diagnostic info const ACTION_FNS = [ @@ -205,16 +216,13 @@ async function cmdExec(fileOrDash, flags = {}) { ? await readStdin() : readFileSync(resolve(fileOrDash), 'utf-8'); - if (flags.noRecord) { - // Inject no-op record() before user code - code = 'async function record() {} // --no-record\n' + code; - } - const sess = loadSession(); + const headers = {}; + if (flags.noRecord) headers['x-no-record'] = '1'; const result = await new Promise((resolve, reject) => { const req = http.request({ hostname: '127.0.0.1', port: sess.port, path: '/exec', - method: 'POST', timeout: 10 * 60 * 1000, + method: 'POST', timeout: 10 * 60 * 1000, headers, }, res => { let data = ''; res.on('data', chunk => data += chunk);