From cecf4dd9a27ec4e0c07b2e6d836eadaf8149884c Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 25 May 2026 22:00:53 +0300 Subject: [PATCH 01/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20A.1=20=E2=80=94=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20module-level=20state=20=D0=B2=20core/state.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Состояние движка (browser, page, sessionPrefix, seanceId, recorder, контексты, константы, normYo, isConnected/ensureConnected/getPage) переехало в core/state.mjs. Импортируется как live-binding; присваивания в browser.mjs конвертированы в setX(...) — ESM imports read-only. Публичный API не меняется (56 экспортов). Регресс 19/19 зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 171 +++++++----------- .../skills/web-test/scripts/core/state.mjs | 113 ++++++++++++ 2 files changed, 176 insertions(+), 108 deletions(-) create mode 100644 .claude/skills/web-test/scripts/core/state.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 4ebb80a2..c62d2f97 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -21,53 +21,23 @@ import { switchTabScript, resolveGridScript } from './dom.mjs'; -// Project root: 4 levels up from .claude/skills/web-test/scripts/browser.mjs -const __fn_browser = fileURLToPath(import.meta.url); -const projectRoot = pathResolve(dirname(__fn_browser), '..', '..', '..', '..'); +// Module-level state, constants, normYo and resolveProjectPath live in core/state.mjs. +// Imported as live bindings — reads stay current; writes go through setters. +import { + browser, page, sessionPrefix, seanceId, recorder, + lastCaptions, lastRecordingDuration, highlightMode, + persistentUserDataDir, preserveClipboard, + contexts, activeContextName, activeMode, + setBrowser, setPage, setSessionPrefix, setSeanceId, setRecorder, + setLastCaptions, setLastRecordingDuration, setHighlightMode, + setPersistentUserDataDir, setActiveContextName, setActiveMode, + setClipboardWarnLogged, + LOAD_TIMEOUT, INIT_TIMEOUT, ACTION_WAIT, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES, + EXT_ID, projectRoot, resolveProjectPath, normYo, + isConnected, ensureConnected, getPage, setPreserveClipboard, +} from './core/state.mjs'; -/** Resolve a user-provided path relative to the project root (not cwd). */ -const resolveProjectPath = (p) => pathResolve(projectRoot, p); - -let browser = null; -let page = null; -let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU" -let seanceId = null; -let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions } -let lastCaptions = []; // captions from the last completed recording (for addNarration) -let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds) -let highlightMode = false; - -// Multi-context registry: name → { context, page, sessionPrefix, seanceId, recorder, lastCaptions, lastRecordingDuration, highlightMode } -// Populated by createContext(); module-level vars above mirror the active slot. -// connect() does NOT use this Map — it preserves legacy single-session behavior for exec/run/start. -const contexts = new Map(); -let activeContextName = null; -// Isolation mode for the current cmdTest session — set by the first createContext call. -// 'tab': all contexts share one persistent context (one window, multiple tabs, extension loads reliably). -// 'window': each context gets its own BrowserContext (separate window per context, full cookie isolation, extension may not load). -let activeMode = null; - -const LOAD_TIMEOUT = 60000; -const INIT_TIMEOUT = 60000; -const ACTION_WAIT = 2000; // fallback minimum wait - -/** Normalize ё→е and \u00a0→space for fuzzy matching. */ -const normYo = s => s.replace(/ё/gi, 'е').replace(/\u00a0/g, ' '); -const MAX_WAIT = 10000; // max wait for stability -const POLL_INTERVAL = 200; // polling interval -const STABLE_CYCLES = 3; // consecutive stable cycles needed - -// 1C browser extension ID (stable across versions, defined by key in manifest.json) -const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik'; -let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect - -// Clipboard preservation: save full clipboard contents (all MIME types) right before -// each writeText+Ctrl+V pair, restore right after — narrow window so a user's -// concurrent Ctrl+C isn't clobbered. Blobs are stashed on `window` (no CDP -// serialization). Toggled via setPreserveClipboard() from run.mjs. -let preserveClipboard = true; -let clipboardWarnLogged = false; -export function setPreserveClipboard(v) { preserveClipboard = !!v; } +export { isConnected, getPage, setPreserveClipboard, ensureConnected }; export async function saveClipboard() { if (!page) return; try { @@ -120,7 +90,7 @@ export async function restoreClipboard() { return; } if (err && !clipboardWarnLogged) { - clipboardWarnLogged = true; + setClipboardWarnLogged(true); console.error(`[web-test] clipboard preserve skipped: ${err} (logged once per session)`); } } @@ -187,14 +157,7 @@ function findExtension(overridePath) { return null; } -/** Check if browser is connected and page is usable. */ -export function isConnected() { - if (!browser || !page || page.isClosed()) return false; - // launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser - if (typeof browser.isConnected === 'function') return browser.isConnected(); - // For persistent context, check via context's browser() - return browser.browser()?.isConnected() ?? false; -} +/* isConnected moved to core/state.mjs */ /** * Open browser and navigate to 1C web client URL. @@ -207,7 +170,7 @@ export async function connect(url, { extensionPath } = {}) { const extPath = findExtension(extensionPath); if (extPath) { // Launch with 1C browser extension via persistent context - persistentUserDataDir = pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now()); + setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now())); mkdirSync(persistentUserDataDir, { recursive: true }); const context = await chromium.launchPersistentContext(persistentUserDataDir, { headless: false, @@ -219,28 +182,28 @@ export async function connect(url, { extensionPath } = {}) { viewport: null, permissions: ['clipboard-read', 'clipboard-write'], }); - browser = context; // persistent context IS the browser - page = context.pages()[0] || await context.newPage(); + setBrowser(context); // persistent context IS the browser + setPage(context.pages()[0] || await context.newPage()); } else { // Fallback: launch without extension - browser = await chromium.launch({ headless: false, args: ['--start-maximized'] }); + setBrowser(await chromium.launch({ headless: false, args: ['--start-maximized'] })); const context = await browser.newContext({ viewport: null, permissions: ['clipboard-read', 'clipboard-write'], }); - page = await context.newPage(); + setPage(await context.newPage()); } // Auto-accept native browser dialogs (confirm/alert from 1C scripts like vis.js) page.on('dialog', dialog => dialog.accept().catch(() => {})); // Capture seanceId from network requests for graceful logout - sessionPrefix = null; - seanceId = null; + setSessionPrefix(null); + setSeanceId(null); page.on('request', req => { if (seanceId) return; const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/); - if (m) { sessionPrefix = m[1]; seanceId = m[2]; } + if (m) { setSessionPrefix(m[1]); setSeanceId(m[2]); } }); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); @@ -293,8 +256,8 @@ export async function disconnect() { await _logoutSlot(slot); } contexts.clear(); - activeContextName = null; - activeMode = null; + setActiveContextName(null); + setActiveMode(null); } // Single-session path (connect): auto-stop recording if active @@ -306,14 +269,14 @@ export async function disconnect() { // Graceful logout — release the 1C license (single-session connect path) await _logoutSlot({ page, sessionPrefix, seanceId }, 1000); await browser.close().catch(() => {}); - browser = null; - page = null; - sessionPrefix = null; - seanceId = null; + setBrowser(null); + setPage(null); + setSessionPrefix(null); + setSeanceId(null); // Clean up persistent user data dir if (persistentUserDataDir) { try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {} - persistentUserDataDir = null; + setPersistentUserDataDir(null); } } } @@ -324,12 +287,12 @@ export async function disconnect() { */ export async function attach(wsEndpoint, session = {}) { if (isConnected()) return; - browser = await chromium.connect(wsEndpoint); + setBrowser(await chromium.connect(wsEndpoint)); const ctx = browser.contexts()[0]; - page = ctx?.pages()[0]; + setPage(ctx?.pages()[0]); if (!page) throw new Error('No page found in browser'); - sessionPrefix = session.sessionPrefix || null; - seanceId = session.seanceId || null; + setSessionPrefix(session.sessionPrefix || null); + setSeanceId(session.seanceId || null); } /** @@ -338,10 +301,10 @@ export async function attach(wsEndpoint, session = {}) { */ export function detach() { const session = { sessionPrefix, seanceId }; - browser = null; - page = null; - sessionPrefix = null; - seanceId = null; + setBrowser(null); + setPage(null); + setSessionPrefix(null); + setSeanceId(null); return session; } @@ -375,11 +338,11 @@ function _saveActiveSlot() { function _activateSlot(name) { const slot = contexts.get(name); if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`); - page = slot.page; - sessionPrefix = slot.sessionPrefix; - seanceId = slot.seanceId; - highlightMode = slot.highlightMode || false; - activeContextName = name; + setPage(slot.page); + setSessionPrefix(slot.sessionPrefix); + setSeanceId(slot.seanceId); + setHighlightMode(slot.highlightMode || false); + setActiveContextName(name); } /** Attach 1C session listeners to a page, writing into the given slot. */ @@ -392,8 +355,8 @@ function _attachSessionListeners(pg, slot, name) { slot.sessionPrefix = m[1]; slot.seanceId = m[2]; if (activeContextName === name) { - sessionPrefix = m[1]; - seanceId = m[2]; + setSessionPrefix(m[1]); + setSeanceId(m[2]); } } }); @@ -436,19 +399,19 @@ export async function createContext(name, url, { extensionPath, isolation = 'tab } if (isolation === 'tab') { // Persistent context: extension loads reliably, one window with tabs per context - persistentUserDataDir = pathJoin(tmpdir(), 'pw-1c-test-' + Date.now()); + setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-test-' + Date.now())); mkdirSync(persistentUserDataDir, { recursive: true }); - browser = await chromium.launchPersistentContext(persistentUserDataDir, { + setBrowser(await chromium.launchPersistentContext(persistentUserDataDir, { headless: false, args: launchArgs, viewport: null, permissions: ['clipboard-read', 'clipboard-write'], - }); + })); } else { // Window mode: separate BrowserContext per slot, full cookie isolation - browser = await chromium.launch({ headless: false, args: launchArgs }); + setBrowser(await chromium.launch({ headless: false, args: launchArgs })); } - activeMode = isolation; + setActiveMode(isolation); } // Save current active before switching @@ -1000,11 +963,7 @@ async function _fetchStackViaHamburger(formNum) { return _parseErrorStack(firstBlock || errorText); } -/** Get the raw Playwright page object (for advanced scripting in skill mode). */ -export function getPage() { - ensureConnected(); - return page; -} +/* getPage moved to core/state.mjs */ /** * Get current page state: active section, tabs. @@ -5152,8 +5111,8 @@ export async function startRecording(outputPath, opts = {}) { throw new Error('Already recording. Call stopRecording() first, or use { force: true }.'); } } - lastCaptions = []; - lastRecordingDuration = null; + setLastCaptions([]); + setLastRecordingDuration(null); const fps = opts.fps || 25; const quality = opts.quality || 80; @@ -5240,7 +5199,7 @@ export async function startRecording(outputPath, opts = {}) { recorder.activePage = targetPage; }; - recorder = { + setRecorder({ cdp: null, activePage: null, ffmpeg, @@ -5255,7 +5214,7 @@ export async function startRecording(outputPath, opts = {}) { _flushFrames, _attachPage, speechRate, - }; + }); ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); }); await _attachPage(page); @@ -5301,15 +5260,15 @@ export async function stopRecording() { const stats = statSync(outputPath); // Preserve captions for addNarration() - lastCaptions = recorder.captions || []; - lastRecordingDuration = duration; + setLastCaptions(recorder.captions || []); + setLastRecordingDuration(duration); if (lastCaptions.length) { const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json'); const captionsData = { recordingDuration: duration, videoTimestamps: true, captions: lastCaptions }; writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8'); } - recorder = null; + setRecorder(null); return { file: outputPath, @@ -6107,7 +6066,7 @@ export async function unhighlight() { * @param {boolean} on true to enable, false to disable */ export function setHighlight(on) { - highlightMode = !!on; + setHighlightMode(!!on); } /** @returns {boolean} Whether auto-highlight mode is active. */ @@ -6286,8 +6245,4 @@ function generateSilence(outputPath, seconds, ffmpegPath) { ], { stdio: 'pipe', timeout: 10000 }); } -function ensureConnected() { - if (!isConnected()) { - throw new Error('Browser not connected. Call web_connect first.'); - } -} +/* ensureConnected moved to core/state.mjs */ diff --git a/.claude/skills/web-test/scripts/core/state.mjs b/.claude/skills/web-test/scripts/core/state.mjs new file mode 100644 index 00000000..62dba485 --- /dev/null +++ b/.claude/skills/web-test/scripts/core/state.mjs @@ -0,0 +1,113 @@ +// web-test core/state v1.16 — module-level state for the web-test engine. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Holds the single browser/page/recorder slot plus the multi-context registry, +// constants, and small state-only utilities (ensureConnected, getPage, +// resolveProjectPath, normYo). Mutable values are exported as `let` bindings +// for live read access from consumer modules; writes go through setters so +// imported bindings stay read-only at the import site. + +import { dirname, resolve as pathResolve } from 'path'; +import { fileURLToPath } from 'url'; + +// Project root: 4 levels up from .claude/skills/web-test/scripts/core/state.mjs +const __fn_state = fileURLToPath(import.meta.url); +export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..'); + +/** Resolve a user-provided path relative to the project root (not cwd). */ +export const resolveProjectPath = (p) => pathResolve(projectRoot, p); + +// ────────────────────────────────────────────────────────────────────────── +// Mutable single-session state. Importers read via the live binding; writes +// must go through the corresponding setter (ESM imports are read-only). +// ────────────────────────────────────────────────────────────────────────── + +export let browser = null; +export let page = null; +export let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU" +export let seanceId = null; +export let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions } +export let lastCaptions = []; // captions from the last completed recording (for addNarration) +export let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds) +export let highlightMode = false; +export let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect + +// Clipboard preservation: save full clipboard contents (all MIME types) right +// before each writeText+Ctrl+V pair, restore right after. Toggled via +// setPreserveClipboard() from run.mjs. +export let preserveClipboard = true; +export let clipboardWarnLogged = false; + +export const setBrowser = (v) => { browser = v; }; +export const setPage = (v) => { page = v; }; +export const setSessionPrefix = (v) => { sessionPrefix = v; }; +export const setSeanceId = (v) => { seanceId = v; }; +export const setRecorder = (v) => { recorder = v; }; +export const setLastCaptions = (v) => { lastCaptions = v; }; +export const setLastRecordingDuration = (v) => { lastRecordingDuration = v; }; +export const setHighlightMode = (v) => { highlightMode = !!v; }; +export const setPersistentUserDataDir = (v) => { persistentUserDataDir = v; }; +export const setPreserveClipboard = (v) => { preserveClipboard = !!v; }; +export const setClipboardWarnLogged = (v) => { clipboardWarnLogged = !!v; }; + +// ────────────────────────────────────────────────────────────────────────── +// Multi-context registry: name → { context, page, sessionPrefix, seanceId, +// recorder, lastCaptions, lastRecordingDuration, highlightMode }. +// Populated by createContext(); module-level vars above mirror the active +// slot. connect() does NOT use this Map — it preserves legacy single-session +// behavior for exec/run/start. +// ────────────────────────────────────────────────────────────────────────── + +export const contexts = new Map(); +export let activeContextName = null; +// Isolation mode for the current cmdTest session — set by the first +// createContext call. 'tab': all contexts share one persistent context +// (one window, multiple tabs, extension loads reliably). 'window': each +// context gets its own BrowserContext (separate window per context, full +// cookie isolation, extension may not load). +export let activeMode = null; + +export const setActiveContextName = (v) => { activeContextName = v; }; +export const setActiveMode = (v) => { activeMode = v; }; + +// ────────────────────────────────────────────────────────────────────────── +// Constants. +// ────────────────────────────────────────────────────────────────────────── + +export const LOAD_TIMEOUT = 60000; +export const INIT_TIMEOUT = 60000; +export const ACTION_WAIT = 2000; // fallback minimum wait +export const MAX_WAIT = 10000; // max wait for stability +export const POLL_INTERVAL = 200; // polling interval +export const STABLE_CYCLES = 3; // consecutive stable cycles needed + +// 1C browser extension ID (stable across versions, defined by key in manifest.json) +export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik'; + +// ────────────────────────────────────────────────────────────────────────── +// Utilities that only depend on state. +// ────────────────────────────────────────────────────────────────────────── + +/** Normalize ё→е and  →space for fuzzy matching. */ +export const normYo = (s) => s.replace(/ё/gi, 'е').replace(/ /g, ' '); + +/** Check if browser is connected and page is usable. */ +export function isConnected() { + if (!browser || !page || page.isClosed()) return false; + // launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser + if (typeof browser.isConnected === 'function') return browser.isConnected(); + // For persistent context, check via context's browser() + return browser.browser()?.isConnected() ?? false; +} + +export function ensureConnected() { + if (!isConnected()) { + throw new Error('Browser not connected. Call web_connect first.'); + } +} + +/** Get the raw Playwright page object (for advanced scripting in skill mode). */ +export function getPage() { + ensureConnected(); + return page; +} From 398c515390a88c501ccc453ce85d5fb24d0e3597 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 25 May 2026 22:07:32 +0300 Subject: [PATCH 02/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20A.2=20=E2=80=94=20=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20recording/*=20=D0=B2=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B5=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перенос ~1200 LOC из browser.mjs в recording/{tts,captions,capture,highlight,narration}.mjs: - tts.mjs: resolveFfmpeg, resolveEdgeTts, edge/openai/elevenlabs providers, getTtsProvider, getAudioDuration, generateSilence - captions.mjs: showCaption/hideCaption/getCaptions, showTitleSlide/ hideTitleSlide, showImage/hideImage - capture.mjs: screenshot, wait, isRecording, startRecording, stopRecording - highlight.mjs: highlight, unhighlight, setHighlight, isHighlightMode - narration.mjs: addNarration browser.mjs стал тоньше на 1200 строк, re-export через `export { ... } from './recording/*.mjs'`. Публичный API сохранён (56 экспортов). state.mjs нормализован на CRLF. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 1206 +---------------- .../skills/web-test/scripts/core/state.mjs | 226 +-- .../web-test/scripts/recording/captions.mjs | 292 ++++ .../web-test/scripts/recording/capture.mjs | 244 ++++ .../web-test/scripts/recording/highlight.mjs | 340 +++++ .../web-test/scripts/recording/narration.mjs | 196 +++ .../skills/web-test/scripts/recording/tts.mjs | 175 +++ 7 files changed, 1373 insertions(+), 1306 deletions(-) create mode 100644 .claude/skills/web-test/scripts/recording/captions.mjs create mode 100644 .claude/skills/web-test/scripts/recording/capture.mjs create mode 100644 .claude/skills/web-test/scripts/recording/highlight.mjs create mode 100644 .claude/skills/web-test/scripts/recording/narration.mjs create mode 100644 .claude/skills/web-test/scripts/recording/tts.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index c62d2f97..1cb965fe 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -5049,1200 +5049,20 @@ export async function unfilterList({ field } = {}) { return state; } -/** Take a screenshot. Returns PNG buffer. */ -export async function screenshot() { - ensureConnected(); - return await page.screenshot({ type: 'png' }); -} - -/** Wait for a specified number of seconds. */ -export async function wait(seconds) { - ensureConnected(); - let ms = seconds * 1000; - // Credit system: if showCaption already waited for TTS, subtract that time - if (recorder && recorder.captionCredit) { - const elapsed = Date.now() - recorder.captionCredit.at; - const credit = Math.max(0, recorder.captionCredit.waitedMs - elapsed); - ms = Math.max(0, ms - credit); - recorder.captionCredit = null; - } - if (ms > 0) { - // During recording, split long waits into chunks and flush frames - // to keep video timeline in sync (CDP may not send frames for static pages) - if (recorder?._flushFrames && ms > 1000) { - let remaining = ms; - while (remaining > 0) { - const chunk = Math.min(remaining, 1000); - await page.waitForTimeout(chunk); - remaining -= chunk; - recorder._flushFrames(); - } - } else { - await page.waitForTimeout(ms); - } - } - return await getFormState(); -} - // ============================================================ -// Video recording — CDP screencast + ffmpeg +// Recording, captions, narration, highlight — extracted to recording/* // ============================================================ - -/** Check if video recording is active. */ -export function isRecording() { - return recorder !== null; -} - -/** - * Start video recording via CDP screencast + ffmpeg. - * Frames are captured as JPEG and piped to ffmpeg for MP4 encoding. - * @param {string} outputPath — output .mp4 file path - * @param {object} [opts] - * @param {number} [opts.fps=25] — target framerate - * @param {number} [opts.quality=80] — JPEG quality (1-100) - * @param {string} [opts.ffmpegPath] — explicit path to ffmpeg binary - */ -export async function startRecording(outputPath, opts = {}) { - ensureConnected(); - if (recorder) { - if (opts.force) { - try { await stopRecording(); } catch {} - } else { - throw new Error('Already recording. Call stopRecording() first, or use { force: true }.'); - } - } - setLastCaptions([]); - setLastRecordingDuration(null); - - const fps = opts.fps || 25; - const quality = opts.quality || 80; - const ffmpegPath = resolveFfmpeg(opts.ffmpegPath); - - // Ensure output directory exists - const resolvedPath = resolveProjectPath(outputPath); - mkdirSync(dirname(resolvedPath), { recursive: true }); - - // Spawn ffmpeg process — single output file across context switches - const ffmpeg = spawn(ffmpegPath, [ - '-y', // overwrite output - '-f', 'image2pipe', // input: piped images - '-framerate', String(fps), // input framerate - '-i', '-', // read from stdin - '-c:v', 'libx264', // H.264 codec - '-preset', 'fast', // good quality/speed balance - '-crf', '23', // default quality (good for screen content) - '-vf', 'scale=in_range=full:out_range=limited', // JPEG full→H.264 limited range - '-pix_fmt', 'yuv420p', // broad compatibility - '-color_range', 'tv', // limited range (16-235) — standard for H.264 players - '-movflags', '+faststart', // web-friendly MP4 - resolvedPath - ], { stdio: ['pipe', 'ignore', 'pipe'] }); - - ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; }); - - const frameDuration = 1000 / fps; - const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait - - // Frame handler shared across CDP sessions (lives in recorder, not closure): - // when the active context switches, we attach a new CDP session and route its - // frames to the same ffmpeg pipe — preserving a single continuous timeline. - const frameHandler = async ({ data, sessionId }, cdp) => { - if (!recorder) return; - const buf = Buffer.from(data, 'base64'); - const now = Date.now(); - if (!ffmpeg.stdin.destroyed) { - let framesWritten = 0; - if (recorder.lastFrameTime && recorder.lastFrameBuf) { - const gap = now - recorder.lastFrameTime; - const dupes = Math.round(gap / frameDuration) - 1; - for (let i = 0; i < dupes && i < fps * 30; i++) { - ffmpeg.stdin.write(recorder.lastFrameBuf); - framesWritten++; - } - } - ffmpeg.stdin.write(buf); - framesWritten++; - recorder.videoTimeMs += framesWritten * frameDuration; - } - recorder.lastFrameTime = now; - recorder.lastFrameBuf = buf; - try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {} - }; - - // Duplicate the last frame to fill wall-clock gaps (static periods, context switches). - const _flushFrames = () => { - if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return; - const now = Date.now(); - const gap = now - recorder.lastFrameTime; - const dupes = Math.round(gap / frameDuration); - for (let i = 0; i < dupes; i++) { - ffmpeg.stdin.write(recorder.lastFrameBuf); - recorder.videoTimeMs += frameDuration; - } - if (dupes > 0) recorder.lastFrameTime = now; - }; - - // Attach screencast to a specific page. Stops the old CDP first (if any). - // Called by startRecording for the initial page, and by setActiveContext when - // the active context changes mid-recording. - const _attachPage = async (targetPage) => { - if (recorder.cdp) { - _flushFrames(); // freeze the last frame of the outgoing page up to "now" - try { await recorder.cdp.send('Page.stopScreencast'); } catch {} - try { await recorder.cdp.detach(); } catch {} - recorder.cdp = null; - } - const cdp = await targetPage.context().newCDPSession(targetPage); - cdp.on('Page.screencastFrame', (ev) => frameHandler(ev, cdp)); - await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 }); - recorder.cdp = cdp; - recorder.activePage = targetPage; - }; - - setRecorder({ - cdp: null, - activePage: null, - ffmpeg, - startTime: Date.now(), - outputPath: resolvedPath, - ffmpegError: '', - captions: [], - videoTimeMs: 0, - frameDuration, - lastFrameTime: null, - lastFrameBuf: null, - _flushFrames, - _attachPage, - speechRate, - }); - ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); }); - - await _attachPage(page); -} - -/** - * Stop video recording. Finalizes the MP4 file. - * @returns {{ file: string, duration: number, size: number }} - */ -export async function stopRecording() { - if (!recorder) return { file: null, duration: 0, size: 0 }; - - const { cdp, ffmpeg, startTime, outputPath } = recorder; - - // Final frame flush: write remaining frames to cover the gap since the last screencast frame - if (recorder._flushFrames) recorder._flushFrames(); - - // Stop CDP screencast - try { await cdp.send('Page.stopScreencast'); } catch {} - try { await cdp.detach(); } catch {} - - // Close ffmpeg stdin and wait for encoding to finish - await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - ffmpeg.kill('SIGKILL'); - reject(new Error('ffmpeg timed out after 30s')); - }, 30000); - - ffmpeg.on('close', (code) => { - clearTimeout(timeout); - if (code === 0) resolve(); - else reject(new Error(`ffmpeg exited with code ${code}: ${recorder?.ffmpegError || ''}`)); - }); - ffmpeg.on('error', (err) => { - clearTimeout(timeout); - reject(err); - }); - - ffmpeg.stdin.end(); - }); - - const duration = (Date.now() - startTime) / 1000; - const stats = statSync(outputPath); - - // Preserve captions for addNarration() - setLastCaptions(recorder.captions || []); - setLastRecordingDuration(duration); - if (lastCaptions.length) { - const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json'); - const captionsData = { recordingDuration: duration, videoTimestamps: true, captions: lastCaptions }; - writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8'); - } - - setRecorder(null); - - return { - file: outputPath, - duration: Math.round(duration * 10) / 10, - size: stats.size, - captions: lastCaptions.length - }; -} - -/** - * Show a text caption overlay on the page (visible in recording). - * Calling again updates the text without creating a new element. - * @param {string} text — caption text - * @param {object} [opts] - * @param {'top'|'bottom'} [opts.position='bottom'] — vertical position - * @param {number} [opts.fontSize=24] — font size in pixels - * @param {string} [opts.background='rgba(0,0,0,0.7)'] — background color - * @param {string} [opts.color='#fff'] — text color - * @param {string|false} [opts.speech] — TTS narration text. Omit to use displayed text, - * pass a string for custom narration, or false to skip narration for this caption. - */ -export async function showCaption(text, opts = {}) { - ensureConnected(); - - // Collect caption for TTS narration if recording - let smartWaitMs = 0; - if (recorder && (text.trim() || typeof opts.speech === 'string') && opts.speech !== false) { - const speech = typeof opts.speech === 'string' ? opts.speech : text; - // Use video timeline position (accounts for frame duplication) instead of wall-clock - recorder.captions.push({ text: text || speech, speech, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) }); - // Estimate TTS duration and wait so the video has enough screen time for voiceover - smartWaitMs = Math.max(2000, speech.length * (recorder.speechRate || 70)); - } - const position = opts.position || 'bottom'; - const fontSize = opts.fontSize || 24; - const bg = opts.background || 'rgba(0,0,0,0.7)'; - const color = opts.color || '#fff'; - - await page.evaluate(({ text, position, fontSize, bg, color }) => { - let el = document.getElementById('__web_test_caption'); - if (!el) { - el = document.createElement('div'); - el.id = '__web_test_caption'; - el.style.cssText = ` - position: fixed; left: 0; right: 0; z-index: 99999; - text-align: center; padding: 12px 24px; - font-family: Arial, sans-serif; pointer-events: none; - `; - document.body.appendChild(el); - } - el.style[position === 'top' ? 'top' : 'bottom'] = '20px'; - el.style[position === 'top' ? 'bottom' : 'top'] = 'auto'; - el.style.fontSize = fontSize + 'px'; - el.style.background = bg; - el.style.color = color; - el.textContent = text; - }, { text, position, fontSize, bg, color }); - - // Smart TTS wait: pause for estimated speech duration so video has enough screen time. - // Split into chunks and flush frames periodically — CDP doesn't send screencast frames - // for static pages, so we must write duplicate frames to keep video timeline in sync. - if (smartWaitMs > 0) { - let remaining = smartWaitMs; - while (remaining > 0) { - const chunk = Math.min(remaining, 1000); - await page.waitForTimeout(chunk); - remaining -= chunk; - if (recorder?._flushFrames) recorder._flushFrames(); - } - recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() }; - } -} - -/** Remove the caption overlay from the page. */ -export async function hideCaption() { - ensureConnected(); - await page.evaluate(() => { - const el = document.getElementById('__web_test_caption'); - if (el) el.remove(); - }); -} - -/** - * Get captions collected during the current or last recording. - * @returns {Array<{text: string, speech: string, time: number}>} - */ -export function getCaptions() { - if (recorder) return [...recorder.captions]; - return [...lastCaptions]; -} - -/** - * Add TTS narration to a recorded video. - * Generates speech from captions and merges audio with the video. - * @param {string} videoPath — path to the recorded MP4 file - * @param {object} [opts] - * @param {Array<{text: string, speech: string, time: number, voice?: string}>} [opts.captions] — explicit captions (default: from last recording or .captions.json). Each caption may include a `voice` field to override the global voice for that segment - * @param {string} [opts.provider='edge'] — TTS provider: 'edge' or 'openai' - * @param {string} [opts.voice] — voice name (provider-specific) - * @param {string} [opts.apiKey] — API key (for openai provider) - * @param {string} [opts.apiUrl] — API endpoint (for openai provider) - * @param {string} [opts.model] — model name (for openai provider, default: 'tts-1') - * @param {string} [opts.ffmpegPath] — path to ffmpeg binary - * @param {string} [opts.outputPath] — output file path (default: video-narrated.mp4) - * @returns {{ file: string, duration: number, size: number, captions: number, warnings?: string[] }} - */ -export async function addNarration(videoPath, opts = {}) { - if (!videoPath) return { file: null, duration: 0, size: 0, captions: 0 }; - videoPath = resolveProjectPath(videoPath); - const ffmpegPath = resolveFfmpeg(opts.ffmpegPath); - const ttsProvider = getTtsProvider(opts.provider || 'edge'); - const ttsOpts = { voice: opts.voice, apiKey: opts.apiKey, apiUrl: opts.apiUrl, model: opts.model }; - - // Resolve captions: explicit > lastCaptions > .captions.json - let captions = opts.captions; - let videoTimestamps = true; // new recordings use video-time timestamps (no scaling needed) - let recordingDuration = null; // wall-clock duration (for legacy scaling fallback) - if (!captions || !captions.length) { - if (lastCaptions.length) { - captions = [...lastCaptions]; - recordingDuration = lastRecordingDuration; - // Runtime captions always use video timestamps (set in showCaption) - } - } - if (!captions || !captions.length) { - const captionsJsonPath = videoPath.replace(/\.[^.]+$/, '.captions.json'); - if (fsExistsSync(captionsJsonPath)) { - const raw = JSON.parse(readFileSync(captionsJsonPath, 'utf-8')); - // Support formats: array (old), { recordingDuration, captions } (v2), { videoTimestamps, captions } (v3) - if (Array.isArray(raw)) { - captions = raw; - videoTimestamps = false; - } else { - captions = raw.captions; - videoTimestamps = !!raw.videoTimestamps; - recordingDuration = raw.recordingDuration || null; - } - } - } - if (!captions || !captions.length) { - throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.'); - } - - const videoDuration = getAudioDuration(videoPath, ffmpegPath); - - // Legacy fallback: scale wall-clock timestamps to video duration - // (only for old captions without videoTimestamps flag) - if (!videoTimestamps && recordingDuration && recordingDuration > 0) { - const timeScale = videoDuration / recordingDuration; - if (Math.abs(timeScale - 1) > 0.005) { - captions = captions.map(c => ({ ...c, time: Math.round(c.time * timeScale) })); - } - } - - // Output path - const ext = extname(videoPath); - const base = videoPath.slice(0, -ext.length); - const outputPath = opts.outputPath || `${base}-narrated${ext}`; - - // Temp directory - const tempDir = pathJoin(tmpdir(), `web-test-tts-${Date.now()}`); - mkdirSync(tempDir, { recursive: true }); - - const warnings = []; - - try { - // Phase 1: Generate TTS audio for each caption - const ttsFiles = []; - const BATCH_SIZE = (opts.provider === 'elevenlabs') ? 2 : 5; - for (let batchStart = 0; batchStart < captions.length; batchStart += BATCH_SIZE) { - const batch = captions.slice(batchStart, batchStart + BATCH_SIZE); - const promises = batch.map(async (cap, batchIdx) => { - const idx = batchStart + batchIdx; - const ttsFile = pathJoin(tempDir, `tts_${idx}.mp3`); - const capTtsOpts = cap.voice ? { ...ttsOpts, voice: cap.voice } : ttsOpts; - try { - await ttsProvider(cap.speech, ttsFile, capTtsOpts); - } catch (err) { - // Retry once - try { - await ttsProvider(cap.speech, ttsFile, capTtsOpts); - } catch (retryErr) { - warnings.push(`TTS failed for caption ${idx}: ${retryErr.message || retryErr.cause?.message || String(retryErr)}`); - // Generate 1s silence as placeholder - generateSilence(ttsFile, 1, ffmpegPath); - } - } - return ttsFile; - }); - const results = await Promise.all(promises); - ttsFiles.push(...results); - } - - // Phase 2+3: Place each TTS at its exact timestamp using adelay + amix - // This avoids MP3 frame quantization drift from silence-file concatenation - const ffmpegInputs = []; - const filterParts = []; - const mixLabels = []; - - for (let i = 0; i < captions.length; i++) { - const captionTimeMs = Math.round(captions[i].time); - const ttsFile = ttsFiles[i]; - const ttsDuration = getAudioDuration(ttsFile, ffmpegPath); - - ffmpegInputs.push('-i', ttsFile); - const filters = []; - - // Speed up TTS slightly if it's longer than gap to next caption (max 1.3x) - if (i < captions.length - 1) { - const maxDuration = (captions[i + 1].time - captions[i].time) / 1000; - if (ttsDuration > maxDuration && maxDuration > 0.1) { - const tempo = ttsDuration / maxDuration; - if (tempo <= 1.3) { - filters.push(`atempo=${tempo.toFixed(4)}`); - } else { - // Too fast — let audio overlap instead of distorting - warnings.push(`Caption ${i + 1}/${captions.length}: TTS ${ttsDuration.toFixed(1)}s > gap ${maxDuration.toFixed(1)}s (need ${Math.round(ttsDuration - maxDuration)}s more pause)`); - } - } - } - - // Delay to exact caption timestamp (milliseconds) - if (captionTimeMs > 0) { - filters.push(`adelay=${captionTimeMs}|${captionTimeMs}`); - } - - const label = `a${i}`; - mixLabels.push(`[${label}]`); - // Input indices are shifted by 1 because silence reference is input [0] - filterParts.push(`[${i + 1}]${filters.length ? filters.join(',') : 'acopy'}[${label}]`); - } - - // Generate a silence reference track as input [0] so amix runs for full video duration - const silencePath = pathJoin(tempDir, 'silence.mp3'); - generateSilence(silencePath, Math.ceil(videoDuration), ffmpegPath); - - const filterComplex = filterParts.join(';') + ';' + - `[0]${mixLabels.join('')}amix=inputs=${captions.length + 1}:normalize=0:duration=first`; - - const narrationPath = pathJoin(tempDir, 'narration.mp3'); - execFileSync(ffmpegPath, [ - '-y', '-i', silencePath, ...ffmpegInputs, - '-filter_complex', filterComplex, - '-t', String(Math.ceil(videoDuration)), - '-c:a', 'libmp3lame', '-b:a', '128k', narrationPath, - ], { stdio: 'pipe', timeout: 120000 }); - - // Phase 4: Merge video + narration audio - execFileSync(ffmpegPath, [ - '-y', '-i', videoPath, '-i', narrationPath, - '-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k', - '-map', '0:v:0', '-map', '1:a:0', - '-t', String(Math.ceil(videoDuration)), - '-movflags', '+faststart', outputPath, - ], { stdio: 'pipe', timeout: 120000 }); - - const stats = statSync(outputPath); - const duration = getAudioDuration(outputPath, ffmpegPath); - - const result = { - file: outputPath, - duration: Math.round(duration * 10) / 10, - size: stats.size, - captions: captions.length, - }; - if (warnings.length) result.warnings = warnings; - return result; - - } finally { - // Cleanup temp directory - try { rmSync(tempDir, { recursive: true, force: true }); } catch {} - } -} - -/** - * Show a full-screen title slide overlay (for video recordings). - * Repeated calls update the content. Use hideTitleSlide() to remove. - * @param {string} text Title text (\n → line break) - * @param {object} [opts] - * @param {string} [opts.subtitle] Smaller text below the title - * @param {string} [opts.background] CSS background (default: dark gradient) - * @param {string} [opts.color] Text color (default: '#fff') - * @param {number} [opts.fontSize] Title font size in px (default: 36) - */ -export async function showTitleSlide(text, opts = {}) { - ensureConnected(); - const { - subtitle = '', - background = 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)', - color = '#fff', - fontSize = 36, - speech, - } = opts; - - // Collect caption for TTS narration if recording - let smartWaitMs = 0; - if (recorder && speech && speech !== false) { - const captionText = typeof speech === 'string' ? speech : text.replace(/\n/g, ' '); - if (captionText) { - recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) }); - smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70)); - } - } - - await page.evaluate(({ text, subtitle, background, color, fontSize }) => { - let div = document.getElementById('__web_test_title'); - if (!div) { - div = document.createElement('div'); - div.id = '__web_test_title'; - document.body.appendChild(div); - } - div.style.cssText = [ - 'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%', - `background:${background}`, - 'display:flex', 'align-items:center', 'justify-content:center', - 'z-index:999999', 'pointer-events:none', - ].join(';'); - // Remove other overlays to prevent flash between slides - const img = document.getElementById('__web_test_image'); - if (img) img.remove(); - const esc = s => s.replace(/&/g, '&').replace(/'); - let html = `
${esc(text)}
`; - if (subtitle) { - html += `
${esc(subtitle)}
`; - } - div.innerHTML = `
${html}
`; - }, { text, subtitle, background, color, fontSize }); - - // Smart TTS wait (same pattern as showCaption/showImage) - if (smartWaitMs > 0) { - let remaining = smartWaitMs; - while (remaining > 0) { - const chunk = Math.min(remaining, 1000); - await page.waitForTimeout(chunk); - remaining -= chunk; - if (recorder?._flushFrames) recorder._flushFrames(); - } - recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() }; - } -} - -/** Remove the title slide overlay. */ -export async function hideTitleSlide() { - ensureConnected(); - await page.evaluate(() => { - const el = document.getElementById('__web_test_title'); - if (el) el.remove(); - }); -} - -/** - * Show a full-screen image overlay (e.g. presentation slide screenshot). - * Reads the image file, base64-encodes it, and renders as a fixed overlay - * on the page — captured by CDP screencast automatically. - * - * Style presets: - * - 'blur' (default) — blurred+dimmed copy as background, image centered with shadow - * - 'dark' — dark background (#2a2a2a) with shadow - * - 'light' — white background with shadow - * - 'full' — image covers entire screen, no padding/shadow - * - * Custom background overrides the preset (e.g. background: '#003366'). - * - * @param {string} imagePath — path to the image file (PNG, JPG, etc.) - * @param {object} [opts] - * @param {'blur'|'dark'|'light'|'full'} [opts.style='blur'] — display style preset - * @param {string} [opts.background] — custom background color/gradient (overrides style preset) - * @param {boolean} [opts.shadow] — show drop shadow (default: true for blur/dark/light, false for full) - * @param {string|false} [opts.speech] — TTS narration text while image is shown. - * Pass a string for narration, or false to skip. Omit to skip (no auto-text for images). - */ -export async function showImage(imagePath, opts = {}) { - ensureConnected(); - const style = opts.style || 'blur'; - const speech = opts.speech; - - // Style presets - const presets = { - blur: { bg: '#222', fit: 'contain', shadow: true, blur: true }, - dark: { bg: '#2a2a2a', fit: 'contain', shadow: true, blur: false }, - light: { bg: '#ffffff', fit: 'contain', shadow: true, blur: false }, - full: { bg: '#000', fit: 'contain', shadow: false, blur: false }, - }; - const preset = presets[style] || presets.blur; - - const bg = opts.background || preset.bg; - const fit = preset.fit; - const shadow = opts.shadow !== undefined ? opts.shadow : preset.shadow; - const useBlur = opts.background ? false : preset.blur; - - // Read image and base64-encode - const absPath = resolveProjectPath(imagePath); - if (!fsExistsSync(absPath)) { - throw new Error(`showImage: file not found: ${absPath}`); - } - const buf = readFileSync(absPath); - const ext = extname(absPath).toLowerCase().replace('.', ''); - const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' - : ext === 'png' ? 'image/png' - : ext === 'gif' ? 'image/gif' - : ext === 'webp' ? 'image/webp' - : ext === 'svg' ? 'image/svg+xml' - : 'image/png'; - const dataUrl = `data:${mime};base64,${buf.toString('base64')}`; - - // Collect caption for TTS narration if recording - let smartWaitMs = 0; - if (recorder && speech && speech !== false) { - const captionText = typeof speech === 'string' ? speech : ''; - if (captionText) { - recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) }); - smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70)); - } - } - - // Padding: full style uses 100%, others use 92% for breathing room - const isFull = style === 'full'; - const maxSize = isFull ? '100%' : '92%'; - - await page.evaluate(({ dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }) => { - let div = document.getElementById('__web_test_image'); - if (!div) { - div = document.createElement('div'); - div.id = '__web_test_image'; - document.body.appendChild(div); - } - // Remove other overlays to prevent flash between slides - const title = document.getElementById('__web_test_title'); - if (title) title.remove(); - - div.style.cssText = [ - 'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%', - `background:${bg}`, - 'display:flex', 'align-items:center', 'justify-content:center', - 'z-index:999999', 'pointer-events:none', 'overflow:hidden' - ].join(';'); - - let html = ''; - - // Blurred background layer: the same image stretched to cover, blurred and dimmed - if (useBlur) { - html += ``; - } - - // Main image - const shadowCss = shadow ? 'box-shadow:0 4px 40px rgba(0,0,0,0.5);' : ''; - const sizeCss = isFull - ? `width:100%;height:100%;object-fit:${fit};` - : `max-width:${maxSize};max-height:${maxSize};min-width:50%;min-height:50%;object-fit:${fit};`; - html += ``; - - div.innerHTML = html; - }, { dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }); - - // Smart TTS wait (same pattern as showCaption) - if (smartWaitMs > 0) { - let remaining = smartWaitMs; - while (remaining > 0) { - const chunk = Math.min(remaining, 1000); - await page.waitForTimeout(chunk); - remaining -= chunk; - if (recorder?._flushFrames) recorder._flushFrames(); - } - recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() }; - } -} - -/** Remove the image overlay. */ -export async function hideImage() { - ensureConnected(); - await page.evaluate(() => { - const el = document.getElementById('__web_test_image'); - if (el) el.remove(); - }); -} - -/** - * Highlight an element on the page (visual accent for video recordings). - * Uses overlay div for visibility (not clipped by overflow:hidden), with - * requestAnimationFrame tracking so it follows layout shifts (async banners etc). - * @param {string} text Element text/label (fuzzy match, same as clickElement/fillFields) - * @param {object} [opts] - * @param {string} [opts.color] Outline color (default: '#e74c3c') - * @param {number} [opts.padding] Extra padding around element (default: 4) - */ -export async function highlight(text, opts = {}) { - ensureConnected(); - const { color = '#e74c3c', padding = 4, table } = opts; - - // Remove previous highlight first - await unhighlight(); - - let elId = null; - - // 0. Open submenu/popup — highest priority (submenu overlays the form, - // so form search would match grid rows behind the popup) - const popupItems = await page.evaluate(readSubmenuScript()); - if (Array.isArray(popupItems) && popupItems.length > 0) { - const target = normYo(text.toLowerCase()); - let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target); - if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).startsWith(target)); - if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target)); - if (found) { - // 1C duplicates IDs in clouds — getElementById returns the hidden copy. - // Use elementFromPoint to find the visible element and get its actual rect. - await page.evaluate(({ x, y, color, padding }) => { - const el = document.elementFromPoint(x, y); - if (!el) return; - const block = el.closest('.submenuBlock') || el.closest('a.press') || el; - const r = block.getBoundingClientRect(); - let div = document.getElementById('__web_test_highlight'); - if (!div) { - div = document.createElement('div'); - div.id = '__web_test_highlight'; - document.body.appendChild(div); - } - div.style.cssText = [ - 'position:fixed', 'pointer-events:none', 'z-index:999998', - `top:${r.y - padding}px`, `left:${r.x - padding}px`, - `width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`, - `outline:3px solid ${color}`, 'border-radius:4px', - `box-shadow:0 0 16px ${color}80`, - ].join(';'); - }, { x: found.x, y: found.y, color, padding }); - return; // overlay placed, done - } - } - - // 1. Visible commands on the function panel (cmd_XXX_txt elements) - // Must be checked BEFORE form search: when the section content panel - // is showing, the form behind it is hidden but detectFormScript still - // finds it, and form buttons match before commands. - if (!elId) { - elId = await page.evaluate(`(() => { - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); - const target = ${JSON.stringify(normYo(text.toLowerCase()))}; - const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0); - if (cmds.length === 0) return null; - let el = cmds.find(e => norm(e.innerText).toLowerCase() === target); - if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().startsWith(target)); - if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().includes(target)); - return el ? el.id : null; - })()`); - } - - // 1b. Command group headers on the function panel (eAccentColor labels). - // Match header text, then highlight the header + commands below it - // until the next spacer/header/end. - if (!elId) { - const groupDone = await page.evaluate(({ target, color, padding }) => { - const container = document.querySelector('#funcPanel_container'); - if (!container) return false; - const norm = s => (s?.trim().replace(/\u00a0/g, ' ') || '').replace(/ё/gi, 'е').toLowerCase(); - const headers = [...container.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0); - if (!headers.length) return false; - - let headerEl = headers.find(h => norm(h.textContent) === target); - if (!headerEl) headerEl = headers.find(h => norm(h.textContent).startsWith(target)); - if (!headerEl) headerEl = headers.find(h => norm(h.textContent).includes(target)); - if (!headerEl) return false; - - // Collect header + following cmd siblings until next spacer/header - const parent = headerEl.parentElement; - const children = [...parent.children]; - const startIdx = children.indexOf(headerEl); - const groupEls = [headerEl]; - for (let i = startIdx + 1; i < children.length; i++) { - const el = children[i]; - if (el.classList.contains('eAccentColor')) break; - if (!el.id && !el.classList.contains('functionItem') && el.getBoundingClientRect().width < 10) break; - groupEls.push(el); - } - - // Bounding box - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; - for (const el of groupEls) { - const r = el.getBoundingClientRect(); - if (r.width === 0 && r.height === 0) continue; - minX = Math.min(minX, r.left); minY = Math.min(minY, r.top); - maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom); - } - if (minX === Infinity) return false; - - let div = document.getElementById('__web_test_highlight'); - if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); } - div.style.cssText = [ - 'position:fixed', 'pointer-events:none', 'z-index:999998', - `top:${minY - padding}px`, `left:${minX - padding}px`, - `width:${maxX - minX + padding * 2}px`, `height:${maxY - minY + padding * 2}px`, - `outline:3px solid ${color}`, 'border-radius:4px', - `box-shadow:0 0 16px ${color}80`, - ].join(';'); - return true; - }, { target: normYo(text.toLowerCase()), color, padding }); - if (groupDone) return; - } - - // 2. Form groups/panels — checked BEFORE buttons/fields because group names - // often collide with command bar buttons (e.g. "БизнесПроцессы" is both a - // panel and a command bar element). Includes _container and _div elements - // but skips logicGroupContainer (Representation=None, height=0). - if (!elId) { - const formNum = await page.evaluate(detectFormScript()); - if (formNum !== null) { - elId = await page.evaluate(`(() => { - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); - const target = ${JSON.stringify(normYo(text.toLowerCase()))}; - const p = 'form' + ${formNum} + '_'; - // Group containers: _container or _div, but skip logicGroupContainer (invisible groups) - const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')] - .filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && !el.classList.contains('logicGroupContainer')); - const items = groups.map(el => { - const idName = el.id.replace(p, '').replace(/_(container|div)$/, ''); - const titleEl = document.getElementById(p + idName + '#title_text') - || document.getElementById(p + idName + '_title_text'); - const label = norm(titleEl?.innerText || '').toLowerCase(); - const name = norm(idName).toLowerCase(); - const big = el.offsetWidth >= 100 && el.offsetHeight >= 50; - return { id: el.id, name, label, big }; - }); - let found = items.find(i => i.label === target); - if (!found) found = items.find(i => i.name === target); - // Fuzzy match: only large groups (min 100x50) to avoid matching command bars - if (!found) found = items.filter(i => i.big).find(i => i.label.startsWith(target) || i.name.startsWith(target)); - if (!found && target.length >= 4) found = items.filter(i => i.big).find(i => i.label.includes(target) || i.name.includes(target)); - return found ? found.id : null; - })()`); - } - } - - // 3. Form-scoped search (buttons, links, fields, grid rows) - if (!elId) { - const formNum = await page.evaluate(detectFormScript()); - if (formNum !== null) { - // 3a. Try button/link/tab/gridRow via findClickTargetScript - let gridSelector; - if (table) { - const resolved = await page.evaluate(resolveGridScript(formNum, table)); - if (!resolved.error) gridSelector = resolved.gridSelector; - } - const target = await page.evaluate(findClickTargetScript(formNum, text, table ? { tableName: table, gridSelector } : undefined)); - if (target && !target.error) { - if (target.id) { - elId = target.id; - } else if (target.x && target.y) { - // Grid row — find the gridLine element and tag it - elId = await page.evaluate(`(() => { - const p = ${JSON.stringify(`form${formNum}_`)}; - const grid = document.querySelector('[id^="' + p + '"].grid'); - if (!grid) return null; - const body = grid.querySelector('.gridBody'); - if (!body) return null; - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); - const target = ${JSON.stringify(normYo(text.toLowerCase()))}; - for (const line of body.querySelectorAll('.gridLine')) { - const cells = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0); - const rowText = cells.map(b => b.innerText?.trim() || '').join(' ').toLowerCase().replace(/ё/gi, 'е'); - if (rowText.includes(target)) { - if (!line.id) line.id = '__wt_hl_tmp'; - return line.id; - } - } - return null; - })()`); - } - } - - // 3b. If not found as button — try as field via resolveFieldsScript - if (!elId) { - const dummyFields = { [text]: '' }; - const resolved = await page.evaluate(resolveFieldsScript(formNum, dummyFields)); - if (resolved?.length > 0 && !resolved[0].error && resolved[0].inputId) { - elId = resolved[0].inputId; - } - } - } - } - - // 4. Fallback: sections (sidebar navigation) - if (!elId) { - elId = await page.evaluate(`(() => { - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); - const target = ${JSON.stringify(normYo(text.toLowerCase()))}; - const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')]; - let el = secs.find(e => norm(e.innerText).toLowerCase() === target); - if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().startsWith(target)); - if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().includes(target)); - return el ? el.id : null; - })()`); - } - - if (!elId) { - // Collect available elements to help the caller fix the name - const available = await page.evaluate(`(() => { - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); - const result = {}; - // Commands - const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0).map(e => norm(e.innerText)); - if (cmds.length) result.commands = cmds; - // Command group headers - const fp = document.querySelector('#funcPanel_container'); - if (fp) { - const gh = [...fp.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0).map(e => norm(e.textContent)); - if (gh.length) result.commandGroups = gh; - } - // Sections - const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')].map(e => norm(e.innerText)).filter(Boolean); - if (secs.length) result.sections = secs; - // Form elements - ${(() => { - // Detect form inline to avoid extra evaluate round-trip - return ` - const forms = {}; - document.querySelectorAll('[id^="form"]').forEach(el => { - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = (forms[m[1]] || 0) + 1; - }); - let formNum = null, maxCount = 0; - for (const [n, c] of Object.entries(forms)) { - if (parseInt(n) > 0 && c > maxCount) { maxCount = c; formNum = n; } - } - if (formNum !== null) { - const p = 'form' + formNum + '_'; - // Groups (_container or _div, skip logicGroupContainer, min 100x50) - const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')] - .filter(el => el.offsetWidth >= 100 && el.offsetHeight >= 50 && !el.classList.contains('logicGroupContainer')) - .map(el => { - const idName = el.id.replace(p, '').replace(/_(container|div)$/, ''); - const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '_title_text'); - return norm(titleEl?.innerText || '') || idName; - }).filter(Boolean); - if (groups.length) result.groups = groups; - // Buttons/links - const btns = [...document.querySelectorAll('[id^="' + p + '"].btnText, [id^="' + p + '"] .btnText, [id^="' + p + '"].hplnk')] - .filter(el => el.offsetWidth > 0).map(el => norm(el.innerText)).filter(Boolean); - if (btns.length) result.buttons = [...new Set(btns)]; - }`; - })()} - return result; - })()`); - const parts = []; - for (const [cat, items] of Object.entries(available)) { - parts.push(` ${cat}: ${items.join(', ')}`); - } - const hint = parts.length ? `\nAvailable:\n${parts.join('\n')}` : ''; - throw new Error(`highlight: "${text}" not found${hint}`); - } - - // Overlay div + rAF tracking loop (not clipped by overflow:hidden, follows layout shifts) - await page.evaluate(({ elId, color, padding }) => { - const target = document.getElementById(elId); - 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); - } - function sync() { - const r = target.getBoundingClientRect(); - div.style.cssText = [ - 'position:fixed', 'pointer-events:none', 'z-index:999998', - `top:${r.y - padding}px`, `left:${r.x - padding}px`, - `width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`, - `outline:3px solid ${color}`, 'border-radius:4px', - `box-shadow:0 0 16px ${color}80`, - ].join(';'); - } - sync(); - // Track position changes via rAF - function tick() { - if (!document.getElementById('__web_test_highlight')) return; // stopped - sync(); - requestAnimationFrame(tick); - } - requestAnimationFrame(tick); - }, { elId, color, padding }); -} - -/** Remove the highlight overlay. */ -export async function unhighlight() { - ensureConnected(); - await page.evaluate(() => { - const el = document.getElementById('__web_test_highlight'); - if (el) el.remove(); // also stops rAF loop (id check) - // Clean up temp ID from grid rows - const tmp = document.getElementById('__wt_hl_tmp'); - if (tmp) tmp.removeAttribute('id'); - }); -} - -/** - * Toggle auto-highlight mode. When enabled, clickElement/fillFields/selectValue - * automatically highlight the target element before acting. - * @param {boolean} on true to enable, false to disable - */ -export function setHighlight(on) { - setHighlightMode(!!on); -} - -/** @returns {boolean} Whether auto-highlight mode is active. */ -export function isHighlightMode() { - return highlightMode; -} - -// ============================================================ -// Private helpers -// ============================================================ - -/** Resolve ffmpeg binary path. */ -function resolveFfmpeg(explicit) { - // 1. Explicit path - if (explicit) { - try { execFileSync(explicit, ['-version'], { stdio: 'ignore', timeout: 5000 }); return explicit; } - catch { throw new Error(`ffmpeg not found at: ${explicit}`); } - } - - // 2. FFMPEG_PATH env var - const envPath = process.env.FFMPEG_PATH; - if (envPath) { - try { execFileSync(envPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return envPath; } - catch { /* fall through */ } - } - - // 3. System PATH - try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 5000 }); return 'ffmpeg'; } - catch { /* fall through */ } - - // 4. tools/ffmpeg/bin/ffmpeg.exe relative to project root - const localPath = pathResolve(projectRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe'); - if (fsExistsSync(localPath)) { - try { execFileSync(localPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return localPath; } - catch { /* fall through */ } - } - - // 5. Error with instructions - throw new Error( - 'ffmpeg not found. Install it:\n' + - ' - Download from https://www.gyan.dev/ffmpeg/builds/ (essentials build)\n' + - ' - Add to PATH, or set FFMPEG_PATH env var, or place in tools/ffmpeg/bin/\n' + - ' - Or pass ffmpegPath option to startRecording()' - ); -} - -// ── TTS providers ────────────────────────────────────────────────────────── - -/** Resolve node-edge-tts module: global install → tools/tts/ → error with instructions. */ -let _edgeTtsModule = null; -async function resolveEdgeTts() { - if (_edgeTtsModule) return _edgeTtsModule; - - // 1. Global/project-level install (standard Node resolution) - try { - _edgeTtsModule = await import('node-edge-tts'); - return _edgeTtsModule; - } catch { /* fall through */ } - - // 2. tools/tts/ relative to project root - const localPath = pathResolve(projectRoot, 'tools', 'tts', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js'); - if (fsExistsSync(localPath)) { - try { - _edgeTtsModule = await import(pathToFileURL(localPath).href); - return _edgeTtsModule; - } catch { /* fall through */ } - } - - // 3. Error with instructions - throw new Error( - 'node-edge-tts not found. Install it:\n' + - ' - npm install --prefix tools/tts node-edge-tts\n' + - ' - or: npm install node-edge-tts (global/project-level)' - ); -} - -/** - * Edge TTS provider (free, no API key). Uses node-edge-tts package. - * @param {string} text — text to synthesize - * @param {string} outputPath — path for the output mp3 file - * @param {object} opts — { voice } - */ -async function edgeTtsProvider(text, outputPath, opts = {}) { - const { EdgeTTS } = await resolveEdgeTts(); - const voice = opts.voice || 'ru-RU-DmitryNeural'; - const tts = new EdgeTTS({ voice }); - await Promise.race([ - tts.ttsPromise(text, outputPath), - new Promise((_, reject) => setTimeout(() => reject(new Error('Edge TTS timeout (30s)')), 30000)), - ]); -} - -/** - * OpenAI-compatible TTS provider. Requires apiKey. - * @param {string} text — text to synthesize - * @param {string} outputPath — path for the output mp3 file - * @param {object} opts — { apiKey, apiUrl, voice, model } - */ -async function openaiTtsProvider(text, outputPath, opts = {}) { - const apiUrl = opts.apiUrl || 'https://api.openai.com/v1/audio/speech'; - if (!opts.apiKey) throw new Error('OpenAI TTS requires apiKey'); - const resp = await fetch(apiUrl, { - method: 'POST', - headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: opts.model || 'tts-1', - input: text, - voice: opts.voice || 'alloy', - response_format: 'mp3', - }), - }); - if (!resp.ok) throw new Error(`OpenAI TTS error ${resp.status}: ${await resp.text()}`); - const buf = Buffer.from(await resp.arrayBuffer()); - writeFileSync(outputPath, buf); -} - -/** - * ElevenLabs TTS provider. Requires apiKey. - * @param {string} text — text to synthesize - * @param {string} outputPath — path for the output mp3 file - * @param {object} opts — { apiKey, apiUrl, voice, model } - */ -async function elevenlabsTtsProvider(text, outputPath, opts = {}) { - const voiceId = opts.voice || 'JBFqnCBsd6RMkjVDRZzb'; // George - const apiUrl = opts.apiUrl || `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`; - if (!opts.apiKey) throw new Error('ElevenLabs TTS requires apiKey'); - const resp = await fetch(apiUrl, { - method: 'POST', - headers: { 'xi-api-key': opts.apiKey, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - text, - model_id: opts.model || 'eleven_multilingual_v2', - }), - }); - if (!resp.ok) throw new Error(`ElevenLabs TTS error ${resp.status}: ${await resp.text()}`); - const buf = Buffer.from(await resp.arrayBuffer()); - writeFileSync(outputPath, buf); -} - -/** Get TTS provider function by name. */ -function getTtsProvider(name) { - switch (name) { - case 'openai': return openaiTtsProvider; - case 'elevenlabs': return elevenlabsTtsProvider; - case 'edge': default: return edgeTtsProvider; - } -} - -// ── TTS audio helpers ────────────────────────────────────────────────────── - -/** - * Get audio duration in seconds using ffprobe. - * @param {string} filePath — path to audio file - * @param {string} ffmpegPath — path to ffmpeg binary (ffprobe is found next to it) - * @returns {number} duration in seconds - */ -function getAudioDuration(filePath, ffmpegPath) { - const ffprobePath = ffmpegPath.replace(/ffmpeg(\.exe)?$/i, 'ffprobe$1'); - const out = execFileSync(ffprobePath, [ - '-v', 'error', '-show_entries', 'format=duration', - '-of', 'default=noprint_wrappers=1:nokey=1', filePath, - ], { encoding: 'utf8', timeout: 10000 }).trim(); - return parseFloat(out) || 0; -} - -/** - * Generate a silence mp3 file of given duration. - * @param {string} outputPath — path for the output mp3 file - * @param {number} seconds — duration in seconds - * @param {string} ffmpegPath — path to ffmpeg binary - */ -function generateSilence(outputPath, seconds, ffmpegPath) { - execFileSync(ffmpegPath, [ - '-y', '-f', 'lavfi', '-i', `anullsrc=r=24000:cl=mono`, - '-t', String(seconds), '-c:a', 'libmp3lame', '-b:a', '32k', outputPath, - ], { stdio: 'pipe', timeout: 10000 }); -} +export { + screenshot, wait, isRecording, startRecording, stopRecording, +} from './recording/capture.mjs'; +export { + showCaption, hideCaption, getCaptions, + showTitleSlide, hideTitleSlide, + showImage, hideImage, +} from './recording/captions.mjs'; +export { + highlight, unhighlight, setHighlight, isHighlightMode, +} from './recording/highlight.mjs'; +export { addNarration } from './recording/narration.mjs'; /* ensureConnected moved to core/state.mjs */ diff --git a/.claude/skills/web-test/scripts/core/state.mjs b/.claude/skills/web-test/scripts/core/state.mjs index 62dba485..395a58ad 100644 --- a/.claude/skills/web-test/scripts/core/state.mjs +++ b/.claude/skills/web-test/scripts/core/state.mjs @@ -1,113 +1,113 @@ -// web-test core/state v1.16 — module-level state for the web-test engine. -// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills -// -// Holds the single browser/page/recorder slot plus the multi-context registry, -// constants, and small state-only utilities (ensureConnected, getPage, -// resolveProjectPath, normYo). Mutable values are exported as `let` bindings -// for live read access from consumer modules; writes go through setters so -// imported bindings stay read-only at the import site. - -import { dirname, resolve as pathResolve } from 'path'; -import { fileURLToPath } from 'url'; - -// Project root: 4 levels up from .claude/skills/web-test/scripts/core/state.mjs -const __fn_state = fileURLToPath(import.meta.url); -export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..'); - -/** Resolve a user-provided path relative to the project root (not cwd). */ -export const resolveProjectPath = (p) => pathResolve(projectRoot, p); - -// ────────────────────────────────────────────────────────────────────────── -// Mutable single-session state. Importers read via the live binding; writes -// must go through the corresponding setter (ESM imports are read-only). -// ────────────────────────────────────────────────────────────────────────── - -export let browser = null; -export let page = null; -export let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU" -export let seanceId = null; -export let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions } -export let lastCaptions = []; // captions from the last completed recording (for addNarration) -export let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds) -export let highlightMode = false; -export let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect - -// Clipboard preservation: save full clipboard contents (all MIME types) right -// before each writeText+Ctrl+V pair, restore right after. Toggled via -// setPreserveClipboard() from run.mjs. -export let preserveClipboard = true; -export let clipboardWarnLogged = false; - -export const setBrowser = (v) => { browser = v; }; -export const setPage = (v) => { page = v; }; -export const setSessionPrefix = (v) => { sessionPrefix = v; }; -export const setSeanceId = (v) => { seanceId = v; }; -export const setRecorder = (v) => { recorder = v; }; -export const setLastCaptions = (v) => { lastCaptions = v; }; -export const setLastRecordingDuration = (v) => { lastRecordingDuration = v; }; -export const setHighlightMode = (v) => { highlightMode = !!v; }; -export const setPersistentUserDataDir = (v) => { persistentUserDataDir = v; }; -export const setPreserveClipboard = (v) => { preserveClipboard = !!v; }; -export const setClipboardWarnLogged = (v) => { clipboardWarnLogged = !!v; }; - -// ────────────────────────────────────────────────────────────────────────── -// Multi-context registry: name → { context, page, sessionPrefix, seanceId, -// recorder, lastCaptions, lastRecordingDuration, highlightMode }. -// Populated by createContext(); module-level vars above mirror the active -// slot. connect() does NOT use this Map — it preserves legacy single-session -// behavior for exec/run/start. -// ────────────────────────────────────────────────────────────────────────── - -export const contexts = new Map(); -export let activeContextName = null; -// Isolation mode for the current cmdTest session — set by the first -// createContext call. 'tab': all contexts share one persistent context -// (one window, multiple tabs, extension loads reliably). 'window': each -// context gets its own BrowserContext (separate window per context, full -// cookie isolation, extension may not load). -export let activeMode = null; - -export const setActiveContextName = (v) => { activeContextName = v; }; -export const setActiveMode = (v) => { activeMode = v; }; - -// ────────────────────────────────────────────────────────────────────────── -// Constants. -// ────────────────────────────────────────────────────────────────────────── - -export const LOAD_TIMEOUT = 60000; -export const INIT_TIMEOUT = 60000; -export const ACTION_WAIT = 2000; // fallback minimum wait -export const MAX_WAIT = 10000; // max wait for stability -export const POLL_INTERVAL = 200; // polling interval -export const STABLE_CYCLES = 3; // consecutive stable cycles needed - -// 1C browser extension ID (stable across versions, defined by key in manifest.json) -export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik'; - -// ────────────────────────────────────────────────────────────────────────── -// Utilities that only depend on state. -// ────────────────────────────────────────────────────────────────────────── - -/** Normalize ё→е and  →space for fuzzy matching. */ -export const normYo = (s) => s.replace(/ё/gi, 'е').replace(/ /g, ' '); - -/** Check if browser is connected and page is usable. */ -export function isConnected() { - if (!browser || !page || page.isClosed()) return false; - // launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser - if (typeof browser.isConnected === 'function') return browser.isConnected(); - // For persistent context, check via context's browser() - return browser.browser()?.isConnected() ?? false; -} - -export function ensureConnected() { - if (!isConnected()) { - throw new Error('Browser not connected. Call web_connect first.'); - } -} - -/** Get the raw Playwright page object (for advanced scripting in skill mode). */ -export function getPage() { - ensureConnected(); - return page; -} +// web-test core/state v1.16 — module-level state for the web-test engine. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Holds the single browser/page/recorder slot plus the multi-context registry, +// constants, and small state-only utilities (ensureConnected, getPage, +// resolveProjectPath, normYo). Mutable values are exported as `let` bindings +// for live read access from consumer modules; writes go through setters so +// imported bindings stay read-only at the import site. + +import { dirname, resolve as pathResolve } from 'path'; +import { fileURLToPath } from 'url'; + +// Project root: 4 levels up from .claude/skills/web-test/scripts/core/state.mjs +const __fn_state = fileURLToPath(import.meta.url); +export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..'); + +/** Resolve a user-provided path relative to the project root (not cwd). */ +export const resolveProjectPath = (p) => pathResolve(projectRoot, p); + +// ────────────────────────────────────────────────────────────────────────── +// Mutable single-session state. Importers read via the live binding; writes +// must go through the corresponding setter (ESM imports are read-only). +// ────────────────────────────────────────────────────────────────────────── + +export let browser = null; +export let page = null; +export let sessionPrefix = null; // e.g. "http://localhost:8081/bpdemo/ru_RU" +export let seanceId = null; +export let recorder = null; // { cdp, ffmpeg, startTime, outputPath, ffmpegError, captions } +export let lastCaptions = []; // captions from the last completed recording (for addNarration) +export let lastRecordingDuration = null; // wall-clock duration of the last recording (seconds) +export let highlightMode = false; +export let persistentUserDataDir = null; // temp dir for launchPersistentContext, cleaned on disconnect + +// Clipboard preservation: save full clipboard contents (all MIME types) right +// before each writeText+Ctrl+V pair, restore right after. Toggled via +// setPreserveClipboard() from run.mjs. +export let preserveClipboard = true; +export let clipboardWarnLogged = false; + +export const setBrowser = (v) => { browser = v; }; +export const setPage = (v) => { page = v; }; +export const setSessionPrefix = (v) => { sessionPrefix = v; }; +export const setSeanceId = (v) => { seanceId = v; }; +export const setRecorder = (v) => { recorder = v; }; +export const setLastCaptions = (v) => { lastCaptions = v; }; +export const setLastRecordingDuration = (v) => { lastRecordingDuration = v; }; +export const setHighlightMode = (v) => { highlightMode = !!v; }; +export const setPersistentUserDataDir = (v) => { persistentUserDataDir = v; }; +export const setPreserveClipboard = (v) => { preserveClipboard = !!v; }; +export const setClipboardWarnLogged = (v) => { clipboardWarnLogged = !!v; }; + +// ────────────────────────────────────────────────────────────────────────── +// Multi-context registry: name → { context, page, sessionPrefix, seanceId, +// recorder, lastCaptions, lastRecordingDuration, highlightMode }. +// Populated by createContext(); module-level vars above mirror the active +// slot. connect() does NOT use this Map — it preserves legacy single-session +// behavior for exec/run/start. +// ────────────────────────────────────────────────────────────────────────── + +export const contexts = new Map(); +export let activeContextName = null; +// Isolation mode for the current cmdTest session — set by the first +// createContext call. 'tab': all contexts share one persistent context +// (one window, multiple tabs, extension loads reliably). 'window': each +// context gets its own BrowserContext (separate window per context, full +// cookie isolation, extension may not load). +export let activeMode = null; + +export const setActiveContextName = (v) => { activeContextName = v; }; +export const setActiveMode = (v) => { activeMode = v; }; + +// ────────────────────────────────────────────────────────────────────────── +// Constants. +// ────────────────────────────────────────────────────────────────────────── + +export const LOAD_TIMEOUT = 60000; +export const INIT_TIMEOUT = 60000; +export const ACTION_WAIT = 2000; // fallback minimum wait +export const MAX_WAIT = 10000; // max wait for stability +export const POLL_INTERVAL = 200; // polling interval +export const STABLE_CYCLES = 3; // consecutive stable cycles needed + +// 1C browser extension ID (stable across versions, defined by key in manifest.json) +export const EXT_ID = 'pbhelknnhilelbnhfpcjlcabhmfangik'; + +// ────────────────────────────────────────────────────────────────────────── +// Utilities that only depend on state. +// ────────────────────────────────────────────────────────────────────────── + +/** Normalize ё→е and  →space for fuzzy matching. */ +export const normYo = (s) => s.replace(/ё/gi, 'е').replace(/ /g, ' '); + +/** Check if browser is connected and page is usable. */ +export function isConnected() { + if (!browser || !page || page.isClosed()) return false; + // launchPersistentContext returns BrowserContext (no isConnected), launch returns Browser + if (typeof browser.isConnected === 'function') return browser.isConnected(); + // For persistent context, check via context's browser() + return browser.browser()?.isConnected() ?? false; +} + +export function ensureConnected() { + if (!isConnected()) { + throw new Error('Browser not connected. Call web_connect first.'); + } +} + +/** Get the raw Playwright page object (for advanced scripting in skill mode). */ +export function getPage() { + ensureConnected(); + return page; +} diff --git a/.claude/skills/web-test/scripts/recording/captions.mjs b/.claude/skills/web-test/scripts/recording/captions.mjs new file mode 100644 index 00000000..c70cd987 --- /dev/null +++ b/.claude/skills/web-test/scripts/recording/captions.mjs @@ -0,0 +1,292 @@ +// web-test recording/captions v1.16 — Overlay primitives: captions, title slides, image overlays. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { existsSync as fsExistsSync, readFileSync } from 'fs'; +import { extname } from 'path'; +import { + page, recorder, lastCaptions, ensureConnected, resolveProjectPath, +} from '../core/state.mjs'; + +/** + * Show a text caption overlay on the page (visible in recording). + * Calling again updates the text without creating a new element. + * @param {string} text — caption text + * @param {object} [opts] + * @param {'top'|'bottom'} [opts.position='bottom'] — vertical position + * @param {number} [opts.fontSize=24] — font size in pixels + * @param {string} [opts.background='rgba(0,0,0,0.7)'] — background color + * @param {string} [opts.color='#fff'] — text color + * @param {string|false} [opts.speech] — TTS narration text. Omit to use displayed text, + * pass a string for custom narration, or false to skip narration for this caption. + */ +export async function showCaption(text, opts = {}) { + ensureConnected(); + + // Collect caption for TTS narration if recording + let smartWaitMs = 0; + if (recorder && (text.trim() || typeof opts.speech === 'string') && opts.speech !== false) { + const speech = typeof opts.speech === 'string' ? opts.speech : text; + // Use video timeline position (accounts for frame duplication) instead of wall-clock + recorder.captions.push({ text: text || speech, speech, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) }); + // Estimate TTS duration and wait so the video has enough screen time for voiceover + smartWaitMs = Math.max(2000, speech.length * (recorder.speechRate || 70)); + } + const position = opts.position || 'bottom'; + const fontSize = opts.fontSize || 24; + const bg = opts.background || 'rgba(0,0,0,0.7)'; + const color = opts.color || '#fff'; + + await page.evaluate(({ text, position, fontSize, bg, color }) => { + let el = document.getElementById('__web_test_caption'); + if (!el) { + el = document.createElement('div'); + el.id = '__web_test_caption'; + el.style.cssText = ` + position: fixed; left: 0; right: 0; z-index: 99999; + text-align: center; padding: 12px 24px; + font-family: Arial, sans-serif; pointer-events: none; + `; + document.body.appendChild(el); + } + el.style[position === 'top' ? 'top' : 'bottom'] = '20px'; + el.style[position === 'top' ? 'bottom' : 'top'] = 'auto'; + el.style.fontSize = fontSize + 'px'; + el.style.background = bg; + el.style.color = color; + el.textContent = text; + }, { text, position, fontSize, bg, color }); + + // Smart TTS wait: pause for estimated speech duration so video has enough screen time. + // Split into chunks and flush frames periodically — CDP doesn't send screencast frames + // for static pages, so we must write duplicate frames to keep video timeline in sync. + if (smartWaitMs > 0) { + let remaining = smartWaitMs; + while (remaining > 0) { + const chunk = Math.min(remaining, 1000); + await page.waitForTimeout(chunk); + remaining -= chunk; + if (recorder?._flushFrames) recorder._flushFrames(); + } + recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() }; + } +} + +/** Remove the caption overlay from the page. */ +export async function hideCaption() { + ensureConnected(); + await page.evaluate(() => { + const el = document.getElementById('__web_test_caption'); + if (el) el.remove(); + }); +} + +/** + * Get captions collected during the current or last recording. + * @returns {Array<{text: string, speech: string, time: number}>} + */ +export function getCaptions() { + if (recorder) return [...recorder.captions]; + return [...lastCaptions]; +} + +/** + * Show a full-screen title slide overlay (for video recordings). + * Repeated calls update the content. Use hideTitleSlide() to remove. + * @param {string} text Title text (\n → line break) + * @param {object} [opts] + * @param {string} [opts.subtitle] Smaller text below the title + * @param {string} [opts.background] CSS background (default: dark gradient) + * @param {string} [opts.color] Text color (default: '#fff') + * @param {number} [opts.fontSize] Title font size in px (default: 36) + */ +export async function showTitleSlide(text, opts = {}) { + ensureConnected(); + const { + subtitle = '', + background = 'linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%)', + color = '#fff', + fontSize = 36, + speech, + } = opts; + + // Collect caption for TTS narration if recording + let smartWaitMs = 0; + if (recorder && speech && speech !== false) { + const captionText = typeof speech === 'string' ? speech : text.replace(/\n/g, ' '); + if (captionText) { + recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) }); + smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70)); + } + } + + await page.evaluate(({ text, subtitle, background, color, fontSize }) => { + let div = document.getElementById('__web_test_title'); + if (!div) { + div = document.createElement('div'); + div.id = '__web_test_title'; + document.body.appendChild(div); + } + div.style.cssText = [ + 'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%', + `background:${background}`, + 'display:flex', 'align-items:center', 'justify-content:center', + 'z-index:999999', 'pointer-events:none', + ].join(';'); + // Remove other overlays to prevent flash between slides + const img = document.getElementById('__web_test_image'); + if (img) img.remove(); + const esc = s => s.replace(/&/g, '&').replace(/'); + let html = `
${esc(text)}
`; + if (subtitle) { + html += `
${esc(subtitle)}
`; + } + div.innerHTML = `
${html}
`; + }, { text, subtitle, background, color, fontSize }); + + // Smart TTS wait (same pattern as showCaption/showImage) + if (smartWaitMs > 0) { + let remaining = smartWaitMs; + while (remaining > 0) { + const chunk = Math.min(remaining, 1000); + await page.waitForTimeout(chunk); + remaining -= chunk; + if (recorder?._flushFrames) recorder._flushFrames(); + } + recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() }; + } +} + +/** Remove the title slide overlay. */ +export async function hideTitleSlide() { + ensureConnected(); + await page.evaluate(() => { + const el = document.getElementById('__web_test_title'); + if (el) el.remove(); + }); +} + +/** + * Show a full-screen image overlay (e.g. presentation slide screenshot). + * Reads the image file, base64-encodes it, and renders as a fixed overlay + * on the page — captured by CDP screencast automatically. + * + * Style presets: + * - 'blur' (default) — blurred+dimmed copy as background, image centered with shadow + * - 'dark' — dark background (#2a2a2a) with shadow + * - 'light' — white background with shadow + * - 'full' — image covers entire screen, no padding/shadow + * + * Custom background overrides the preset (e.g. background: '#003366'). + * + * @param {string} imagePath — path to the image file (PNG, JPG, etc.) + * @param {object} [opts] + * @param {'blur'|'dark'|'light'|'full'} [opts.style='blur'] — display style preset + * @param {string} [opts.background] — custom background color/gradient (overrides style preset) + * @param {boolean} [opts.shadow] — show drop shadow (default: true for blur/dark/light, false for full) + * @param {string|false} [opts.speech] — TTS narration text while image is shown. + * Pass a string for narration, or false to skip. Omit to skip (no auto-text for images). + */ +export async function showImage(imagePath, opts = {}) { + ensureConnected(); + const style = opts.style || 'blur'; + const speech = opts.speech; + + // Style presets + const presets = { + blur: { bg: '#222', fit: 'contain', shadow: true, blur: true }, + dark: { bg: '#2a2a2a', fit: 'contain', shadow: true, blur: false }, + light: { bg: '#ffffff', fit: 'contain', shadow: true, blur: false }, + full: { bg: '#000', fit: 'contain', shadow: false, blur: false }, + }; + const preset = presets[style] || presets.blur; + + const bg = opts.background || preset.bg; + const fit = preset.fit; + const shadow = opts.shadow !== undefined ? opts.shadow : preset.shadow; + const useBlur = opts.background ? false : preset.blur; + + // Read image and base64-encode + const absPath = resolveProjectPath(imagePath); + if (!fsExistsSync(absPath)) { + throw new Error(`showImage: file not found: ${absPath}`); + } + const buf = readFileSync(absPath); + const ext = extname(absPath).toLowerCase().replace('.', ''); + const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' + : ext === 'png' ? 'image/png' + : ext === 'gif' ? 'image/gif' + : ext === 'webp' ? 'image/webp' + : ext === 'svg' ? 'image/svg+xml' + : 'image/png'; + const dataUrl = `data:${mime};base64,${buf.toString('base64')}`; + + // Collect caption for TTS narration if recording + let smartWaitMs = 0; + if (recorder && speech && speech !== false) { + const captionText = typeof speech === 'string' ? speech : ''; + if (captionText) { + recorder.captions.push({ text: captionText, speech: captionText, time: Math.round(recorder.videoTimeMs), ...(opts.voice ? { voice: opts.voice } : {}) }); + smartWaitMs = Math.max(2000, captionText.length * (recorder.speechRate || 70)); + } + } + + // Padding: full style uses 100%, others use 92% for breathing room + const isFull = style === 'full'; + const maxSize = isFull ? '100%' : '92%'; + + await page.evaluate(({ dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }) => { + let div = document.getElementById('__web_test_image'); + if (!div) { + div = document.createElement('div'); + div.id = '__web_test_image'; + document.body.appendChild(div); + } + // Remove other overlays to prevent flash between slides + const title = document.getElementById('__web_test_title'); + if (title) title.remove(); + + div.style.cssText = [ + 'position:fixed', 'top:0', 'left:0', 'width:100%', 'height:100%', + `background:${bg}`, + 'display:flex', 'align-items:center', 'justify-content:center', + 'z-index:999999', 'pointer-events:none', 'overflow:hidden' + ].join(';'); + + let html = ''; + + // Blurred background layer: the same image stretched to cover, blurred and dimmed + if (useBlur) { + html += ``; + } + + // Main image + const shadowCss = shadow ? 'box-shadow:0 4px 40px rgba(0,0,0,0.5);' : ''; + const sizeCss = isFull + ? `width:100%;height:100%;object-fit:${fit};` + : `max-width:${maxSize};max-height:${maxSize};min-width:50%;min-height:50%;object-fit:${fit};`; + html += ``; + + div.innerHTML = html; + }, { dataUrl, fit, bg, useBlur, shadow, maxSize, isFull }); + + // Smart TTS wait (same pattern as showCaption) + if (smartWaitMs > 0) { + let remaining = smartWaitMs; + while (remaining > 0) { + const chunk = Math.min(remaining, 1000); + await page.waitForTimeout(chunk); + remaining -= chunk; + if (recorder?._flushFrames) recorder._flushFrames(); + } + recorder.captionCredit = { waitedMs: smartWaitMs, at: Date.now() }; + } +} + +/** Remove the image overlay. */ +export async function hideImage() { + ensureConnected(); + await page.evaluate(() => { + const el = document.getElementById('__web_test_image'); + if (el) el.remove(); + }); +} diff --git a/.claude/skills/web-test/scripts/recording/capture.mjs b/.claude/skills/web-test/scripts/recording/capture.mjs new file mode 100644 index 00000000..ad94a4ee --- /dev/null +++ b/.claude/skills/web-test/scripts/recording/capture.mjs @@ -0,0 +1,244 @@ +// web-test recording/capture v1.16 — Recording lifecycle (CDP screencast + ffmpeg pipe), screenshot, wait helpers. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { spawn } from 'child_process'; +import { mkdirSync, statSync, writeFileSync } from 'fs'; +import { dirname } from 'path'; +import { + page, recorder, lastCaptions, + setRecorder, setLastCaptions, setLastRecordingDuration, + resolveProjectPath, ensureConnected, +} from '../core/state.mjs'; +import { resolveFfmpeg } from './tts.mjs'; +// getFormState lives in browser.mjs for now (moves to forms/ in a later stage). +// Imported lazily inside wait() to avoid initialization-time circular deps. + +/** Take a screenshot. Returns PNG buffer. */ +export async function screenshot() { + ensureConnected(); + return await page.screenshot({ type: 'png' }); +} + +/** Wait for a specified number of seconds. */ +export async function wait(seconds) { + ensureConnected(); + let ms = seconds * 1000; + // Credit system: if showCaption already waited for TTS, subtract that time + if (recorder && recorder.captionCredit) { + const elapsed = Date.now() - recorder.captionCredit.at; + const credit = Math.max(0, recorder.captionCredit.waitedMs - elapsed); + ms = Math.max(0, ms - credit); + recorder.captionCredit = null; + } + if (ms > 0) { + // During recording, split long waits into chunks and flush frames + // to keep video timeline in sync (CDP may not send frames for static pages) + if (recorder?._flushFrames && ms > 1000) { + let remaining = ms; + while (remaining > 0) { + const chunk = Math.min(remaining, 1000); + await page.waitForTimeout(chunk); + remaining -= chunk; + recorder._flushFrames(); + } + } else { + await page.waitForTimeout(ms); + } + } + const { getFormState } = await import('../browser.mjs'); + return await getFormState(); +} + +// ============================================================ +// Video recording — CDP screencast + ffmpeg +// ============================================================ + +/** Check if video recording is active. */ +export function isRecording() { + return recorder !== null; +} + +/** + * Start video recording via CDP screencast + ffmpeg. + * Frames are captured as JPEG and piped to ffmpeg for MP4 encoding. + * @param {string} outputPath — output .mp4 file path + * @param {object} [opts] + * @param {number} [opts.fps=25] — target framerate + * @param {number} [opts.quality=80] — JPEG quality (1-100) + * @param {string} [opts.ffmpegPath] — explicit path to ffmpeg binary + */ +export async function startRecording(outputPath, opts = {}) { + ensureConnected(); + if (recorder) { + if (opts.force) { + try { await stopRecording(); } catch {} + } else { + throw new Error('Already recording. Call stopRecording() first, or use { force: true }.'); + } + } + setLastCaptions([]); + setLastRecordingDuration(null); + + const fps = opts.fps || 25; + const quality = opts.quality || 80; + const ffmpegPath = resolveFfmpeg(opts.ffmpegPath); + + // Ensure output directory exists + const resolvedPath = resolveProjectPath(outputPath); + mkdirSync(dirname(resolvedPath), { recursive: true }); + + // Spawn ffmpeg process — single output file across context switches + const ffmpeg = spawn(ffmpegPath, [ + '-y', // overwrite output + '-f', 'image2pipe', // input: piped images + '-framerate', String(fps), // input framerate + '-i', '-', // read from stdin + '-c:v', 'libx264', // H.264 codec + '-preset', 'fast', // good quality/speed balance + '-crf', '23', // default quality (good for screen content) + '-vf', 'scale=in_range=full:out_range=limited', // JPEG full→H.264 limited range + '-pix_fmt', 'yuv420p', // broad compatibility + '-color_range', 'tv', // limited range (16-235) — standard for H.264 players + '-movflags', '+faststart', // web-friendly MP4 + resolvedPath + ], { stdio: ['pipe', 'ignore', 'pipe'] }); + + ffmpeg.on('error', err => { if (recorder) recorder.ffmpegError += err.message; }); + + const frameDuration = 1000 / fps; + const speechRate = opts.speechRate || 70; // ms per character for smart TTS wait + + // Frame handler shared across CDP sessions (lives in recorder, not closure): + // when the active context switches, we attach a new CDP session and route its + // frames to the same ffmpeg pipe — preserving a single continuous timeline. + const frameHandler = async ({ data, sessionId }, cdp) => { + if (!recorder) return; + const buf = Buffer.from(data, 'base64'); + const now = Date.now(); + if (!ffmpeg.stdin.destroyed) { + let framesWritten = 0; + if (recorder.lastFrameTime && recorder.lastFrameBuf) { + const gap = now - recorder.lastFrameTime; + const dupes = Math.round(gap / frameDuration) - 1; + for (let i = 0; i < dupes && i < fps * 30; i++) { + ffmpeg.stdin.write(recorder.lastFrameBuf); + framesWritten++; + } + } + ffmpeg.stdin.write(buf); + framesWritten++; + recorder.videoTimeMs += framesWritten * frameDuration; + } + recorder.lastFrameTime = now; + recorder.lastFrameBuf = buf; + try { await cdp.send('Page.screencastFrameAck', { sessionId }); } catch {} + }; + + // Duplicate the last frame to fill wall-clock gaps (static periods, context switches). + const _flushFrames = () => { + if (!recorder || !recorder.lastFrameBuf || !recorder.lastFrameTime || ffmpeg.stdin.destroyed) return; + const now = Date.now(); + const gap = now - recorder.lastFrameTime; + const dupes = Math.round(gap / frameDuration); + for (let i = 0; i < dupes; i++) { + ffmpeg.stdin.write(recorder.lastFrameBuf); + recorder.videoTimeMs += frameDuration; + } + if (dupes > 0) recorder.lastFrameTime = now; + }; + + // Attach screencast to a specific page. Stops the old CDP first (if any). + // Called by startRecording for the initial page, and by setActiveContext when + // the active context changes mid-recording. + const _attachPage = async (targetPage) => { + if (recorder.cdp) { + _flushFrames(); // freeze the last frame of the outgoing page up to "now" + try { await recorder.cdp.send('Page.stopScreencast'); } catch {} + try { await recorder.cdp.detach(); } catch {} + recorder.cdp = null; + } + const cdp = await targetPage.context().newCDPSession(targetPage); + cdp.on('Page.screencastFrame', (ev) => frameHandler(ev, cdp)); + await cdp.send('Page.startScreencast', { format: 'jpeg', quality, everyNthFrame: 1 }); + recorder.cdp = cdp; + recorder.activePage = targetPage; + }; + + setRecorder({ + cdp: null, + activePage: null, + ffmpeg, + startTime: Date.now(), + outputPath: resolvedPath, + ffmpegError: '', + captions: [], + videoTimeMs: 0, + frameDuration, + lastFrameTime: null, + lastFrameBuf: null, + _flushFrames, + _attachPage, + speechRate, + }); + ffmpeg.stderr.on('data', d => { recorder.ffmpegError += d.toString(); }); + + await _attachPage(page); +} + +/** + * Stop video recording. Finalizes the MP4 file. + * @returns {{ file: string, duration: number, size: number }} + */ +export async function stopRecording() { + if (!recorder) return { file: null, duration: 0, size: 0 }; + + const { cdp, ffmpeg, startTime, outputPath } = recorder; + + // Final frame flush: write remaining frames to cover the gap since the last screencast frame + if (recorder._flushFrames) recorder._flushFrames(); + + // Stop CDP screencast + try { await cdp.send('Page.stopScreencast'); } catch {} + try { await cdp.detach(); } catch {} + + // Close ffmpeg stdin and wait for encoding to finish + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ffmpeg.kill('SIGKILL'); + reject(new Error('ffmpeg timed out after 30s')); + }, 30000); + + ffmpeg.on('close', (code) => { + clearTimeout(timeout); + if (code === 0) resolve(); + else reject(new Error(`ffmpeg exited with code ${code}: ${recorder?.ffmpegError || ''}`)); + }); + ffmpeg.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + + ffmpeg.stdin.end(); + }); + + const duration = (Date.now() - startTime) / 1000; + const stats = statSync(outputPath); + + // Preserve captions for addNarration() + setLastCaptions(recorder.captions || []); + setLastRecordingDuration(duration); + if (lastCaptions.length) { + const captionsPath = outputPath.replace(/\.[^.]+$/, '.captions.json'); + const captionsData = { recordingDuration: duration, videoTimestamps: true, captions: lastCaptions }; + writeFileSync(captionsPath, JSON.stringify(captionsData, null, 2), 'utf-8'); + } + + setRecorder(null); + + return { + file: outputPath, + duration: Math.round(duration * 10) / 10, + size: stats.size, + captions: lastCaptions.length + }; +} diff --git a/.claude/skills/web-test/scripts/recording/highlight.mjs b/.claude/skills/web-test/scripts/recording/highlight.mjs new file mode 100644 index 00000000..bdf5eee1 --- /dev/null +++ b/.claude/skills/web-test/scripts/recording/highlight.mjs @@ -0,0 +1,340 @@ +// web-test recording/highlight v1.16 — Visual highlight overlay (single + auto-mode for clickElement/fillFields/selectValue). +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { + page, highlightMode, ensureConnected, normYo, + setHighlightMode, +} from '../core/state.mjs'; +import { + readSubmenuScript, detectFormScript, resolveGridScript, + findClickTargetScript, resolveFieldsScript, +} from '../dom.mjs'; + +/** + * Highlight an element on the page (visual accent for video recordings). + * Uses overlay div for visibility (not clipped by overflow:hidden), with + * requestAnimationFrame tracking so it follows layout shifts (async banners etc). + * @param {string} text Element text/label (fuzzy match, same as clickElement/fillFields) + * @param {object} [opts] + * @param {string} [opts.color] Outline color (default: '#e74c3c') + * @param {number} [opts.padding] Extra padding around element (default: 4) + */ +export async function highlight(text, opts = {}) { + ensureConnected(); + const { color = '#e74c3c', padding = 4, table } = opts; + + // Remove previous highlight first + await unhighlight(); + + let elId = null; + + // 0. Open submenu/popup — highest priority (submenu overlays the form, + // so form search would match grid rows behind the popup) + const popupItems = await page.evaluate(readSubmenuScript()); + if (Array.isArray(popupItems) && popupItems.length > 0) { + const target = normYo(text.toLowerCase()); + let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target); + if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).startsWith(target)); + if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target)); + if (found) { + // 1C duplicates IDs in clouds — getElementById returns the hidden copy. + // Use elementFromPoint to find the visible element and get its actual rect. + await page.evaluate(({ x, y, color, padding }) => { + const el = document.elementFromPoint(x, y); + if (!el) return; + const block = el.closest('.submenuBlock') || el.closest('a.press') || el; + const r = block.getBoundingClientRect(); + let div = document.getElementById('__web_test_highlight'); + if (!div) { + div = document.createElement('div'); + div.id = '__web_test_highlight'; + document.body.appendChild(div); + } + div.style.cssText = [ + 'position:fixed', 'pointer-events:none', 'z-index:999998', + `top:${r.y - padding}px`, `left:${r.x - padding}px`, + `width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`, + `outline:3px solid ${color}`, 'border-radius:4px', + `box-shadow:0 0 16px ${color}80`, + ].join(';'); + }, { x: found.x, y: found.y, color, padding }); + return; // overlay placed, done + } + } + + // 1. Visible commands on the function panel (cmd_XXX_txt elements) + // Must be checked BEFORE form search: when the section content panel + // is showing, the form behind it is hidden but detectFormScript still + // finds it, and form buttons match before commands. + if (!elId) { + elId = await page.evaluate(`(() => { + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); + const target = ${JSON.stringify(normYo(text.toLowerCase()))}; + const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0); + if (cmds.length === 0) return null; + let el = cmds.find(e => norm(e.innerText).toLowerCase() === target); + if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().startsWith(target)); + if (!el) el = cmds.find(e => norm(e.innerText).toLowerCase().includes(target)); + return el ? el.id : null; + })()`); + } + + // 1b. Command group headers on the function panel (eAccentColor labels). + // Match header text, then highlight the header + commands below it + // until the next spacer/header/end. + if (!elId) { + const groupDone = await page.evaluate(({ target, color, padding }) => { + const container = document.querySelector('#funcPanel_container'); + if (!container) return false; + const norm = s => (s?.trim().replace(/\u00a0/g, ' ') || '').replace(/ё/gi, 'е').toLowerCase(); + const headers = [...container.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0); + if (!headers.length) return false; + + let headerEl = headers.find(h => norm(h.textContent) === target); + if (!headerEl) headerEl = headers.find(h => norm(h.textContent).startsWith(target)); + if (!headerEl) headerEl = headers.find(h => norm(h.textContent).includes(target)); + if (!headerEl) return false; + + // Collect header + following cmd siblings until next spacer/header + const parent = headerEl.parentElement; + const children = [...parent.children]; + const startIdx = children.indexOf(headerEl); + const groupEls = [headerEl]; + for (let i = startIdx + 1; i < children.length; i++) { + const el = children[i]; + if (el.classList.contains('eAccentColor')) break; + if (!el.id && !el.classList.contains('functionItem') && el.getBoundingClientRect().width < 10) break; + groupEls.push(el); + } + + // Bounding box + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + for (const el of groupEls) { + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) continue; + minX = Math.min(minX, r.left); minY = Math.min(minY, r.top); + maxX = Math.max(maxX, r.right); maxY = Math.max(maxY, r.bottom); + } + if (minX === Infinity) return false; + + let div = document.getElementById('__web_test_highlight'); + if (!div) { div = document.createElement('div'); div.id = '__web_test_highlight'; document.body.appendChild(div); } + div.style.cssText = [ + 'position:fixed', 'pointer-events:none', 'z-index:999998', + `top:${minY - padding}px`, `left:${minX - padding}px`, + `width:${maxX - minX + padding * 2}px`, `height:${maxY - minY + padding * 2}px`, + `outline:3px solid ${color}`, 'border-radius:4px', + `box-shadow:0 0 16px ${color}80`, + ].join(';'); + return true; + }, { target: normYo(text.toLowerCase()), color, padding }); + if (groupDone) return; + } + + // 2. Form groups/panels — checked BEFORE buttons/fields because group names + // often collide with command bar buttons (e.g. "БизнесПроцессы" is both a + // panel and a command bar element). Includes _container and _div elements + // but skips logicGroupContainer (Representation=None, height=0). + if (!elId) { + const formNum = await page.evaluate(detectFormScript()); + if (formNum !== null) { + elId = await page.evaluate(`(() => { + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); + const target = ${JSON.stringify(normYo(text.toLowerCase()))}; + const p = 'form' + ${formNum} + '_'; + // Group containers: _container or _div, but skip logicGroupContainer (invisible groups) + const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')] + .filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && !el.classList.contains('logicGroupContainer')); + const items = groups.map(el => { + const idName = el.id.replace(p, '').replace(/_(container|div)$/, ''); + const titleEl = document.getElementById(p + idName + '#title_text') + || document.getElementById(p + idName + '_title_text'); + const label = norm(titleEl?.innerText || '').toLowerCase(); + const name = norm(idName).toLowerCase(); + const big = el.offsetWidth >= 100 && el.offsetHeight >= 50; + return { id: el.id, name, label, big }; + }); + let found = items.find(i => i.label === target); + if (!found) found = items.find(i => i.name === target); + // Fuzzy match: only large groups (min 100x50) to avoid matching command bars + if (!found) found = items.filter(i => i.big).find(i => i.label.startsWith(target) || i.name.startsWith(target)); + if (!found && target.length >= 4) found = items.filter(i => i.big).find(i => i.label.includes(target) || i.name.includes(target)); + return found ? found.id : null; + })()`); + } + } + + // 3. Form-scoped search (buttons, links, fields, grid rows) + if (!elId) { + const formNum = await page.evaluate(detectFormScript()); + if (formNum !== null) { + // 3a. Try button/link/tab/gridRow via findClickTargetScript + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (!resolved.error) gridSelector = resolved.gridSelector; + } + const target = await page.evaluate(findClickTargetScript(formNum, text, table ? { tableName: table, gridSelector } : undefined)); + if (target && !target.error) { + if (target.id) { + elId = target.id; + } else if (target.x && target.y) { + // Grid row — find the gridLine element and tag it + elId = await page.evaluate(`(() => { + const p = ${JSON.stringify(`form${formNum}_`)}; + const grid = document.querySelector('[id^="' + p + '"].grid'); + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); + const target = ${JSON.stringify(normYo(text.toLowerCase()))}; + for (const line of body.querySelectorAll('.gridLine')) { + const cells = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0); + const rowText = cells.map(b => b.innerText?.trim() || '').join(' ').toLowerCase().replace(/ё/gi, 'е'); + if (rowText.includes(target)) { + if (!line.id) line.id = '__wt_hl_tmp'; + return line.id; + } + } + return null; + })()`); + } + } + + // 3b. If not found as button — try as field via resolveFieldsScript + if (!elId) { + const dummyFields = { [text]: '' }; + const resolved = await page.evaluate(resolveFieldsScript(formNum, dummyFields)); + if (resolved?.length > 0 && !resolved[0].error && resolved[0].inputId) { + elId = resolved[0].inputId; + } + } + } + } + + // 4. Fallback: sections (sidebar navigation) + if (!elId) { + elId = await page.evaluate(`(() => { + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); + const target = ${JSON.stringify(normYo(text.toLowerCase()))}; + const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')]; + let el = secs.find(e => norm(e.innerText).toLowerCase() === target); + if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().startsWith(target)); + if (!el) el = secs.find(e => norm(e.innerText).toLowerCase().includes(target)); + return el ? el.id : null; + })()`); + } + + if (!elId) { + // Collect available elements to help the caller fix the name + const available = await page.evaluate(`(() => { + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); + const result = {}; + // Commands + const cmds = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(e => e.offsetWidth > 0).map(e => norm(e.innerText)); + if (cmds.length) result.commands = cmds; + // Command group headers + const fp = document.querySelector('#funcPanel_container'); + if (fp) { + const gh = [...fp.querySelectorAll('.eAccentColor')].filter(e => e.offsetWidth > 0).map(e => norm(e.textContent)); + if (gh.length) result.commandGroups = gh; + } + // Sections + const secs = [...document.querySelectorAll('[id^="themesCell_theme_"]')].map(e => norm(e.innerText)).filter(Boolean); + if (secs.length) result.sections = secs; + // Form elements + ${(() => { + // Detect form inline to avoid extra evaluate round-trip + return ` + const forms = {}; + document.querySelectorAll('[id^="form"]').forEach(el => { + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = (forms[m[1]] || 0) + 1; + }); + let formNum = null, maxCount = 0; + for (const [n, c] of Object.entries(forms)) { + if (parseInt(n) > 0 && c > maxCount) { maxCount = c; formNum = n; } + } + if (formNum !== null) { + const p = 'form' + formNum + '_'; + // Groups (_container or _div, skip logicGroupContainer, min 100x50) + const groups = [...document.querySelectorAll('[id^="' + p + '"][id$="_container"], [id^="' + p + '"][id$="_div"]')] + .filter(el => el.offsetWidth >= 100 && el.offsetHeight >= 50 && !el.classList.contains('logicGroupContainer')) + .map(el => { + const idName = el.id.replace(p, '').replace(/_(container|div)$/, ''); + const titleEl = document.getElementById(p + idName + '#title_text') || document.getElementById(p + idName + '_title_text'); + return norm(titleEl?.innerText || '') || idName; + }).filter(Boolean); + if (groups.length) result.groups = groups; + // Buttons/links + const btns = [...document.querySelectorAll('[id^="' + p + '"].btnText, [id^="' + p + '"] .btnText, [id^="' + p + '"].hplnk')] + .filter(el => el.offsetWidth > 0).map(el => norm(el.innerText)).filter(Boolean); + if (btns.length) result.buttons = [...new Set(btns)]; + }`; + })()} + return result; + })()`); + const parts = []; + for (const [cat, items] of Object.entries(available)) { + parts.push(` ${cat}: ${items.join(', ')}`); + } + const hint = parts.length ? `\nAvailable:\n${parts.join('\n')}` : ''; + throw new Error(`highlight: "${text}" not found${hint}`); + } + + // Overlay div + rAF tracking loop (not clipped by overflow:hidden, follows layout shifts) + await page.evaluate(({ elId, color, padding }) => { + const target = document.getElementById(elId); + 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); + } + function sync() { + const r = target.getBoundingClientRect(); + div.style.cssText = [ + 'position:fixed', 'pointer-events:none', 'z-index:999998', + `top:${r.y - padding}px`, `left:${r.x - padding}px`, + `width:${r.width + padding * 2}px`, `height:${r.height + padding * 2}px`, + `outline:3px solid ${color}`, 'border-radius:4px', + `box-shadow:0 0 16px ${color}80`, + ].join(';'); + } + sync(); + // Track position changes via rAF + function tick() { + if (!document.getElementById('__web_test_highlight')) return; // stopped + sync(); + requestAnimationFrame(tick); + } + requestAnimationFrame(tick); + }, { elId, color, padding }); +} + +/** Remove the highlight overlay. */ +export async function unhighlight() { + ensureConnected(); + await page.evaluate(() => { + const el = document.getElementById('__web_test_highlight'); + if (el) el.remove(); // also stops rAF loop (id check) + // Clean up temp ID from grid rows + const tmp = document.getElementById('__wt_hl_tmp'); + if (tmp) tmp.removeAttribute('id'); + }); +} + +/** + * Toggle auto-highlight mode. When enabled, clickElement/fillFields/selectValue + * automatically highlight the target element before acting. + * @param {boolean} on true to enable, false to disable + */ +export function setHighlight(on) { + setHighlightMode(!!on); +} + +/** @returns {boolean} Whether auto-highlight mode is active. */ +export function isHighlightMode() { + return highlightMode; +} diff --git a/.claude/skills/web-test/scripts/recording/narration.mjs b/.claude/skills/web-test/scripts/recording/narration.mjs new file mode 100644 index 00000000..dff34e0a --- /dev/null +++ b/.claude/skills/web-test/scripts/recording/narration.mjs @@ -0,0 +1,196 @@ +// web-test recording/narration v1.16 — Post-process: generate TTS audio for captions and merge with recorded video. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { execFileSync } from 'child_process'; +import { existsSync as fsExistsSync, mkdirSync, readFileSync, rmSync, statSync } from 'fs'; +import { extname, join as pathJoin } from 'path'; +import { tmpdir } from 'os'; +import { + lastCaptions, lastRecordingDuration, resolveProjectPath, +} from '../core/state.mjs'; +import { + resolveFfmpeg, getTtsProvider, getAudioDuration, generateSilence, +} from './tts.mjs'; + +/** + * Add TTS narration to a recorded video. + * Generates speech from captions and merges audio with the video. + * @param {string} videoPath — path to the recorded MP4 file + * @param {object} [opts] + * @param {Array<{text: string, speech: string, time: number, voice?: string}>} [opts.captions] — explicit captions (default: from last recording or .captions.json). Each caption may include a `voice` field to override the global voice for that segment + * @param {string} [opts.provider='edge'] — TTS provider: 'edge' or 'openai' + * @param {string} [opts.voice] — voice name (provider-specific) + * @param {string} [opts.apiKey] — API key (for openai provider) + * @param {string} [opts.apiUrl] — API endpoint (for openai provider) + * @param {string} [opts.model] — model name (for openai provider, default: 'tts-1') + * @param {string} [opts.ffmpegPath] — path to ffmpeg binary + * @param {string} [opts.outputPath] — output file path (default: video-narrated.mp4) + * @returns {{ file: string, duration: number, size: number, captions: number, warnings?: string[] }} + */ +export async function addNarration(videoPath, opts = {}) { + if (!videoPath) return { file: null, duration: 0, size: 0, captions: 0 }; + videoPath = resolveProjectPath(videoPath); + const ffmpegPath = resolveFfmpeg(opts.ffmpegPath); + const ttsProvider = getTtsProvider(opts.provider || 'edge'); + const ttsOpts = { voice: opts.voice, apiKey: opts.apiKey, apiUrl: opts.apiUrl, model: opts.model }; + + // Resolve captions: explicit > lastCaptions > .captions.json + let captions = opts.captions; + let videoTimestamps = true; // new recordings use video-time timestamps (no scaling needed) + let recordingDuration = null; // wall-clock duration (for legacy scaling fallback) + if (!captions || !captions.length) { + if (lastCaptions.length) { + captions = [...lastCaptions]; + recordingDuration = lastRecordingDuration; + // Runtime captions always use video timestamps (set in showCaption) + } + } + if (!captions || !captions.length) { + const captionsJsonPath = videoPath.replace(/\.[^.]+$/, '.captions.json'); + if (fsExistsSync(captionsJsonPath)) { + const raw = JSON.parse(readFileSync(captionsJsonPath, 'utf-8')); + // Support formats: array (old), { recordingDuration, captions } (v2), { videoTimestamps, captions } (v3) + if (Array.isArray(raw)) { + captions = raw; + videoTimestamps = false; + } else { + captions = raw.captions; + videoTimestamps = !!raw.videoTimestamps; + recordingDuration = raw.recordingDuration || null; + } + } + } + if (!captions || !captions.length) { + throw new Error('No captions available. Record with showCaption() first, or pass opts.captions.'); + } + + const videoDuration = getAudioDuration(videoPath, ffmpegPath); + + // Legacy fallback: scale wall-clock timestamps to video duration + // (only for old captions without videoTimestamps flag) + if (!videoTimestamps && recordingDuration && recordingDuration > 0) { + const timeScale = videoDuration / recordingDuration; + if (Math.abs(timeScale - 1) > 0.005) { + captions = captions.map(c => ({ ...c, time: Math.round(c.time * timeScale) })); + } + } + + // Output path + const ext = extname(videoPath); + const base = videoPath.slice(0, -ext.length); + const outputPath = opts.outputPath || `${base}-narrated${ext}`; + + // Temp directory + const tempDir = pathJoin(tmpdir(), `web-test-tts-${Date.now()}`); + mkdirSync(tempDir, { recursive: true }); + + const warnings = []; + + try { + // Phase 1: Generate TTS audio for each caption + const ttsFiles = []; + const BATCH_SIZE = (opts.provider === 'elevenlabs') ? 2 : 5; + for (let batchStart = 0; batchStart < captions.length; batchStart += BATCH_SIZE) { + const batch = captions.slice(batchStart, batchStart + BATCH_SIZE); + const promises = batch.map(async (cap, batchIdx) => { + const idx = batchStart + batchIdx; + const ttsFile = pathJoin(tempDir, `tts_${idx}.mp3`); + const capTtsOpts = cap.voice ? { ...ttsOpts, voice: cap.voice } : ttsOpts; + try { + await ttsProvider(cap.speech, ttsFile, capTtsOpts); + } catch (err) { + // Retry once + try { + await ttsProvider(cap.speech, ttsFile, capTtsOpts); + } catch (retryErr) { + warnings.push(`TTS failed for caption ${idx}: ${retryErr.message || retryErr.cause?.message || String(retryErr)}`); + // Generate 1s silence as placeholder + generateSilence(ttsFile, 1, ffmpegPath); + } + } + return ttsFile; + }); + const results = await Promise.all(promises); + ttsFiles.push(...results); + } + + // Phase 2+3: Place each TTS at its exact timestamp using adelay + amix + // This avoids MP3 frame quantization drift from silence-file concatenation + const ffmpegInputs = []; + const filterParts = []; + const mixLabels = []; + + for (let i = 0; i < captions.length; i++) { + const captionTimeMs = Math.round(captions[i].time); + const ttsFile = ttsFiles[i]; + const ttsDuration = getAudioDuration(ttsFile, ffmpegPath); + + ffmpegInputs.push('-i', ttsFile); + const filters = []; + + // Speed up TTS slightly if it's longer than gap to next caption (max 1.3x) + if (i < captions.length - 1) { + const maxDuration = (captions[i + 1].time - captions[i].time) / 1000; + if (ttsDuration > maxDuration && maxDuration > 0.1) { + const tempo = ttsDuration / maxDuration; + if (tempo <= 1.3) { + filters.push(`atempo=${tempo.toFixed(4)}`); + } else { + // Too fast — let audio overlap instead of distorting + warnings.push(`Caption ${i + 1}/${captions.length}: TTS ${ttsDuration.toFixed(1)}s > gap ${maxDuration.toFixed(1)}s (need ${Math.round(ttsDuration - maxDuration)}s more pause)`); + } + } + } + + // Delay to exact caption timestamp (milliseconds) + if (captionTimeMs > 0) { + filters.push(`adelay=${captionTimeMs}|${captionTimeMs}`); + } + + const label = `a${i}`; + mixLabels.push(`[${label}]`); + // Input indices are shifted by 1 because silence reference is input [0] + filterParts.push(`[${i + 1}]${filters.length ? filters.join(',') : 'acopy'}[${label}]`); + } + + // Generate a silence reference track as input [0] so amix runs for full video duration + const silencePath = pathJoin(tempDir, 'silence.mp3'); + generateSilence(silencePath, Math.ceil(videoDuration), ffmpegPath); + + const filterComplex = filterParts.join(';') + ';' + + `[0]${mixLabels.join('')}amix=inputs=${captions.length + 1}:normalize=0:duration=first`; + + const narrationPath = pathJoin(tempDir, 'narration.mp3'); + execFileSync(ffmpegPath, [ + '-y', '-i', silencePath, ...ffmpegInputs, + '-filter_complex', filterComplex, + '-t', String(Math.ceil(videoDuration)), + '-c:a', 'libmp3lame', '-b:a', '128k', narrationPath, + ], { stdio: 'pipe', timeout: 120000 }); + + // Phase 4: Merge video + narration audio + execFileSync(ffmpegPath, [ + '-y', '-i', videoPath, '-i', narrationPath, + '-c:v', 'copy', '-c:a', 'aac', '-b:a', '128k', + '-map', '0:v:0', '-map', '1:a:0', + '-t', String(Math.ceil(videoDuration)), + '-movflags', '+faststart', outputPath, + ], { stdio: 'pipe', timeout: 120000 }); + + const stats = statSync(outputPath); + const duration = getAudioDuration(outputPath, ffmpegPath); + + const result = { + file: outputPath, + duration: Math.round(duration * 10) / 10, + size: stats.size, + captions: captions.length, + }; + if (warnings.length) result.warnings = warnings; + return result; + + } finally { + // Cleanup temp directory + try { rmSync(tempDir, { recursive: true, force: true }); } catch {} + } +} diff --git a/.claude/skills/web-test/scripts/recording/tts.mjs b/.claude/skills/web-test/scripts/recording/tts.mjs new file mode 100644 index 00000000..0a965fb0 --- /dev/null +++ b/.claude/skills/web-test/scripts/recording/tts.mjs @@ -0,0 +1,175 @@ +// web-test recording/tts v1.16 — TTS providers (edge/openai/elevenlabs) and ffmpeg/ffprobe helpers. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { execFileSync, spawn } from 'child_process'; +import { existsSync as fsExistsSync, writeFileSync } from 'fs'; +import { resolve as pathResolve } from 'path'; +import { pathToFileURL } from 'url'; +import { projectRoot } from '../core/state.mjs'; + +/** Resolve ffmpeg binary path. */ +export function resolveFfmpeg(explicit) { + // 1. Explicit path + if (explicit) { + try { execFileSync(explicit, ['-version'], { stdio: 'ignore', timeout: 5000 }); return explicit; } + catch { throw new Error(`ffmpeg not found at: ${explicit}`); } + } + + // 2. FFMPEG_PATH env var + const envPath = process.env.FFMPEG_PATH; + if (envPath) { + try { execFileSync(envPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return envPath; } + catch { /* fall through */ } + } + + // 3. System PATH + try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore', timeout: 5000 }); return 'ffmpeg'; } + catch { /* fall through */ } + + // 4. tools/ffmpeg/bin/ffmpeg.exe relative to project root + const localPath = pathResolve(projectRoot, 'tools', 'ffmpeg', 'bin', 'ffmpeg.exe'); + if (fsExistsSync(localPath)) { + try { execFileSync(localPath, ['-version'], { stdio: 'ignore', timeout: 5000 }); return localPath; } + catch { /* fall through */ } + } + + // 5. Error with instructions + throw new Error( + 'ffmpeg not found. Install it:\n' + + ' - Download from https://www.gyan.dev/ffmpeg/builds/ (essentials build)\n' + + ' - Add to PATH, or set FFMPEG_PATH env var, or place in tools/ffmpeg/bin/\n' + + ' - Or pass ffmpegPath option to startRecording()' + ); +} + +// ── TTS providers ────────────────────────────────────────────────────────── + +/** Resolve node-edge-tts module: global install → tools/tts/ → error with instructions. */ +let _edgeTtsModule = null; +export async function resolveEdgeTts() { + if (_edgeTtsModule) return _edgeTtsModule; + + // 1. Global/project-level install (standard Node resolution) + try { + _edgeTtsModule = await import('node-edge-tts'); + return _edgeTtsModule; + } catch { /* fall through */ } + + // 2. tools/tts/ relative to project root + const localPath = pathResolve(projectRoot, 'tools', 'tts', 'node_modules', 'node-edge-tts', 'dist', 'edge-tts.js'); + if (fsExistsSync(localPath)) { + try { + _edgeTtsModule = await import(pathToFileURL(localPath).href); + return _edgeTtsModule; + } catch { /* fall through */ } + } + + // 3. Error with instructions + throw new Error( + 'node-edge-tts not found. Install it:\n' + + ' - npm install --prefix tools/tts node-edge-tts\n' + + ' - or: npm install node-edge-tts (global/project-level)' + ); +} + +/** + * Edge TTS provider (free, no API key). Uses node-edge-tts package. + * @param {string} text — text to synthesize + * @param {string} outputPath — path for the output mp3 file + * @param {object} opts — { voice } + */ +export async function edgeTtsProvider(text, outputPath, opts = {}) { + const { EdgeTTS } = await resolveEdgeTts(); + const voice = opts.voice || 'ru-RU-DmitryNeural'; + const tts = new EdgeTTS({ voice }); + await Promise.race([ + tts.ttsPromise(text, outputPath), + new Promise((_, reject) => setTimeout(() => reject(new Error('Edge TTS timeout (30s)')), 30000)), + ]); +} + +/** + * OpenAI-compatible TTS provider. Requires apiKey. + * @param {string} text — text to synthesize + * @param {string} outputPath — path for the output mp3 file + * @param {object} opts — { apiKey, apiUrl, voice, model } + */ +export async function openaiTtsProvider(text, outputPath, opts = {}) { + const apiUrl = opts.apiUrl || 'https://api.openai.com/v1/audio/speech'; + if (!opts.apiKey) throw new Error('OpenAI TTS requires apiKey'); + const resp = await fetch(apiUrl, { + method: 'POST', + headers: { 'Authorization': `Bearer ${opts.apiKey}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: opts.model || 'tts-1', + input: text, + voice: opts.voice || 'alloy', + response_format: 'mp3', + }), + }); + if (!resp.ok) throw new Error(`OpenAI TTS error ${resp.status}: ${await resp.text()}`); + const buf = Buffer.from(await resp.arrayBuffer()); + writeFileSync(outputPath, buf); +} + +/** + * ElevenLabs TTS provider. Requires apiKey. + * @param {string} text — text to synthesize + * @param {string} outputPath — path for the output mp3 file + * @param {object} opts — { apiKey, apiUrl, voice, model } + */ +export async function elevenlabsTtsProvider(text, outputPath, opts = {}) { + const voiceId = opts.voice || 'JBFqnCBsd6RMkjVDRZzb'; // George + const apiUrl = opts.apiUrl || `https://api.elevenlabs.io/v1/text-to-speech/${voiceId}`; + if (!opts.apiKey) throw new Error('ElevenLabs TTS requires apiKey'); + const resp = await fetch(apiUrl, { + method: 'POST', + headers: { 'xi-api-key': opts.apiKey, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text, + model_id: opts.model || 'eleven_multilingual_v2', + }), + }); + if (!resp.ok) throw new Error(`ElevenLabs TTS error ${resp.status}: ${await resp.text()}`); + const buf = Buffer.from(await resp.arrayBuffer()); + writeFileSync(outputPath, buf); +} + +/** Get TTS provider function by name. */ +export function getTtsProvider(name) { + switch (name) { + case 'openai': return openaiTtsProvider; + case 'elevenlabs': return elevenlabsTtsProvider; + case 'edge': default: return edgeTtsProvider; + } +} + +// ── TTS audio helpers ────────────────────────────────────────────────────── + +/** + * Get audio duration in seconds using ffprobe. + * @param {string} filePath — path to audio file + * @param {string} ffmpegPath — path to ffmpeg binary (ffprobe is found next to it) + * @returns {number} duration in seconds + */ +export function getAudioDuration(filePath, ffmpegPath) { + const ffprobePath = ffmpegPath.replace(/ffmpeg(\.exe)?$/i, 'ffprobe$1'); + const out = execFileSync(ffprobePath, [ + '-v', 'error', '-show_entries', 'format=duration', + '-of', 'default=noprint_wrappers=1:nokey=1', filePath, + ], { encoding: 'utf8', timeout: 10000 }).trim(); + return parseFloat(out) || 0; +} + +/** + * Generate a silence mp3 file of given duration. + * @param {string} outputPath — path for the output mp3 file + * @param {number} seconds — duration in seconds + * @param {string} ffmpegPath — path to ffmpeg binary + */ +export function generateSilence(outputPath, seconds, ffmpegPath) { + execFileSync(ffmpegPath, [ + '-y', '-f', 'lavfi', '-i', `anullsrc=r=24000:cl=mono`, + '-t', String(seconds), '-c:a', 'libmp3lame', '-b:a', '32k', outputPath, + ], { stdio: 'pipe', timeout: 10000 }); +} From 4f01f012864be17d783030799fd4a6bb3d56cb6b Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 25 May 2026 22:10:31 +0300 Subject: [PATCH 03/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20A.3=20=E2=80=94=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20core/wait.mjs=20+=20core/errors.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/wait.mjs (123 LOC): - waitForStable: smart DOM-stability polling - waitForCondition: JS-expression polling - startNetworkMonitor: CDP network-activity monitor core/errors.mjs (336 LOC): - closeModals, dismissPendingErrors, checkForErrors, fetchErrorStack - Платформенные диалоги: _detectPlatformDialogs, _closePlatformDialogs - _parseErrorStack, _fetchStackViaReport, _fetchStackViaHamburger (приватные) browser.mjs импортирует их для внутреннего использования и re-export'ит только fetchErrorStack (исходно публичный). Остальные функции остаются приватными — публичный API не меняется (56 экспортов). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 465 +----------------- .../skills/web-test/scripts/core/errors.mjs | 341 +++++++++++++ .claude/skills/web-test/scripts/core/wait.mjs | 123 +++++ 3 files changed, 477 insertions(+), 452 deletions(-) create mode 100644 .claude/skills/web-test/scripts/core/errors.mjs create mode 100644 .claude/skills/web-test/scripts/core/wait.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 1cb965fe..56168171 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -510,458 +510,19 @@ export async function closeContext(name) { contexts.delete(name); } -/** - * Close startup modals and guide tabs. - * Strategy: Escape → click default buttons → close extra tabs → repeat. - */ -async function closeModals() { - for (let attempt = 0; attempt < 5; attempt++) { - // 1. Press Escape to dismiss any popup/modal - await page.keyboard.press('Escape'); - await page.waitForTimeout(1000); - - // 2. Try clicking default "Закрыть"/"OK" buttons - const clicked = await page.evaluate(`(() => { - const btns = [...document.querySelectorAll('a.press.pressDefault')].filter(el => el.offsetWidth > 0); - for (const btn of btns) { - const text = (btn.innerText?.trim() || '').toLowerCase(); - if (['закрыть', 'ok', 'ок', 'нет', 'отмена'].includes(text)) { - btn.click(); - return text; - } - } - return null; - })()`); - if (clicked) { await page.waitForTimeout(1000); continue; } - - // 3. Close extra tabs (Путеводитель etc.) via openedClose button - const tabClosed = await page.evaluate(`(() => { - const btn = document.querySelector('.openedClose'); - if (btn && btn.offsetWidth > 0) { btn.click(); return true; } - return false; - })()`); - if (tabClosed) { await page.waitForTimeout(1000); continue; } - - // Nothing to close — done - break; - } -} - -/** - * Smart wait: poll until DOM is stable and no loading indicators are visible. - * Checks: form number change, loading indicators, DOM stability. - * @param {number|null} previousFormNum — form number before the action (null = don't check) - */ -async function waitForStable(previousFormNum = null) { - let stableCount = 0; - let lastSnapshot = ''; - const start = Date.now(); - - while (Date.now() - start < MAX_WAIT) { - await page.waitForTimeout(POLL_INTERVAL); - - // Check for loading indicators - const status = await page.evaluate(`(() => { - const loading = document.querySelector('.loadingImage, .waitCurtain, .progressBar'); - const isLoading = loading && loading.offsetWidth > 0; - const formCount = document.querySelectorAll('input.editInput[id], a.press[id]').length; - return { isLoading, formCount }; - })()`); - - if (status.isLoading) { - stableCount = 0; - continue; - } - - // Check DOM stability by comparing element count snapshot - const snapshot = String(status.formCount); - if (snapshot === lastSnapshot) { - stableCount++; - } else { - stableCount = 0; - lastSnapshot = snapshot; - } - - // If form was expected to change, ensure it did - if (previousFormNum !== null && stableCount === 1) { - const currentForm = await page.evaluate(detectFormScript()); - if (currentForm !== previousFormNum) { - // Form changed — still wait for stability - } - } - - if (stableCount >= STABLE_CYCLES) return; - } - // Fallback: max wait reached -} - -/** - * Start monitoring network activity via CDP. - * Must be called BEFORE the click so it captures all server requests. - * Returns a monitor object with waitDone() and cleanup() methods. - */ -async function startNetworkMonitor() { - const client = await page.context().newCDPSession(page); - await client.send('Network.enable'); - - let pending = 0; - let total = 0; - let lastZeroTime = null; - const DEBOUNCE = 300; - - client.on('Network.requestWillBeSent', () => { - pending++; - total++; - lastZeroTime = null; - }); - client.on('Network.loadingFinished', () => { - if (--pending === 0) lastZeroTime = Date.now(); - }); - client.on('Network.loadingFailed', () => { - if (--pending === 0) lastZeroTime = Date.now(); - }); - - return { - /** Wait until all network requests complete (300ms debounce) or UI element appears. */ - async waitDone(timeout = 10000) { - const start = Date.now(); - while (Date.now() - start < timeout) { - await page.waitForTimeout(50); - - // Check for UI elements (modal, balloon, confirm) - const ui = await page.evaluate(`(() => { - const modal = document.querySelector('#modalSurface:not([style*="display: none"])'); - const balloon = document.querySelector('.balloon'); - const confirm = document.querySelector('.confirm'); - return !!(modal || balloon || confirm); - })()`); - if (ui) return; - - // CDP debounce: pending===0 held for DEBOUNCE ms - if (total > 0 && pending === 0 && lastZeroTime !== null) { - if (Date.now() - lastZeroTime >= DEBOUNCE) return; - } - } - }, - /** Detach CDP session. Always call this when done. */ - async cleanup() { - await client.send('Network.disable').catch(() => {}); - await client.detach().catch(() => {}); - } - }; -} - -/** - * Poll until a JS expression returns truthy, or timeout (ms) expires. - * Resolves early — typically within 100-300ms instead of fixed delays. - */ -async function waitForCondition(evalScript, timeout = 2000) { - const start = Date.now(); - while (Date.now() - start < timeout) { - const result = await page.evaluate(evalScript); - if (result) return result; - await page.waitForTimeout(100); - } - return null; -} - -/** - * Check for validation errors / diagnostics after an action. - * Detects: inline balloon tooltip, messages panel, modal error dialog. - * Returns { balloon, messages[], modal } or null. - */ -async function checkForErrors() { - return await page.evaluate(checkErrorsScript()); -} - -/** - * Dismiss pending error modal if present (single OK button dialog). - * Called at the start of action functions so that a leftover error modal - * from a previous operation doesn't block the next action. - * Does NOT dismiss confirmations (Да/Нет — require user decision). - * Returns the dismissed error object or null. - */ -async function dismissPendingErrors() { - // Close leftover platform dialogs first (About, Support Info, Error Report) - // These block all interaction via modalSurface and are invisible to 1C form detection - try { - const pd = await _detectPlatformDialogs(); - if (pd.length) await _closePlatformDialogs(); - } catch { /* OK */ } - const err = await checkForErrors(); - if (!err?.modal) return null; - try { - // Target pressDefault within the modal's form container specifically - const formNum = err.modal.formNum; - const sel = formNum != null - ? `#form${formNum}_container a.press.pressDefault` - : 'a.press.pressDefault'; - const btn = await page.$(sel); - if (btn) { await btn.click({ force: true }); await page.waitForTimeout(500); } - } catch { /* OK */ } - await waitForStable(); - return err; -} - -/** - * Detect open platform-level dialogs (About, Support Info, Error Report). - * Returns array of { type, title? } for each detected dialog, or empty array. - */ -async function _detectPlatformDialogs() { - return await page.evaluate(() => { - const result = []; - // "О программе" dialog - const about = document.getElementById('aboutContainer'); - if (about && about.offsetWidth > 0) result.push({ type: 'about', title: 'О программе' }); - // "Информация для технической поддержки" (inside a ps*win with errJournalInput) - const errJ = document.getElementById('errJournalInput'); - if (errJ && errJ.offsetWidth > 0) result.push({ type: 'supportInfo', title: 'Информация для технической поддержки' }); - // "Отчет об ошибке" / "Подробный текст ошибки" — ps*win cloud windows without aboutContainer - if (!result.length) { - document.querySelectorAll('[id^="ps"][id$="win"]').forEach(w => { - if (w.offsetWidth === 0 || w.offsetHeight === 0) return; - // Skip the main app window (ps*win that contains the 1C forms) - if (w.querySelector('[id^="form"][id$="_container"]')) return; - // Check title text - const titleEl = w.querySelector('[id$="headerTopLine_cmd_Title"]'); - const title = titleEl?.textContent?.trim() || ''; - if (title) result.push({ type: 'platformWindow', title }); - }); - } - return result; - }); -} - -/** - * Close any platform-level dialogs that may be left open (about, support info, error report). - * These are NOT 1C forms — they are platform UI overlays invisible to getFormState(). - * Each close is wrapped in try/catch to avoid cascading failures. - */ -async function _closePlatformDialogs() { - await page.evaluate(() => { - // "Подробный текст ошибки" OK button (inside error report detail view) - // It's a cloud window with its own OK button — look for visible pressDefault in small ps*win - const psWins = document.querySelectorAll('[id^="ps"][id$="win"]'); - for (const w of psWins) { - if (w.offsetWidth === 0) continue; - // Check if this is a small dialog (error detail, about, support info) - const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]'); - if (closeBtn && closeBtn.offsetWidth > 0) { - try { closeBtn.click(); } catch {} - } - } - // "Информация для технической поддержки" — extOkBtn - const extOk = document.getElementById('extOkBtn'); - if (extOk && extOk.offsetWidth > 0) try { extOk.click(); } catch {} - // "О программе" — aboutOkButton - const aboutOk = document.getElementById('aboutOkButton'); - if (aboutOk && aboutOk.offsetWidth > 0) try { aboutOk.click(); } catch {} - }); - await page.waitForTimeout(300); -} - -/** - * Parse raw error stack text into structured entries. - * Input: raw text from errJournalInput (first block) or "Подробный текст ошибки" textarea. - * Returns { raw, timestamp?, entries: [{location, code}] } - */ -function _parseErrorStack(raw) { - if (!raw) return null; - const result = { raw, entries: [] }; - // Extract timestamp if present (format: DD.MM.YYYY HH:MM:SS) - const tsMatch = raw.match(/^(\d{2}\.\d{2}\.\d{4}\s+\d{1,2}:\d{2}:\d{2})/m); - if (tsMatch) result.timestamp = tsMatch[1]; - // Extract {Module.Path(lineNum)}: code entries - const entryRe = /\{([^}]+)\}:\s*(.+)/g; - let m; - while ((m = entryRe.exec(raw)) !== null) { - result.entries.push({ location: m[1].trim(), code: m[2].trim() }); - } - return result.entries.length > 0 ? result : null; -} - -/** - * Fetch error call stack from the 1C platform UI. - * Uses two strategies: - * Path 1 (hasReport=true): Click OpenReport link → "подробный текст ошибки" → read textarea - * Path 2 (fallback): Hamburger → "О программе" → "Информация для техподдержки" → errJournalInput - * - * Always closes the error modal and any platform dialogs it opened. - * Returns parsed stack object or null on failure. - * - * @param {number} formNum - form number of the error modal (e.g. 6 for form6_) - * @param {boolean} hasReport - true if OpenReport link is available - */ -export async function fetchErrorStack(formNum, hasReport) { - try { - // Platform exception modals are initially unstable — they redraw within ~1s. - // The initial state may lack the OpenReport link. Re-check after a short delay. - if (!hasReport) { - await page.waitForTimeout(1500); - hasReport = await page.evaluate((fn) => { - const el = document.getElementById('form' + fn + '_OpenReport#text'); - return !!(el && el.offsetWidth > 2 && el.textContent.trim()); - }, formNum); - } - if (hasReport) return await _fetchStackViaReport(formNum); - return await _fetchStackViaHamburger(formNum); - } catch { - return null; - } finally { - // Ensure all platform dialogs are closed - try { await _closePlatformDialogs(); } catch {} - // Ensure the error modal itself is closed - try { - const sel = formNum != null - ? `#form${formNum}_container a.press.pressDefault` - : 'a.press.pressDefault'; - const btn = await page.$(sel); - if (btn) await btn.click({ force: true }); - await page.waitForTimeout(300); - } catch {} - } -} - -/** - * Path 1: Fetch stack via OpenReport link (for platform exceptions). - * The error modal must still be open with a visible "Сформировать отчет об ошибке" link. - */ -async function _fetchStackViaReport(formNum) { - // 1. Get coordinates of the OpenReport link and click via mouse (modalSurface blocks JS clicks) - const coords = await page.evaluate((fn) => { - const el = document.getElementById('form' + fn + '_OpenReport#text'); - if (!el || el.offsetWidth <= 2) return null; - const rect = el.getBoundingClientRect(); - return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; - }, formNum); - if (!coords) return null; - - await page.mouse.click(coords.x, coords.y); - - // 2. Wait for "Отчет об ошибке" dialog — look for "подробный текст ошибки" link - let found = false; - for (let i = 0; i < 20; i++) { - await page.waitForTimeout(500); - found = await page.evaluate(() => { - const links = document.querySelectorAll('a, [class*="hyper"], span'); - for (const el of links) { - if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true; - } - return false; - }); - if (found) break; - } - if (!found) return null; - - // 3. Click "подробный текст ошибки" - await page.getByText('подробный текст ошибки').click(); - await page.waitForTimeout(2000); - - // 4. Read the textarea with detailed error text (find the largest visible textarea) - const raw = await page.evaluate(() => { - let best = null; - document.querySelectorAll('textarea').forEach(ta => { - if (ta.offsetWidth > 0 && ta.value.length > 0) { - if (!best || ta.value.length > best.value.length) best = ta; - } - }); - return best?.value || null; - }); - - // 5. Close "Подробный текст ошибки" dialog (click its OK button) - try { - const okBtn = await page.evaluate(() => { - // Find the OK button in the topmost small cloud window - const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')] - .filter(w => w.offsetWidth > 0) - .sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0')); - for (const w of psWins) { - const ok = w.querySelector('button.webBtn, .pressDefault'); - if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; } - } - return false; - }); - await page.waitForTimeout(300); - } catch {} - - // 6. Close "Отчет об ошибке" dialog (click its × close button) - try { - await page.evaluate(() => { - const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')] - .filter(w => w.offsetWidth > 0); - for (const w of psWins) { - const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]'); - if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; } - } - }); - await page.waitForTimeout(300); - } catch {} - - return _parseErrorStack(raw); -} - -/** - * Path 2: Fetch stack via hamburger menu → "О программе" → "Информация для техподдержки". - * Works for all error types including simple ВызватьИсключение. - * The error modal is closed first to allow access to the hamburger menu. - */ -async function _fetchStackViaHamburger(formNum) { - // 1. Close the error modal first - try { - const sel = formNum != null - ? `#form${formNum}_container a.press.pressDefault` - : 'a.press.pressDefault'; - const btn = await page.$(sel); - if (btn) await btn.click({ force: true }); - await page.waitForTimeout(500); - } catch {} - - // 2. Click hamburger menu - await page.click('#captionbarMore', { timeout: 5000 }); - await page.waitForTimeout(1000); - - // 3. Click "О программе..." - await page.getByText('О программе...', { exact: true }).click({ timeout: 5000 }); - await page.waitForTimeout(2000); - - // 4. Click "Информация для технической поддержки" - await page.click('#aboutHyperLink', { timeout: 5000 }); - - // 5. Wait for errJournalInput to appear and be filled - let raw = null; - for (let i = 0; i < 20; i++) { - await page.waitForTimeout(500); - raw = await page.evaluate(() => { - const el = document.getElementById('errJournalInput'); - return (el && el.offsetWidth > 0 && el.value.length > 50) ? el.value : null; - }); - if (raw) break; - } - if (!raw) return null; - - // 6. Parse first error block (most recent — before first separator) - const separator = / - - - - /; - const errSection = raw.indexOf('\n\n') !== -1 ? raw.substring(raw.indexOf('\n\n')) : raw; - // Find the "Ошибки:" section - const errIdx = raw.indexOf('Ошибки:'); - let errorText = errIdx !== -1 ? raw.substring(errIdx + 'Ошибки:'.length).trim() : raw; - // Take first block (before first separator line) - const lines = errorText.split('\n'); - const firstBlockLines = []; - let inBlock = false; - for (const line of lines) { - if (separator.test(line)) { - if (inBlock) break; // end of first block - inBlock = true; - continue; - } - if (inBlock) firstBlockLines.push(line); - } - const firstBlock = firstBlockLines.join('\n').trim(); - - // 7. Close support info and about dialogs (done in finally via _closePlatformDialogs) - return _parseErrorStack(firstBlock || errorText); -} +// ============================================================ +// Wait + error/modal handling — extracted to core/{wait,errors}.mjs +// ============================================================ +import { + waitForStable, waitForCondition, startNetworkMonitor, +} from './core/wait.mjs'; +import { + closeModals, checkForErrors, dismissPendingErrors, fetchErrorStack, +} from './core/errors.mjs'; +// Re-export only what was publicly exported before the refactor. +// waitForStable/waitForCondition/startNetworkMonitor/closeModals/checkForErrors/ +// dismissPendingErrors are internal helpers — imported above for local use only. +export { fetchErrorStack } from './core/errors.mjs'; /* getPage moved to core/state.mjs */ diff --git a/.claude/skills/web-test/scripts/core/errors.mjs b/.claude/skills/web-test/scripts/core/errors.mjs new file mode 100644 index 00000000..13e96233 --- /dev/null +++ b/.claude/skills/web-test/scripts/core/errors.mjs @@ -0,0 +1,341 @@ +// web-test core/errors v1.16 — Error/modal/platform-dialog handling: dismiss, detect, fetch stack from 1C UI. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page } from './state.mjs'; +import { checkErrorsScript } from '../dom.mjs'; +import { waitForStable } from './wait.mjs'; + +/** + * Close startup modals and guide tabs. + * Strategy: Escape → click default buttons → close extra tabs → repeat. + */ +export async function closeModals() { + for (let attempt = 0; attempt < 5; attempt++) { + // 1. Press Escape to dismiss any popup/modal + await page.keyboard.press('Escape'); + await page.waitForTimeout(1000); + + // 2. Try clicking default "Закрыть"/"OK" buttons + const clicked = await page.evaluate(`(() => { + const btns = [...document.querySelectorAll('a.press.pressDefault')].filter(el => el.offsetWidth > 0); + for (const btn of btns) { + const text = (btn.innerText?.trim() || '').toLowerCase(); + if (['закрыть', 'ok', 'ок', 'нет', 'отмена'].includes(text)) { + btn.click(); + return text; + } + } + return null; + })()`); + if (clicked) { await page.waitForTimeout(1000); continue; } + + // 3. Close extra tabs (Путеводитель etc.) via openedClose button + const tabClosed = await page.evaluate(`(() => { + const btn = document.querySelector('.openedClose'); + if (btn && btn.offsetWidth > 0) { btn.click(); return true; } + return false; + })()`); + if (tabClosed) { await page.waitForTimeout(1000); continue; } + + // Nothing to close — done + break; + } +} + +/** + * Check for validation errors / diagnostics after an action. + * Detects: inline balloon tooltip, messages panel, modal error dialog. + * Returns { balloon, messages[], modal } or null. + */ +export async function checkForErrors() { + return await page.evaluate(checkErrorsScript()); +} + +/** + * Dismiss pending error modal if present (single OK button dialog). + * Called at the start of action functions so that a leftover error modal + * from a previous operation doesn't block the next action. + * Does NOT dismiss confirmations (Да/Нет — require user decision). + * Returns the dismissed error object or null. + */ +export async function dismissPendingErrors() { + // Close leftover platform dialogs first (About, Support Info, Error Report) + // These block all interaction via modalSurface and are invisible to 1C form detection + try { + const pd = await _detectPlatformDialogs(); + if (pd.length) await _closePlatformDialogs(); + } catch { /* OK */ } + const err = await checkForErrors(); + if (!err?.modal) return null; + try { + // Target pressDefault within the modal's form container specifically + const formNum = err.modal.formNum; + const sel = formNum != null + ? `#form${formNum}_container a.press.pressDefault` + : 'a.press.pressDefault'; + const btn = await page.$(sel); + if (btn) { await btn.click({ force: true }); await page.waitForTimeout(500); } + } catch { /* OK */ } + await waitForStable(); + return err; +} + +/** + * Detect open platform-level dialogs (About, Support Info, Error Report). + * Returns array of { type, title? } for each detected dialog, or empty array. + */ +async function _detectPlatformDialogs() { + return await page.evaluate(() => { + const result = []; + // "О программе" dialog + const about = document.getElementById('aboutContainer'); + if (about && about.offsetWidth > 0) result.push({ type: 'about', title: 'О программе' }); + // "Информация для технической поддержки" (inside a ps*win with errJournalInput) + const errJ = document.getElementById('errJournalInput'); + if (errJ && errJ.offsetWidth > 0) result.push({ type: 'supportInfo', title: 'Информация для технической поддержки' }); + // "Отчет об ошибке" / "Подробный текст ошибки" — ps*win cloud windows without aboutContainer + if (!result.length) { + document.querySelectorAll('[id^="ps"][id$="win"]').forEach(w => { + if (w.offsetWidth === 0 || w.offsetHeight === 0) return; + // Skip the main app window (ps*win that contains the 1C forms) + if (w.querySelector('[id^="form"][id$="_container"]')) return; + // Check title text + const titleEl = w.querySelector('[id$="headerTopLine_cmd_Title"]'); + const title = titleEl?.textContent?.trim() || ''; + if (title) result.push({ type: 'platformWindow', title }); + }); + } + return result; + }); +} + +/** + * Close any platform-level dialogs that may be left open (about, support info, error report). + * These are NOT 1C forms — they are platform UI overlays invisible to getFormState(). + * Each close is wrapped in try/catch to avoid cascading failures. + */ +async function _closePlatformDialogs() { + await page.evaluate(() => { + // "Подробный текст ошибки" OK button (inside error report detail view) + // It's a cloud window with its own OK button — look for visible pressDefault in small ps*win + const psWins = document.querySelectorAll('[id^="ps"][id$="win"]'); + for (const w of psWins) { + if (w.offsetWidth === 0) continue; + // Check if this is a small dialog (error detail, about, support info) + const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]'); + if (closeBtn && closeBtn.offsetWidth > 0) { + try { closeBtn.click(); } catch {} + } + } + // "Информация для технической поддержки" — extOkBtn + const extOk = document.getElementById('extOkBtn'); + if (extOk && extOk.offsetWidth > 0) try { extOk.click(); } catch {} + // "О программе" — aboutOkButton + const aboutOk = document.getElementById('aboutOkButton'); + if (aboutOk && aboutOk.offsetWidth > 0) try { aboutOk.click(); } catch {} + }); + await page.waitForTimeout(300); +} + +/** + * Parse raw error stack text into structured entries. + * Input: raw text from errJournalInput (first block) or "Подробный текст ошибки" textarea. + * Returns { raw, timestamp?, entries: [{location, code}] } + */ +function _parseErrorStack(raw) { + if (!raw) return null; + const result = { raw, entries: [] }; + // Extract timestamp if present (format: DD.MM.YYYY HH:MM:SS) + const tsMatch = raw.match(/^(\d{2}\.\d{2}\.\d{4}\s+\d{1,2}:\d{2}:\d{2})/m); + if (tsMatch) result.timestamp = tsMatch[1]; + // Extract {Module.Path(lineNum)}: code entries + const entryRe = /\{([^}]+)\}:\s*(.+)/g; + let m; + while ((m = entryRe.exec(raw)) !== null) { + result.entries.push({ location: m[1].trim(), code: m[2].trim() }); + } + return result.entries.length > 0 ? result : null; +} + +/** + * Fetch error call stack from the 1C platform UI. + * Uses two strategies: + * Path 1 (hasReport=true): Click OpenReport link → "подробный текст ошибки" → read textarea + * Path 2 (fallback): Hamburger → "О программе" → "Информация для техподдержки" → errJournalInput + * + * Always closes the error modal and any platform dialogs it opened. + * Returns parsed stack object or null on failure. + * + * @param {number} formNum - form number of the error modal (e.g. 6 for form6_) + * @param {boolean} hasReport - true if OpenReport link is available + */ +export async function fetchErrorStack(formNum, hasReport) { + try { + // Platform exception modals are initially unstable — they redraw within ~1s. + // The initial state may lack the OpenReport link. Re-check after a short delay. + if (!hasReport) { + await page.waitForTimeout(1500); + hasReport = await page.evaluate((fn) => { + const el = document.getElementById('form' + fn + '_OpenReport#text'); + return !!(el && el.offsetWidth > 2 && el.textContent.trim()); + }, formNum); + } + if (hasReport) return await _fetchStackViaReport(formNum); + return await _fetchStackViaHamburger(formNum); + } catch { + return null; + } finally { + // Ensure all platform dialogs are closed + try { await _closePlatformDialogs(); } catch {} + // Ensure the error modal itself is closed + try { + const sel = formNum != null + ? `#form${formNum}_container a.press.pressDefault` + : 'a.press.pressDefault'; + const btn = await page.$(sel); + if (btn) await btn.click({ force: true }); + await page.waitForTimeout(300); + } catch {} + } +} + +/** + * Path 1: Fetch stack via OpenReport link (for platform exceptions). + * The error modal must still be open with a visible "Сформировать отчет об ошибке" link. + */ +async function _fetchStackViaReport(formNum) { + // 1. Get coordinates of the OpenReport link and click via mouse (modalSurface blocks JS clicks) + const coords = await page.evaluate((fn) => { + const el = document.getElementById('form' + fn + '_OpenReport#text'); + if (!el || el.offsetWidth <= 2) return null; + const rect = el.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + }, formNum); + if (!coords) return null; + + await page.mouse.click(coords.x, coords.y); + + // 2. Wait for "Отчет об ошибке" dialog — look for "подробный текст ошибки" link + let found = false; + for (let i = 0; i < 20; i++) { + await page.waitForTimeout(500); + found = await page.evaluate(() => { + const links = document.querySelectorAll('a, [class*="hyper"], span'); + for (const el of links) { + if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true; + } + return false; + }); + if (found) break; + } + if (!found) return null; + + // 3. Click "подробный текст ошибки" + await page.getByText('подробный текст ошибки').click(); + await page.waitForTimeout(2000); + + // 4. Read the textarea with detailed error text (find the largest visible textarea) + const raw = await page.evaluate(() => { + let best = null; + document.querySelectorAll('textarea').forEach(ta => { + if (ta.offsetWidth > 0 && ta.value.length > 0) { + if (!best || ta.value.length > best.value.length) best = ta; + } + }); + return best?.value || null; + }); + + // 5. Close "Подробный текст ошибки" dialog (click its OK button) + try { + const okBtn = await page.evaluate(() => { + // Find the OK button in the topmost small cloud window + const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')] + .filter(w => w.offsetWidth > 0) + .sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0')); + for (const w of psWins) { + const ok = w.querySelector('button.webBtn, .pressDefault'); + if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; } + } + return false; + }); + await page.waitForTimeout(300); + } catch {} + + // 6. Close "Отчет об ошибке" dialog (click its × close button) + try { + await page.evaluate(() => { + const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')] + .filter(w => w.offsetWidth > 0); + for (const w of psWins) { + const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]'); + if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; } + } + }); + await page.waitForTimeout(300); + } catch {} + + return _parseErrorStack(raw); +} + +/** + * Path 2: Fetch stack via hamburger menu → "О программе" → "Информация для техподдержки". + * Works for all error types including simple ВызватьИсключение. + * The error modal is closed first to allow access to the hamburger menu. + */ +async function _fetchStackViaHamburger(formNum) { + // 1. Close the error modal first + try { + const sel = formNum != null + ? `#form${formNum}_container a.press.pressDefault` + : 'a.press.pressDefault'; + const btn = await page.$(sel); + if (btn) await btn.click({ force: true }); + await page.waitForTimeout(500); + } catch {} + + // 2. Click hamburger menu + await page.click('#captionbarMore', { timeout: 5000 }); + await page.waitForTimeout(1000); + + // 3. Click "О программе..." + await page.getByText('О программе...', { exact: true }).click({ timeout: 5000 }); + await page.waitForTimeout(2000); + + // 4. Click "Информация для технической поддержки" + await page.click('#aboutHyperLink', { timeout: 5000 }); + + // 5. Wait for errJournalInput to appear and be filled + let raw = null; + for (let i = 0; i < 20; i++) { + await page.waitForTimeout(500); + raw = await page.evaluate(() => { + const el = document.getElementById('errJournalInput'); + return (el && el.offsetWidth > 0 && el.value.length > 50) ? el.value : null; + }); + if (raw) break; + } + if (!raw) return null; + + // 6. Parse first error block (most recent — before first separator) + const separator = / - - - - /; + const errSection = raw.indexOf('\n\n') !== -1 ? raw.substring(raw.indexOf('\n\n')) : raw; + // Find the "Ошибки:" section + const errIdx = raw.indexOf('Ошибки:'); + let errorText = errIdx !== -1 ? raw.substring(errIdx + 'Ошибки:'.length).trim() : raw; + // Take first block (before first separator line) + const lines = errorText.split('\n'); + const firstBlockLines = []; + let inBlock = false; + for (const line of lines) { + if (separator.test(line)) { + if (inBlock) break; // end of first block + inBlock = true; + continue; + } + if (inBlock) firstBlockLines.push(line); + } + const firstBlock = firstBlockLines.join('\n').trim(); + + // 7. Close support info and about dialogs (done in finally via _closePlatformDialogs) + return _parseErrorStack(firstBlock || errorText); +} diff --git a/.claude/skills/web-test/scripts/core/wait.mjs b/.claude/skills/web-test/scripts/core/wait.mjs new file mode 100644 index 00000000..8e58f41a --- /dev/null +++ b/.claude/skills/web-test/scripts/core/wait.mjs @@ -0,0 +1,123 @@ +// web-test core/wait v1.16 — Smart wait helpers: DOM stability polling, JS-expression polling, CDP network monitor. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES } from './state.mjs'; +import { detectFormScript } from '../dom.mjs'; + +/** + * Smart wait: poll until DOM is stable and no loading indicators are visible. + * Checks: form number change, loading indicators, DOM stability. + * @param {number|null} previousFormNum — form number before the action (null = don't check) + */ +export async function waitForStable(previousFormNum = null) { + let stableCount = 0; + let lastSnapshot = ''; + const start = Date.now(); + + while (Date.now() - start < MAX_WAIT) { + await page.waitForTimeout(POLL_INTERVAL); + + // Check for loading indicators + const status = await page.evaluate(`(() => { + const loading = document.querySelector('.loadingImage, .waitCurtain, .progressBar'); + const isLoading = loading && loading.offsetWidth > 0; + const formCount = document.querySelectorAll('input.editInput[id], a.press[id]').length; + return { isLoading, formCount }; + })()`); + + if (status.isLoading) { + stableCount = 0; + continue; + } + + // Check DOM stability by comparing element count snapshot + const snapshot = String(status.formCount); + if (snapshot === lastSnapshot) { + stableCount++; + } else { + stableCount = 0; + lastSnapshot = snapshot; + } + + // If form was expected to change, ensure it did + if (previousFormNum !== null && stableCount === 1) { + const currentForm = await page.evaluate(detectFormScript()); + if (currentForm !== previousFormNum) { + // Form changed — still wait for stability + } + } + + if (stableCount >= STABLE_CYCLES) return; + } + // Fallback: max wait reached +} + +/** + * Start monitoring network activity via CDP. + * Must be called BEFORE the click so it captures all server requests. + * Returns a monitor object with waitDone() and cleanup() methods. + */ +export async function startNetworkMonitor() { + const client = await page.context().newCDPSession(page); + await client.send('Network.enable'); + + let pending = 0; + let total = 0; + let lastZeroTime = null; + const DEBOUNCE = 300; + + client.on('Network.requestWillBeSent', () => { + pending++; + total++; + lastZeroTime = null; + }); + client.on('Network.loadingFinished', () => { + if (--pending === 0) lastZeroTime = Date.now(); + }); + client.on('Network.loadingFailed', () => { + if (--pending === 0) lastZeroTime = Date.now(); + }); + + return { + /** Wait until all network requests complete (300ms debounce) or UI element appears. */ + async waitDone(timeout = 10000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + await page.waitForTimeout(50); + + // Check for UI elements (modal, balloon, confirm) + const ui = await page.evaluate(`(() => { + const modal = document.querySelector('#modalSurface:not([style*="display: none"])'); + const balloon = document.querySelector('.balloon'); + const confirm = document.querySelector('.confirm'); + return !!(modal || balloon || confirm); + })()`); + if (ui) return; + + // CDP debounce: pending===0 held for DEBOUNCE ms + if (total > 0 && pending === 0 && lastZeroTime !== null) { + if (Date.now() - lastZeroTime >= DEBOUNCE) return; + } + } + }, + /** Detach CDP session. Always call this when done. */ + async cleanup() { + await client.send('Network.disable').catch(() => {}); + await client.detach().catch(() => {}); + } + }; +} + +/** + * Poll until a JS expression returns truthy, or timeout (ms) expires. + * Resolves early — typically within 100-300ms instead of fixed delays. + */ +export async function waitForCondition(evalScript, timeout = 2000) { + const start = Date.now(); + while (Date.now() - start < timeout) { + const result = await page.evaluate(evalScript); + if (result) return result; + await page.waitForTimeout(100); + } + return null; +} From fca65ef658cf03206b4f27db50b8545111dd2f89 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 25 May 2026 22:12:07 +0300 Subject: [PATCH 04/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20A.4=20=E2=80=94=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20core/session.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перенос session-функций из browser.mjs (~380 LOC): - connect, disconnect, attach, detach, getSession - createContext, setActiveContext, listContexts, getActiveContext, hasContext, closeContext - findExtension (приватная) - _logoutSlot, _saveActiveSlot, _activateSlot, _attachSessionListeners (приватные multi-context хелперы) Session-модуль зависит от core/state, core/errors (closeModals), recording/capture (stopRecording) и циклически от browser.mjs (getPageState — переедет в nav/navigation.mjs на этапе C.7). ESM live-binding делает цикл безопасным: getPageState вызывается только внутри async функций, а не на этапе загрузки модуля. browser.mjs: 4251 LOC, 56 публичных экспортов. Завершает Чекпоинт A. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 390 +---------------- .../skills/web-test/scripts/core/session.mjs | 407 ++++++++++++++++++ 2 files changed, 413 insertions(+), 384 deletions(-) create mode 100644 .claude/skills/web-test/scripts/core/session.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 56168171..9f9fd9f9 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -123,392 +123,14 @@ export async function pasteText(text, { confirm = 'Control+V', postDelay = 0 } = } } -/** - * Find the 1C browser extension in Chrome/Edge user profiles. - * Returns the path to the latest version, or null if not found. - * Can be overridden via extensionPath in .v8-project.json. - */ -function findExtension(overridePath) { - if (overridePath) { - try { if (statSync(overridePath).isDirectory()) return overridePath; } catch {} - return null; - } - const localAppData = process.env.LOCALAPPDATA; - if (!localAppData) return null; - const browsers = [ - pathJoin(localAppData, 'Google', 'Chrome', 'User Data'), - pathJoin(localAppData, 'Microsoft', 'Edge', 'User Data'), - ]; - for (const userData of browsers) { - try { if (!statSync(userData).isDirectory()) continue; } catch { continue; } - let profiles; - try { profiles = readdirSync(userData).filter(d => d === 'Default' || d.startsWith('Profile ')); } catch { continue; } - for (const profile of profiles) { - const extDir = pathJoin(userData, profile, 'Extensions', EXT_ID); - try { if (!statSync(extDir).isDirectory()) continue; } catch { continue; } - let versions; - try { versions = readdirSync(extDir).filter(d => /^\d/.test(d)).sort(); } catch { continue; } - if (versions.length > 0) { - const best = pathJoin(extDir, versions[versions.length - 1]); - try { if (statSync(pathJoin(best, 'manifest.json')).isFile()) return best; } catch {} - } - } - } - return null; -} - -/* isConnected moved to core/state.mjs */ - -/** - * Open browser and navigate to 1C web client URL. - * Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals. - */ -export async function connect(url, { extensionPath } = {}) { - if (isConnected()) { - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); - } else { - const extPath = findExtension(extensionPath); - if (extPath) { - // Launch with 1C browser extension via persistent context - setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now())); - mkdirSync(persistentUserDataDir, { recursive: true }); - const context = await chromium.launchPersistentContext(persistentUserDataDir, { - headless: false, - args: [ - '--start-maximized', - '--disable-extensions-except=' + extPath, - '--load-extension=' + extPath, - ], - viewport: null, - permissions: ['clipboard-read', 'clipboard-write'], - }); - setBrowser(context); // persistent context IS the browser - setPage(context.pages()[0] || await context.newPage()); - } else { - // Fallback: launch without extension - setBrowser(await chromium.launch({ headless: false, args: ['--start-maximized'] })); - const context = await browser.newContext({ - viewport: null, - permissions: ['clipboard-read', 'clipboard-write'], - }); - setPage(await context.newPage()); - } - - // Auto-accept native browser dialogs (confirm/alert from 1C scripts like vis.js) - page.on('dialog', dialog => dialog.accept().catch(() => {})); - - // Capture seanceId from network requests for graceful logout - setSessionPrefix(null); - setSeanceId(null); - page.on('request', req => { - if (seanceId) return; - const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/); - if (m) { setSessionPrefix(m[1]); setSeanceId(m[2]); } - }); - - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); - } - - // Wait for 1C to initialize — detect by section panel appearance - try { - await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); - } catch { - // Fallback: wait fixed time if selector doesn't appear (e.g. login page) - await page.waitForTimeout(5000); - } - - // Try to close startup modals (Путеводитель etc.) - await closeModals(); - - return await getPageState(); -} - -/** - * Best-effort POST /e1cib/logout on a slot to release the 1C session license. - * Silent — if page is closed or session info missing, just returns. - * @param {object} slot { page, sessionPrefix, seanceId } from contexts Map - * @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process) - */ -async function _logoutSlot(slot, waitMs = 500) { - if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return; - try { - const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`; - await slot.page.evaluate(async (url) => { - await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' }); - }, logoutUrl); - await slot.page.waitForTimeout(waitMs); - } catch {} -} - -/** - * Gracefully terminate the 1C session and close the browser. - * Sends POST /e1cib/logout to release the license before closing. - */ -export async function disconnect() { - // Multi-context path: stop recording + logout each slot before closing browser - if (contexts.size > 0) { - _saveActiveSlot(); - // Recorder is global — one stop covers all contexts - if (recorder) { - try { await stopRecording(); } catch {} - } - for (const [, slot] of contexts.entries()) { - await _logoutSlot(slot); - } - contexts.clear(); - setActiveContextName(null); - setActiveMode(null); - } - - // Single-session path (connect): auto-stop recording if active - if (recorder) { - try { await stopRecording(); } catch {} - } - - if (browser) { - // Graceful logout — release the 1C license (single-session connect path) - await _logoutSlot({ page, sessionPrefix, seanceId }, 1000); - await browser.close().catch(() => {}); - setBrowser(null); - setPage(null); - setSessionPrefix(null); - setSeanceId(null); - // Clean up persistent user data dir - if (persistentUserDataDir) { - try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {} - setPersistentUserDataDir(null); - } - } -} - -/** - * Attach to a running browser server via CDP WebSocket. - * Sets module state so all functions (getFormState, clickElement, etc.) work. - */ -export async function attach(wsEndpoint, session = {}) { - if (isConnected()) return; - setBrowser(await chromium.connect(wsEndpoint)); - const ctx = browser.contexts()[0]; - setPage(ctx?.pages()[0]); - if (!page) throw new Error('No page found in browser'); - setSessionPrefix(session.sessionPrefix || null); - setSeanceId(session.seanceId || null); -} - -/** - * Detach from browser without closing it. - * Returns session state for persistence. - */ -export function detach() { - const session = { sessionPrefix, seanceId }; - setBrowser(null); - setPage(null); - setSessionPrefix(null); - setSeanceId(null); - return session; -} - -/** Get current session state (for saving between reconnections). */ -export function getSession() { - return { sessionPrefix, seanceId }; -} - // ============================================================ -// Multi-context support (used by run.mjs cmdTest only) +// Session lifecycle + multi-context — extracted to core/session.mjs // ============================================================ - -/** - * Save current module-level state into the active slot before switching. - * No-op if no active slot. - */ -function _saveActiveSlot() { - if (!activeContextName) return; - const slot = contexts.get(activeContextName); - if (!slot) return; - slot.page = page; - slot.sessionPrefix = sessionPrefix; - slot.seanceId = seanceId; - slot.highlightMode = highlightMode; - // Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT - // mirrored per-slot. A multi-context recording produces one continuous output file — - // the recorder follows the active page via recorder._attachPage(), not per-slot state. -} - -/** Load a slot's state into module-level vars and mark it active. */ -function _activateSlot(name) { - const slot = contexts.get(name); - if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`); - setPage(slot.page); - setSessionPrefix(slot.sessionPrefix); - setSeanceId(slot.seanceId); - setHighlightMode(slot.highlightMode || false); - setActiveContextName(name); -} - -/** Attach 1C session listeners to a page, writing into the given slot. */ -function _attachSessionListeners(pg, slot, name) { - pg.on('dialog', dialog => dialog.accept().catch(() => {})); - pg.on('request', req => { - if (slot.seanceId) return; - const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/); - if (m) { - slot.sessionPrefix = m[1]; - slot.seanceId = m[2]; - if (activeContextName === name) { - setSessionPrefix(m[1]); - setSeanceId(m[2]); - } - } - }); -} - -/** - * Create (or navigate) a named browser context. - * First call launches Chromium via chromium.launch() (NOT launchPersistentContext) so that - * subsequent calls can create additional isolated BrowserContexts in the same process. - * Trade-off: 1C browser extension is loaded via --load-extension (process-level) rather than - * persistent profile. - * - * Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the - * legacy persistent-context path. - */ -export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) { - if (contexts.has(name)) { - await setActiveContext(name); - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); - try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); } - catch { await page.waitForTimeout(5000); } - await closeModals(); - return await getPageState(); - } - - if (!['tab', 'window'].includes(isolation)) { - throw new Error(`createContext: invalid isolation "${isolation}", expected 'tab' or 'window'`); - } - if (activeMode && activeMode !== isolation) { - throw new Error(`createContext: cannot mix isolation modes — first context used "${activeMode}", "${name}" requested "${isolation}". Use the same mode for all contexts in one run.`); - } - - // First context: launch browser. Subsequent: reuse existing. - let isFirstContext = !browser; - if (isFirstContext) { - const extPath = findExtension(extensionPath); - const launchArgs = ['--start-maximized']; - if (extPath) { - launchArgs.push('--disable-extensions-except=' + extPath, '--load-extension=' + extPath); - } - if (isolation === 'tab') { - // Persistent context: extension loads reliably, one window with tabs per context - setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-test-' + Date.now())); - mkdirSync(persistentUserDataDir, { recursive: true }); - setBrowser(await chromium.launchPersistentContext(persistentUserDataDir, { - headless: false, - args: launchArgs, - viewport: null, - permissions: ['clipboard-read', 'clipboard-write'], - })); - } else { - // Window mode: separate BrowserContext per slot, full cookie isolation - setBrowser(await chromium.launch({ headless: false, args: launchArgs })); - } - setActiveMode(isolation); - } - - // Save current active before switching - _saveActiveSlot(); - - // Create slot — page differs by mode - let newCtx, newPage; - if (activeMode === 'tab') { - // Reuse the persistent context for all slots; each slot gets its own page (tab) - newCtx = browser; - if (isFirstContext) { - newPage = browser.pages()[0] || await browser.newPage(); - } else { - newPage = await browser.newPage(); - } - } else { - // Window mode: each slot owns its BrowserContext + page - newCtx = await browser.newContext({ - viewport: null, - permissions: ['clipboard-read', 'clipboard-write'], - }); - newPage = await newCtx.newPage(); - } - - const slot = { - context: newCtx, - page: newPage, - sessionPrefix: null, - seanceId: null, - highlightMode: false, - }; - contexts.set(name, slot); - - _attachSessionListeners(newPage, slot, name); - _activateSlot(name); - - await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); - try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); } - catch { await page.waitForTimeout(5000); } - await closeModals(); - - return await getPageState(); -} - -/** Switch the active context. Subsequent browser API calls operate on this context's page. */ -export async function setActiveContext(name) { - if (activeContextName === name) return; - if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`); - // If a recording is active, flush the outgoing page's last frame so the gap is filled - // up to the moment of the switch (avoids a "jump" in video time). - if (recorder && recorder._flushFrames) recorder._flushFrames(); - _saveActiveSlot(); - _activateSlot(name); - // If the recording is still alive (it lives across slots — we keep the same ffmpeg/output), - // re-attach its screencast to the newly active page. - if (recorder && recorder._attachPage) { - await recorder._attachPage(page); - } -} - -export function listContexts() { - return [...contexts.keys()]; -} - -export function getActiveContext() { - return activeContextName; -} - -export function hasContext(name) { - return contexts.has(name); -} - -/** - * Close a named context: logout, close its page (tab mode) or BrowserContext - * (window mode), remove from registry. Cannot close the currently active - * context — caller must setActiveContext to another first. This keeps the - * recorder/page invariants simple: recorder is always attached to the - * active slot, which closeContext never touches. - * - * @throws if name is not registered or equals the active context. - */ -export async function closeContext(name) { - if (!contexts.has(name)) { - throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`); - } - if (name === activeContextName) { - throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`); - } - const slot = contexts.get(name); - await _logoutSlot(slot); - if (activeMode === 'tab') { - try { await slot.page.close(); } catch {} - } else { - try { await slot.context.close(); } catch {} - } - contexts.delete(name); -} +export { + connect, disconnect, attach, detach, getSession, + createContext, setActiveContext, listContexts, getActiveContext, + hasContext, closeContext, +} from './core/session.mjs'; // ============================================================ // Wait + error/modal handling — extracted to core/{wait,errors}.mjs diff --git a/.claude/skills/web-test/scripts/core/session.mjs b/.claude/skills/web-test/scripts/core/session.mjs new file mode 100644 index 00000000..78905977 --- /dev/null +++ b/.claude/skills/web-test/scripts/core/session.mjs @@ -0,0 +1,407 @@ +// web-test core/session v1.16 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { chromium } from 'playwright'; +import { statSync, mkdirSync, readdirSync, rmSync } from 'fs'; +import { join as pathJoin } from 'path'; +import { tmpdir } from 'os'; +import { + browser, page, sessionPrefix, seanceId, recorder, highlightMode, + contexts, activeContextName, activeMode, persistentUserDataDir, + setBrowser, setPage, setSessionPrefix, setSeanceId, setHighlightMode, + setActiveContextName, setActiveMode, setPersistentUserDataDir, + isConnected, LOAD_TIMEOUT, INIT_TIMEOUT, EXT_ID, +} from './state.mjs'; +import { closeModals } from './errors.mjs'; +import { stopRecording } from '../recording/capture.mjs'; +// getPageState lives in browser.mjs (moves to nav/navigation.mjs in a later stage). +// Static import is a deliberate ESM cycle — fine because the binding is used at +// call time (inside async connect/createContext), not at module evaluation time. +import { getPageState } from '../browser.mjs'; + +/** + * Find the 1C browser extension in Chrome/Edge user profiles. + * Returns the path to the latest version, or null if not found. + * Can be overridden via extensionPath in .v8-project.json. + */ +function findExtension(overridePath) { + if (overridePath) { + try { if (statSync(overridePath).isDirectory()) return overridePath; } catch {} + return null; + } + const localAppData = process.env.LOCALAPPDATA; + if (!localAppData) return null; + const browsers = [ + pathJoin(localAppData, 'Google', 'Chrome', 'User Data'), + pathJoin(localAppData, 'Microsoft', 'Edge', 'User Data'), + ]; + for (const userData of browsers) { + try { if (!statSync(userData).isDirectory()) continue; } catch { continue; } + let profiles; + try { profiles = readdirSync(userData).filter(d => d === 'Default' || d.startsWith('Profile ')); } catch { continue; } + for (const profile of profiles) { + const extDir = pathJoin(userData, profile, 'Extensions', EXT_ID); + try { if (!statSync(extDir).isDirectory()) continue; } catch { continue; } + let versions; + try { versions = readdirSync(extDir).filter(d => /^\d/.test(d)).sort(); } catch { continue; } + if (versions.length > 0) { + const best = pathJoin(extDir, versions[versions.length - 1]); + try { if (statSync(pathJoin(best, 'manifest.json')).isFile()) return best; } catch {} + } + } + } + return null; +} + +/* isConnected moved to core/state.mjs */ + +/** + * Open browser and navigate to 1C web client URL. + * Waits for initialization (themesCell_theme_0 selector) and attempts to close startup modals. + */ +export async function connect(url, { extensionPath } = {}) { + if (isConnected()) { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); + } else { + const extPath = findExtension(extensionPath); + if (extPath) { + // Launch with 1C browser extension via persistent context + setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-ext-' + Date.now())); + mkdirSync(persistentUserDataDir, { recursive: true }); + const context = await chromium.launchPersistentContext(persistentUserDataDir, { + headless: false, + args: [ + '--start-maximized', + '--disable-extensions-except=' + extPath, + '--load-extension=' + extPath, + ], + viewport: null, + permissions: ['clipboard-read', 'clipboard-write'], + }); + setBrowser(context); // persistent context IS the browser + setPage(context.pages()[0] || await context.newPage()); + } else { + // Fallback: launch without extension + setBrowser(await chromium.launch({ headless: false, args: ['--start-maximized'] })); + const context = await browser.newContext({ + viewport: null, + permissions: ['clipboard-read', 'clipboard-write'], + }); + setPage(await context.newPage()); + } + + // Auto-accept native browser dialogs (confirm/alert from 1C scripts like vis.js) + page.on('dialog', dialog => dialog.accept().catch(() => {})); + + // Capture seanceId from network requests for graceful logout + setSessionPrefix(null); + setSeanceId(null); + page.on('request', req => { + if (seanceId) return; + const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/); + if (m) { setSessionPrefix(m[1]); setSeanceId(m[2]); } + }); + + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); + } + + // Wait for 1C to initialize — detect by section panel appearance + try { + await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); + } catch { + // Fallback: wait fixed time if selector doesn't appear (e.g. login page) + await page.waitForTimeout(5000); + } + + // Try to close startup modals (Путеводитель etc.) + await closeModals(); + + return await getPageState(); +} + +/** + * Best-effort POST /e1cib/logout on a slot to release the 1C session license. + * Silent — if page is closed or session info missing, just returns. + * @param {object} slot { page, sessionPrefix, seanceId } from contexts Map + * @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process) + */ +async function _logoutSlot(slot, waitMs = 500) { + if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return; + try { + const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`; + await slot.page.evaluate(async (url) => { + await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{"root":{}}' }); + }, logoutUrl); + await slot.page.waitForTimeout(waitMs); + } catch {} +} + +/** + * Gracefully terminate the 1C session and close the browser. + * Sends POST /e1cib/logout to release the license before closing. + */ +export async function disconnect() { + // Multi-context path: stop recording + logout each slot before closing browser + if (contexts.size > 0) { + _saveActiveSlot(); + // Recorder is global — one stop covers all contexts + if (recorder) { + try { await stopRecording(); } catch {} + } + for (const [, slot] of contexts.entries()) { + await _logoutSlot(slot); + } + contexts.clear(); + setActiveContextName(null); + setActiveMode(null); + } + + // Single-session path (connect): auto-stop recording if active + if (recorder) { + try { await stopRecording(); } catch {} + } + + if (browser) { + // Graceful logout — release the 1C license (single-session connect path) + await _logoutSlot({ page, sessionPrefix, seanceId }, 1000); + await browser.close().catch(() => {}); + setBrowser(null); + setPage(null); + setSessionPrefix(null); + setSeanceId(null); + // Clean up persistent user data dir + if (persistentUserDataDir) { + try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {} + setPersistentUserDataDir(null); + } + } +} + +/** + * Attach to a running browser server via CDP WebSocket. + * Sets module state so all functions (getFormState, clickElement, etc.) work. + */ +export async function attach(wsEndpoint, session = {}) { + if (isConnected()) return; + setBrowser(await chromium.connect(wsEndpoint)); + const ctx = browser.contexts()[0]; + setPage(ctx?.pages()[0]); + if (!page) throw new Error('No page found in browser'); + setSessionPrefix(session.sessionPrefix || null); + setSeanceId(session.seanceId || null); +} + +/** + * Detach from browser without closing it. + * Returns session state for persistence. + */ +export function detach() { + const session = { sessionPrefix, seanceId }; + setBrowser(null); + setPage(null); + setSessionPrefix(null); + setSeanceId(null); + return session; +} + +/** Get current session state (for saving between reconnections). */ +export function getSession() { + return { sessionPrefix, seanceId }; +} + +// ============================================================ +// Multi-context support (used by run.mjs cmdTest only) +// ============================================================ + +/** + * Save current module-level state into the active slot before switching. + * No-op if no active slot. + */ +function _saveActiveSlot() { + if (!activeContextName) return; + const slot = contexts.get(activeContextName); + if (!slot) return; + slot.page = page; + slot.sessionPrefix = sessionPrefix; + slot.seanceId = seanceId; + slot.highlightMode = highlightMode; + // Note: `recorder`, `lastCaptions`, `lastRecordingDuration` are intentionally NOT + // mirrored per-slot. A multi-context recording produces one continuous output file — + // the recorder follows the active page via recorder._attachPage(), not per-slot state. +} + +/** Load a slot's state into module-level vars and mark it active. */ +function _activateSlot(name) { + const slot = contexts.get(name); + if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`); + setPage(slot.page); + setSessionPrefix(slot.sessionPrefix); + setSeanceId(slot.seanceId); + setHighlightMode(slot.highlightMode || false); + setActiveContextName(name); +} + +/** Attach 1C session listeners to a page, writing into the given slot. */ +function _attachSessionListeners(pg, slot, name) { + pg.on('dialog', dialog => dialog.accept().catch(() => {})); + pg.on('request', req => { + if (slot.seanceId) return; + const m = req.url().match(/^(https?:\/\/[^/]+\/[^/]+\/[^/]+)\/e1cib\/.+[?&]seanceId=([^&]+)/); + if (m) { + slot.sessionPrefix = m[1]; + slot.seanceId = m[2]; + if (activeContextName === name) { + setSessionPrefix(m[1]); + setSeanceId(m[2]); + } + } + }); +} + +/** + * Create (or navigate) a named browser context. + * First call launches Chromium via chromium.launch() (NOT launchPersistentContext) so that + * subsequent calls can create additional isolated BrowserContexts in the same process. + * Trade-off: 1C browser extension is loaded via --load-extension (process-level) rather than + * persistent profile. + * + * Use this from run.mjs cmdTest only — exec/run/start use connect() and stay on the + * legacy persistent-context path. + */ +export async function createContext(name, url, { extensionPath, isolation = 'tab' } = {}) { + if (contexts.has(name)) { + await setActiveContext(name); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); + try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); } + catch { await page.waitForTimeout(5000); } + await closeModals(); + return await getPageState(); + } + + if (!['tab', 'window'].includes(isolation)) { + throw new Error(`createContext: invalid isolation "${isolation}", expected 'tab' or 'window'`); + } + if (activeMode && activeMode !== isolation) { + throw new Error(`createContext: cannot mix isolation modes — first context used "${activeMode}", "${name}" requested "${isolation}". Use the same mode for all contexts in one run.`); + } + + // First context: launch browser. Subsequent: reuse existing. + let isFirstContext = !browser; + if (isFirstContext) { + const extPath = findExtension(extensionPath); + const launchArgs = ['--start-maximized']; + if (extPath) { + launchArgs.push('--disable-extensions-except=' + extPath, '--load-extension=' + extPath); + } + if (isolation === 'tab') { + // Persistent context: extension loads reliably, one window with tabs per context + setPersistentUserDataDir(pathJoin(tmpdir(), 'pw-1c-test-' + Date.now())); + mkdirSync(persistentUserDataDir, { recursive: true }); + setBrowser(await chromium.launchPersistentContext(persistentUserDataDir, { + headless: false, + args: launchArgs, + viewport: null, + permissions: ['clipboard-read', 'clipboard-write'], + })); + } else { + // Window mode: separate BrowserContext per slot, full cookie isolation + setBrowser(await chromium.launch({ headless: false, args: launchArgs })); + } + setActiveMode(isolation); + } + + // Save current active before switching + _saveActiveSlot(); + + // Create slot — page differs by mode + let newCtx, newPage; + if (activeMode === 'tab') { + // Reuse the persistent context for all slots; each slot gets its own page (tab) + newCtx = browser; + if (isFirstContext) { + newPage = browser.pages()[0] || await browser.newPage(); + } else { + newPage = await browser.newPage(); + } + } else { + // Window mode: each slot owns its BrowserContext + page + newCtx = await browser.newContext({ + viewport: null, + permissions: ['clipboard-read', 'clipboard-write'], + }); + newPage = await newCtx.newPage(); + } + + const slot = { + context: newCtx, + page: newPage, + sessionPrefix: null, + seanceId: null, + highlightMode: false, + }; + contexts.set(name, slot); + + _attachSessionListeners(newPage, slot, name); + _activateSlot(name); + + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); + try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); } + catch { await page.waitForTimeout(5000); } + await closeModals(); + + return await getPageState(); +} + +/** Switch the active context. Subsequent browser API calls operate on this context's page. */ +export async function setActiveContext(name) { + if (activeContextName === name) return; + if (!contexts.has(name)) throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`); + // If a recording is active, flush the outgoing page's last frame so the gap is filled + // up to the moment of the switch (avoids a "jump" in video time). + if (recorder && recorder._flushFrames) recorder._flushFrames(); + _saveActiveSlot(); + _activateSlot(name); + // If the recording is still alive (it lives across slots — we keep the same ffmpeg/output), + // re-attach its screencast to the newly active page. + if (recorder && recorder._attachPage) { + await recorder._attachPage(page); + } +} + +export function listContexts() { + return [...contexts.keys()]; +} + +export function getActiveContext() { + return activeContextName; +} + +export function hasContext(name) { + return contexts.has(name); +} + +/** + * Close a named context: logout, close its page (tab mode) or BrowserContext + * (window mode), remove from registry. Cannot close the currently active + * context — caller must setActiveContext to another first. This keeps the + * recorder/page invariants simple: recorder is always attached to the + * active slot, which closeContext never touches. + * + * @throws if name is not registered or equals the active context. + */ +export async function closeContext(name) { + if (!contexts.has(name)) { + throw new Error(`Context "${name}" not found. Available: [${[...contexts.keys()].join(', ')}]`); + } + if (name === activeContextName) { + throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`); + } + const slot = contexts.get(name); + await _logoutSlot(slot); + if (activeMode === 'tab') { + try { await slot.page.close(); } catch {} + } else { + try { await slot.context.close(); } catch {} + } + contexts.delete(name); +} From 2cba13a8cc8a07b00115bcc4a1bbfb551d9a4dd5 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 25 May 2026 22:25:54 +0300 Subject: [PATCH 05/47] =?UTF-8?q?fix(web-test):=20=D1=8D=D0=BA=D1=81=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20?= =?UTF-8?q?=5FdetectPlatformDialogs/=5FclosePlatformDialogs=20=D0=B8=D0=B7?= =?UTF-8?q?=20core/errors.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После A.3 эти helpers стали приватными в core/errors.mjs, но getFormState (browser.mjs:408) и closeForm (browser.mjs:2168) их по-прежнему вызывают — ловили ReferenceError на каждое действие. Делаем их экспортируемыми и импортируем в browser.mjs. Имя с подчёркиванием сохраняется до этапа E.13 (финальная чистка). Регресс 19/19. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 1 + .claude/skills/web-test/scripts/core/errors.mjs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 9f9fd9f9..6abfd02e 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -140,6 +140,7 @@ import { } from './core/wait.mjs'; import { closeModals, checkForErrors, dismissPendingErrors, fetchErrorStack, + _detectPlatformDialogs, _closePlatformDialogs, } from './core/errors.mjs'; // Re-export only what was publicly exported before the refactor. // waitForStable/waitForCondition/startNetworkMonitor/closeModals/checkForErrors/ diff --git a/.claude/skills/web-test/scripts/core/errors.mjs b/.claude/skills/web-test/scripts/core/errors.mjs index 13e96233..4e72381f 100644 --- a/.claude/skills/web-test/scripts/core/errors.mjs +++ b/.claude/skills/web-test/scripts/core/errors.mjs @@ -84,7 +84,7 @@ export async function dismissPendingErrors() { * Detect open platform-level dialogs (About, Support Info, Error Report). * Returns array of { type, title? } for each detected dialog, or empty array. */ -async function _detectPlatformDialogs() { +export async function _detectPlatformDialogs() { return await page.evaluate(() => { const result = []; // "О программе" dialog @@ -114,7 +114,7 @@ async function _detectPlatformDialogs() { * These are NOT 1C forms — they are platform UI overlays invisible to getFormState(). * Each close is wrapped in try/catch to avoid cascading failures. */ -async function _closePlatformDialogs() { +export async function _closePlatformDialogs() { await page.evaluate(() => { // "Подробный текст ошибки" OK button (inside error report detail view) // It's a cloud window with its own OK button — look for visible pressDefault in small ps*win From 5b6243bbccdea7b412d107748e9b5f52a3d707ac Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 25 May 2026 22:31:45 +0300 Subject: [PATCH 06/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20B.5.1=20=E2=80=94=20safeClick=20=D1=85=D0=B5=D0=BB?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=203?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B9=20pointer-events=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В fillReferenceField, clickElement и DLB-ветке selectValue был один и тот же паттерн: page.click → catch 'intercepts pointer events' → force-click → catch снова → Escape + retry. Три копии (плюс одна с dismissPendingErrors). core/helpers.mjs (новый): safeClick(selector, { timeout, dismissErrors }). Экономия ~60 LOC дублей. Поведение 1-в-1 (dismissErrors:true только в fillReferenceField — там единственное место, где исходно было). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 57 ++----------------- .../skills/web-test/scripts/core/helpers.mjs | 36 ++++++++++++ 2 files changed, 40 insertions(+), 53 deletions(-) create mode 100644 .claude/skills/web-test/scripts/core/helpers.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 6abfd02e..159d5e15 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -142,6 +142,7 @@ import { closeModals, checkForErrors, dismissPendingErrors, fetchErrorStack, _detectPlatformDialogs, _closePlatformDialogs, } from './core/errors.mjs'; +import { safeClick } from './core/helpers.mjs'; // Re-export only what was publicly exported before the refactor. // waitForStable/waitForCondition/startNetworkMonitor/closeModals/checkForErrors/ // dismissPendingErrors are internal helpers — imported above for local use only. @@ -1502,23 +1503,7 @@ async function fillReferenceField(selector, fieldName, value, formNum) { } catch { /* DLB approach failed — fall through to paste */ } // 1. Focus (handle surface/modal overlay from previous interaction) - try { - await page.click(selector); - } catch (e) { - if (e.message.includes('intercepts pointer events')) { - // Try force click first (no side effects), then Escape as fallback - try { - await page.click(selector, { force: true }); - } catch (e2) { - if (e2.message.includes('intercepts pointer events')) { - await dismissPendingErrors(); - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - await page.click(selector); - } else throw e2; - } - } else throw e; - } + await safeClick(selector, { dismissErrors: true }); // 2. If field already has a value, clear using Shift+F4 (native 1C mechanism). // This is needed for reference fields — Shift+F4 properly clears the ref link. @@ -2064,27 +2049,7 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi } else { const selector = `[id="${target.id}"]`; // Use Playwright click for proper mousedown/mouseup events - try { - await page.click(selector, { timeout: 5000 }); - } catch (clickErr) { - if (clickErr.message.includes('intercepts pointer events')) { - // Surface overlay intercepts — try force click first (no side effects), - // then Escape + retry as fallback (Escape can trigger save dialogs on forms) - try { - await page.click(selector, { force: true, timeout: 5000 }); - } catch (clickErr2) { - if (clickErr2.message.includes('intercepts pointer events')) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - await page.click(selector, { timeout: 5000 }); - } else { - throw clickErr2; - } - } - } else { - throw clickErr; - } - } + await safeClick(selector, { timeout: 5000 }); } // If submenu button — read popup items and return them as hints @@ -2427,21 +2392,7 @@ export async function selectValue(fieldName, searchText, { type } = {}) { // 2. Click DLB (handle funcPanel / surface overlay intercept) const dlbSel = `[id="${btn.buttonId}"]`; - try { - await page.click(dlbSel, { timeout: 5000 }); - } catch (dlbErr) { - if (dlbErr.message.includes('intercepts pointer events')) { - try { - await page.click(dlbSel, { force: true, timeout: 5000 }); - } catch (dlbErr2) { - if (dlbErr2.message.includes('intercepts pointer events')) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - await page.click(dlbSel, { timeout: 5000 }); - } else throw dlbErr2; - } - } else throw dlbErr; - } + await safeClick(dlbSel, { timeout: 5000 }); await page.waitForTimeout(ACTION_WAIT); // 3A. Check if a dropdown popup appeared (inline quick selection) diff --git a/.claude/skills/web-test/scripts/core/helpers.mjs b/.claude/skills/web-test/scripts/core/helpers.mjs new file mode 100644 index 00000000..4e138e2f --- /dev/null +++ b/.claude/skills/web-test/scripts/core/helpers.mjs @@ -0,0 +1,36 @@ +// web-test core/helpers v1.16 — private, cross-cutting helpers used by the +// public action functions (clickElement/fillFields/selectValue/etc). +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page } from './state.mjs'; +import { dismissPendingErrors } from './errors.mjs'; + +/** + * page.click with the standard "intercepts pointer events" retry ladder: + * normal → force → Escape (+ optional dismissPendingErrors) → normal. + * Mirrors the three hand-written copies in fillReferenceField, clickElement + * and the DLB branch of selectValue. + * + * @param {string} selector + * @param {object} [opts] + * @param {number} [opts.timeout] — passed through to page.click + * @param {boolean} [opts.dismissErrors=false] — call dismissPendingErrors() + * before pressing Escape on the second retry (used in fillReferenceField). + */ +export async function safeClick(selector, { timeout, dismissErrors = false } = {}) { + const baseOpts = timeout != null ? { timeout } : {}; + try { + await page.click(selector, baseOpts); + } catch (e) { + if (!e.message.includes('intercepts pointer events')) throw e; + try { + await page.click(selector, { ...baseOpts, force: true }); + } catch (e2) { + if (!e2.message.includes('intercepts pointer events')) throw e2; + if (dismissErrors) await dismissPendingErrors(); + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + await page.click(selector, baseOpts); + } + } +} From 3fe038277fb7d95557332c40440d8327df3f720b Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 25 May 2026 22:33:15 +0300 Subject: [PATCH 07/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20B.5.2=20=E2=80=94=20findFieldInputId=20=D1=85=D0=B5?= =?UTF-8?q?=D0=BB=D0=BF=D0=B5=D1=80=20(4=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8?= =?UTF-8?q?=20=E2=86=92=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В selectValue было 4 одинаковых блока поиска input-элемента поля по имени (form{N}_{name} либо form{N}_{name}_i0 для refs): clear-ветка, composite-type-ветка, F4-fallback, "last resort" F4. core/helpers.mjs: findFieldInputId(formNum, fieldName) → string|null. ~30 LOC дублей убрано, поведение 1-в-1. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 30 ++++--------------- .../skills/web-test/scripts/core/helpers.mjs | 19 ++++++++++++ 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 159d5e15..625085e1 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -142,7 +142,7 @@ import { closeModals, checkForErrors, dismissPendingErrors, fetchErrorStack, _detectPlatformDialogs, _closePlatformDialogs, } from './core/errors.mjs'; -import { safeClick } from './core/helpers.mjs'; +import { safeClick, findFieldInputId } from './core/helpers.mjs'; // Re-export only what was publicly exported before the refactor. // waitForStable/waitForCondition/startNetworkMonitor/closeModals/checkForErrors/ // dismissPendingErrors are internal helpers — imported above for local use only. @@ -2195,12 +2195,7 @@ export async function selectValue(fieldName, searchText, { type } = {}) { // === CLEAR FIELD if searchText is empty/null === if (!searchText && searchText !== 0) { - const inputId = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const name = ${JSON.stringify(btn.fieldName)}; - const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]'); - return el ? el.id : null; - })()`); + const inputId = await findFieldInputId(formNum, btn.fieldName); if (inputId) { await page.click(`[id="${inputId}"]`); await page.waitForTimeout(200); @@ -2219,12 +2214,7 @@ export async function selectValue(fieldName, searchText, { type } = {}) { // then open type selection dialog, pick the type, then pick the value. if (type) { // Find and focus the field input - const inputId = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const name = ${JSON.stringify(btn.fieldName)}; - const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]'); - return el ? el.id : null; - })()`); + const inputId = await findFieldInputId(formNum, btn.fieldName); if (!inputId) throw new Error(`selectValue: field "${btn.fieldName}" input not found`); // Clear cached type + value with Shift+F4 @@ -2434,12 +2424,7 @@ export async function selectValue(fieldName, searchText, { type } = {}) { await page.waitForTimeout(500); // Focus the field input and press F4 to open selection form - const inputId = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const name = ${JSON.stringify(btn.fieldName)}; - const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]'); - return el ? el.id : null; - })()`); + const inputId = await findFieldInputId(formNum, btn.fieldName); if (inputId) { await page.click(`[id="${inputId}"]`); await page.waitForTimeout(300); @@ -2489,12 +2474,7 @@ export async function selectValue(fieldName, searchText, { type } = {}) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); - const inputId = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const name = ${JSON.stringify(btn.fieldName)}; - const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]'); - return el ? el.id : null; - })()`); + const inputId = await findFieldInputId(formNum, btn.fieldName); if (inputId) { await page.click(`[id="${inputId}"]`); await page.waitForTimeout(300); diff --git a/.claude/skills/web-test/scripts/core/helpers.mjs b/.claude/skills/web-test/scripts/core/helpers.mjs index 4e138e2f..7f1ecdc3 100644 --- a/.claude/skills/web-test/scripts/core/helpers.mjs +++ b/.claude/skills/web-test/scripts/core/helpers.mjs @@ -34,3 +34,22 @@ export async function safeClick(selector, { timeout, dismissErrors = false } = { } } } + +/** + * Find a form field's input element id by name. Tries `form{N}_{name}` first, + * then `form{N}_{name}_i0` (reference fields use the _i0 suffix). Returns the + * element id or null. Used in selectValue's clear/composite-type/F4 fallback + * branches. + * + * @param {number} formNum + * @param {string} fieldName + * @returns {Promise} + */ +export async function findFieldInputId(formNum, fieldName) { + return await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const name = ${JSON.stringify(fieldName)}; + const el = document.querySelector('[id="' + p + name + '"], [id="' + p + name + '_i0"]'); + return el ? el.id : null; + })()`); +} From 09b2084672aa9d5eb1245c9b20e2909e8fae8612 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 25 May 2026 22:34:21 +0300 Subject: [PATCH 08/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20B.5.3=20=E2=80=94=20detectNewForm=20=D1=85=D0=B5=D0=BB?= =?UTF-8?q?=D0=BF=D0=B5=D1=80=20(3=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8=20?= =?UTF-8?q?=E2=86=92=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В fillReferenceField, selectValue и fillTableRow была одна и та же логика: сканировать DOM на наличие элемента с id="form{N}_*" где N > prevFormNum. Две вариации: strict (только visible interactive — input.editInput/a.press) и broad (любой [id], учитывает type-dialogs с пустыми button-id). core/helpers.mjs: detectNewForm(prevFormNum, { strict }) → number|null. Внутри функций оставлены тонкие локальные обёртки (для совместимости с уже использующейся сигнатурой без аргументов) — будут убраны на C.8/D. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 49 +++++-------------- .../skills/web-test/scripts/core/helpers.mjs | 30 ++++++++++++ 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 625085e1..c48e7fda 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -142,7 +142,10 @@ import { closeModals, checkForErrors, dismissPendingErrors, fetchErrorStack, _detectPlatformDialogs, _closePlatformDialogs, } from './core/errors.mjs'; -import { safeClick, findFieldInputId } from './core/helpers.mjs'; +import { + safeClick, findFieldInputId, + detectNewForm as helperDetectNewForm, +} from './core/helpers.mjs'; // Re-export only what was publicly exported before the refactor. // waitForStable/waitForCondition/startNetworkMonitor/closeModals/checkForErrors/ // dismissPendingErrors are internal helpers — imported above for local use only. @@ -1408,19 +1411,9 @@ async function fillReferenceField(selector, fieldName, value, formNum) { const text = String(value); const escapedSel = selector.replace(/'/g, "\\'"); - // Helper: detect new forms opened above the current one - async function detectNewForm() { - return page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { - if (el.offsetWidth === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - } + // Helper: detect new forms opened above the current one (strict — interactive + // elements only; fillReferenceField-specific) + const detectNewForm = () => helperDetectNewForm(formNum, { strict: true }); // Helper: clear the field using Shift+F4 (native 1C mechanism) async function clearField() { @@ -2290,20 +2283,9 @@ export async function selectValue(fieldName, searchText, { type } = {}) { })()`); } - // Helper: detect any new form (broader than detectSelectionForm — also finds type dialogs - // whose a.press buttons have empty IDs). Looks for any visible element with id="form{N}_*". - async function detectNewForm() { - return page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - } + // Helper: detect any new form (broad — finds type dialogs whose a.press + // buttons have empty IDs). Looks for any visible element with id="form{N}_*". + const detectNewForm = () => helperDetectNewForm(formNum); // Helper: open selection form and pick value async function openFormAndPick() { @@ -3496,16 +3478,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { } // Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs) - const newForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + const newForm = await helperDetectNewForm(formNum); if (newForm !== null) { if (await isTypeDialog(newForm)) { diff --git a/.claude/skills/web-test/scripts/core/helpers.mjs b/.claude/skills/web-test/scripts/core/helpers.mjs index 7f1ecdc3..a80cc6c7 100644 --- a/.claude/skills/web-test/scripts/core/helpers.mjs +++ b/.claude/skills/web-test/scripts/core/helpers.mjs @@ -53,3 +53,33 @@ export async function findFieldInputId(formNum, fieldName) { return el ? el.id : null; })()`); } + +/** + * Detect a new form opened above the given `prevFormNum`. Two modes: + * `{ strict: true }` — only counts visible interactive elements + * (`input.editInput[id], a.press[id]`); used by fillReferenceField. + * default (broad) — any element with `id^=form{N}_` that's visible + * in either dimension; also finds type-dialogs whose a.press buttons + * have empty IDs. Used by selectValue / fillTableRow. + * + * @param {number} prevFormNum + * @param {object} [opts] + * @param {boolean} [opts.strict=false] + * @returns {Promise} new form number or null + */ +export async function detectNewForm(prevFormNum, { strict = false } = {}) { + const selector = strict ? 'input.editInput[id], a.press[id]' : '[id]'; + const visibleCheck = strict + ? 'el.offsetWidth === 0' + : 'el.offsetWidth === 0 && el.offsetHeight === 0'; + return page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll(${JSON.stringify(selector)}).forEach(el => { + if (${visibleCheck}) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${prevFormNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); +} From e215957344352d252396b693e76beda088c71cc3 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 25 May 2026 22:35:24 +0300 Subject: [PATCH 09/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20B.5.4=20=E2=80=94=20readEdd=20=D1=85=D0=B5=D0=BB=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=20(2=20=D0=BA=D0=BE=D0=BF=D0=B8=D0=B8=20=D0=B2=20f?= =?UTF-8?q?illReferenceField)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В fillReferenceField было два места с одинаковым page.evaluate-скриптом чтения #editDropDown (DLB-popup перед paste и autocomplete после Ctrl+V). core/helpers.mjs: readEdd() → { visible, items?: [{ name, x, y }] }. selectValue использует свой clickEddItem через dispatchEvent (bypass div.surface) — оставлен как есть, специфика API там сильно отличается. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 28 ++----------------- .../skills/web-test/scripts/core/helpers.mjs | 22 +++++++++++++++ 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index c48e7fda..1056bba6 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -143,7 +143,7 @@ import { _detectPlatformDialogs, _closePlatformDialogs, } from './core/errors.mjs'; import { - safeClick, findFieldInputId, + safeClick, findFieldInputId, readEdd, detectNewForm as helperDetectNewForm, } from './core/helpers.mjs'; // Re-export only what was publicly exported before the refactor. @@ -1456,18 +1456,7 @@ async function fillReferenceField(selector, fieldName, value, formNum) { if (dlbVisible) { await page.click(dlbSelector); await page.waitForTimeout(1000); - const eddState = await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return { visible: false }; - const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0); - return { - visible: true, - items: eddTexts.map(el => { - const r = el.getBoundingClientRect(); - return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 }; - }) - }; - })()`); + const eddState = await readEdd(); if (eddState.visible && eddState.items?.length > 0) { const target = normYo(text.toLowerCase()); const candidates = eddState.items.filter(i => !i.name.startsWith('Создать')); @@ -1515,18 +1504,7 @@ async function fillReferenceField(selector, fieldName, value, formNum) { await page.waitForTimeout(2000); // 4. Check editDropDown for autocomplete suggestions - const eddState = await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return { visible: false }; - const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0); - return { - visible: true, - items: eddTexts.map(el => { - const r = el.getBoundingClientRect(); - return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 }; - }) - }; - })()`); + const eddState = await readEdd(); if (eddState.visible && eddState.items?.length > 0) { const target = normYo(text.toLowerCase()); diff --git a/.claude/skills/web-test/scripts/core/helpers.mjs b/.claude/skills/web-test/scripts/core/helpers.mjs index a80cc6c7..4b8615e1 100644 --- a/.claude/skills/web-test/scripts/core/helpers.mjs +++ b/.claude/skills/web-test/scripts/core/helpers.mjs @@ -83,3 +83,25 @@ export async function detectNewForm(prevFormNum, { strict = false } = {}) { return nums.length > 0 ? Math.max(...nums) : null; })()`); } + +/** + * Read the `#editDropDown` autocomplete popup. Returns whether it's visible + * and, when visible, an array of `.eddText` items with display name and + * center coordinates (suitable for page.mouse.click). + * + * @returns {Promise<{visible: boolean, items?: Array<{name:string, x:number, y:number}>}>} + */ +export async function readEdd() { + return await page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return { visible: false }; + const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0); + return { + visible: true, + items: eddTexts.map(el => { + const r = el.getBoundingClientRect(); + return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }) + }; + })()`); +} From 9ac0cb3b87b5e5968f92a9e9129a6918598aa2fa Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 25 May 2026 22:42:23 +0300 Subject: [PATCH 10/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20B.5.5=20=E2=80=94=20=D0=B2=D0=B2=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B8=20returnFormState=20(=D0=B2=D1=8B=D0=B1=D0=BE=D1=80?= =?UTF-8?q?=D0=BE=D1=87=D0=BD=D0=BE=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=82=D1=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/helpers.mjs: returnFormState(extras) — стандартный хвост action-функций: getFormState + Object.assign(extras) + checkForErrors → state.errors. Унифицирует ~15 hand-written копий и закрывает R1/R2/R3 (state.errors теперь добавляется автоматически у любого пользователя хелпера). В этом коммите конвертированы только 2 простейших P1-сайта (openCommand, второй handle в navigateLink) — без extras между getFormState и err-проверкой. Остальные 30+ сайтов сложнее (state.X между, разные return-shape, wrapped fillFields) — будут мигрированы органически при переносе clickElement/ selectValue/closeForm в forms/* на этапе C. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 12 +++------- .../skills/web-test/scripts/core/helpers.mjs | 23 ++++++++++++++++++- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 1056bba6..94723e71 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -143,7 +143,7 @@ import { _detectPlatformDialogs, _closePlatformDialogs, } from './core/errors.mjs'; import { - safeClick, findFieldInputId, readEdd, + safeClick, findFieldInputId, readEdd, returnFormState, detectNewForm as helperDetectNewForm, } from './core/helpers.mjs'; // Re-export only what was publicly exported before the refactor. @@ -215,10 +215,7 @@ export async function openCommand(name) { if (result?.error) throw new Error(`openCommand: "${name}" not found. Available: ${result.available?.join(', ') || 'none'}`); await waitForStable(formBefore); - const state = await getFormState(); - const err = await checkForErrors(); - if (err) state.errors = err; - return state; + return await returnFormState(); } /** Switch to an open tab by name (fuzzy match). Returns updated form state. */ @@ -390,10 +387,7 @@ export async function navigateLink(url) { } await waitForStable(formBefore); - const state = await getFormState(); - const err = await checkForErrors(); - if (err) state.errors = err; - return state; + return await returnFormState(); } /** Read current form state. Single evaluate call via combined script. */ diff --git a/.claude/skills/web-test/scripts/core/helpers.mjs b/.claude/skills/web-test/scripts/core/helpers.mjs index 4b8615e1..810cf2a9 100644 --- a/.claude/skills/web-test/scripts/core/helpers.mjs +++ b/.claude/skills/web-test/scripts/core/helpers.mjs @@ -3,7 +3,8 @@ // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page } from './state.mjs'; -import { dismissPendingErrors } from './errors.mjs'; +import { dismissPendingErrors, checkForErrors } from './errors.mjs'; +import { getFormState } from '../browser.mjs'; /** * page.click with the standard "intercepts pointer events" retry ladder: @@ -105,3 +106,23 @@ export async function readEdd() { }; })()`); } + +/** + * Standard "tail" of action functions: fetch current form state, attach + * caller-specified extras (e.g. `{ clicked: {...} }`) and the result of + * `checkForErrors()` if any. Returns the flat state object. + * + * Unifies ~15 hand-written copies in clickElement, selectValue, closeForm, + * navigation functions, etc. Also closes R1/R2/R3 from the refactor plan — + * any caller using this helper gets `state.errors` for free. + * + * @param {object} [extras] — merged into the state object via Object.assign. + * @returns {Promise} form state (flat) with optional `errors`. + */ +export async function returnFormState(extras = {}) { + const state = await getFormState(); + Object.assign(state, extras); + const err = await checkForErrors(); + if (err) state.errors = err; + return state; +} From 6fb5b9f617d45d79b9dee8894f904359ad48b134 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Mon, 25 May 2026 22:44:18 +0300 Subject: [PATCH 11/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20B.6=20=E2=80=94=20table/grid-toggle.mjs=20(icon=20dete?= =?UTF-8?q?ction=20shared)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В clickElement две ветки (gridGroup/gridParent + gridTreeNode) имели почти идентичные page.evaluate-блоки: найти gridLine под target.y, получить иконку-разворачивалку, вернуть её центр + isExpanded. table/grid-toggle.mjs: - getGridToggleIcon(target, formNum, { iconSelector, isExpandedExpr }) - shouldClickToggle(iconInfo, expand, toggle) Поведение 1-в-1. Селекторы и isExpanded-критерий передаются параметрами: - groups: '.gridListH, .gridListV' + icon.classList.contains('gridListV') - trees: '.gridBoxImg [tree="true"]' + bg.includes('gx=0') Экономия ~30 LOC дублей. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 66 +++++-------------- .../web-test/scripts/table/grid-toggle.mjs | 64 ++++++++++++++++++ 2 files changed, 79 insertions(+), 51 deletions(-) create mode 100644 .claude/skills/web-test/scripts/table/grid-toggle.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 94723e71..4bfd5d55 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -146,6 +146,7 @@ import { safeClick, findFieldInputId, readEdd, returnFormState, detectNewForm as helperDetectNewForm, } from './core/helpers.mjs'; +import { getGridToggleIcon, shouldClickToggle } from './table/grid-toggle.mjs'; // Re-export only what was publicly exported before the refactor. // waitForStable/waitForCondition/startNetworkMonitor/closeModals/checkForErrors/ // dismissPendingErrors are internal helpers — imported above for local use only. @@ -1891,31 +1892,13 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi // Grid row targets — use coordinate click (single or double) if (target.kind === 'gridGroup' || target.kind === 'gridParent') { if (expand != null || toggle) { - // Expand/collapse group in hierarchy mode — click the triangle icon (.gridListH/.gridListV) - // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click - const levelIconInfo = await page.evaluate(`(() => { - const p = ${JSON.stringify(`form${formNum}_`)}; - const gridSel = ${JSON.stringify(target.gridId ? '#' + target.gridId : null)}; - const grid = gridSel ? document.querySelector(gridSel) : document.querySelector('[id^="' + p + '"].grid'); - const body = grid?.querySelector('.gridBody'); - if (!body) return null; - const targetY = ${target.y}; - const lines = [...body.querySelectorAll('.gridLine')]; - for (const line of lines) { - const lr = line.getBoundingClientRect(); - if (targetY < lr.top || targetY > lr.bottom) continue; - const icon = line.querySelector('.gridListH, .gridListV'); - if (icon) { - const r = icon.getBoundingClientRect(); - const isExpanded = !!icon.classList.contains('gridListV'); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isExpanded }; - } - } - return null; - })()`); - const shouldClick = toggle || !levelIconInfo - || (expand === true && !levelIconInfo.isExpanded) - || (expand === false && levelIconInfo.isExpanded); + // Expand/collapse group in hierarchy mode — click the triangle icon (.gridListH/.gridListV). + // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click. + const levelIconInfo = await getGridToggleIcon(target, formNum, { + iconSelector: '.gridListH, .gridListV', + isExpandedExpr: "icon.classList.contains('gridListV')", + }); + const shouldClick = shouldClickToggle(levelIconInfo, expand, toggle); if (shouldClick) { if (levelIconInfo) { await modClick(levelIconInfo.x, levelIconInfo.y); @@ -1939,32 +1922,13 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi } if (target.kind === 'gridTreeNode') { if (expand != null || toggle) { - // Expand/collapse tree node — click the tree icon [tree="true"] - // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click - const treeIconInfo = await page.evaluate(`(() => { - const p = ${JSON.stringify(`form${formNum}_`)}; - const gridSel = ${JSON.stringify(target.gridId ? '#' + target.gridId : null)}; - const grid = gridSel ? document.querySelector(gridSel) : document.querySelector('[id^="' + p + '"].grid'); - const body = grid?.querySelector('.gridBody'); - if (!body) return null; - const targetY = ${target.y}; - const lines = [...body.querySelectorAll('.gridLine')]; - for (const line of lines) { - const lr = line.getBoundingClientRect(); - if (targetY < lr.top || targetY > lr.bottom) continue; - const treeIcon = line.querySelector('.gridBoxImg [tree="true"]'); - if (treeIcon) { - const r = treeIcon.getBoundingClientRect(); - const bg = treeIcon.style.backgroundImage || ''; - const isExpanded = bg.includes('gx=0'); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isExpanded }; - } - } - return null; - })()`); - const shouldClick = toggle || !treeIconInfo - || (expand === true && !treeIconInfo.isExpanded) - || (expand === false && treeIconInfo.isExpanded); + // Expand/collapse tree node — click the tree icon [tree="true"]. + // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click. + const treeIconInfo = await getGridToggleIcon(target, formNum, { + iconSelector: '.gridBoxImg [tree="true"]', + isExpandedExpr: '(icon.style.backgroundImage || "").includes("gx=0")', + }); + const shouldClick = shouldClickToggle(treeIconInfo, expand, toggle); if (shouldClick) { if (treeIconInfo) { await modClick(treeIconInfo.x, treeIconInfo.y); diff --git a/.claude/skills/web-test/scripts/table/grid-toggle.mjs b/.claude/skills/web-test/scripts/table/grid-toggle.mjs new file mode 100644 index 00000000..cf5e7a2d --- /dev/null +++ b/.claude/skills/web-test/scripts/table/grid-toggle.mjs @@ -0,0 +1,64 @@ +// web-test table/grid-toggle v1.16 — shared icon-detection for grid expand/ +// collapse toggles. Used by clickElement's gridGroup/gridParent and +// gridTreeNode branches; the actual mouse click stays in the caller because +// it depends on the caller-local modifier-key handling. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page } from '../core/state.mjs'; + +/** + * Locate the toggle icon for the grid row at `target.y`. Inspects the row + * under that Y-coordinate inside the resolved grid, returns the icon's + * center coordinates and current expanded state — or `null` if no toggle + * icon is present (e.g. leaf node or detached row). + * + * @param {{y:number, gridId?:string}} target + * @param {number} formNum + * @param {object} opts + * @param {string} opts.iconSelector — CSS selector inside .gridLine + * (e.g. '.gridListH, .gridListV' for groups, '.gridBoxImg [tree="true"]' for tree nodes) + * @param {string} opts.isExpandedExpr — JS expression evaluated in browser + * context where `icon` is the matched element; must yield a boolean + * (e.g. "icon.classList.contains('gridListV')" or "(icon.style.backgroundImage || '').includes('gx=0')") + * @returns {Promise<{x:number, y:number, isExpanded:boolean}|null>} + */ +export async function getGridToggleIcon(target, formNum, { iconSelector, isExpandedExpr }) { + return await page.evaluate(`(() => { + const p = ${JSON.stringify(`form${formNum}_`)}; + const gridSel = ${JSON.stringify(target.gridId ? '#' + target.gridId : null)}; + const grid = gridSel ? document.querySelector(gridSel) : document.querySelector('[id^="' + p + '"].grid'); + const body = grid?.querySelector('.gridBody'); + if (!body) return null; + const targetY = ${target.y}; + const lines = [...body.querySelectorAll('.gridLine')]; + for (const line of lines) { + const lr = line.getBoundingClientRect(); + if (targetY < lr.top || targetY > lr.bottom) continue; + const icon = line.querySelector(${JSON.stringify(iconSelector)}); + if (icon) { + const r = icon.getBoundingClientRect(); + const isExpanded = ${isExpandedExpr}; + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isExpanded }; + } + } + return null; + })()`); +} + +/** + * Standard expand/toggle decision: should we click the toggle icon? + * - `toggle:true` → always click. + * - `expand:true` → click only if not already expanded. + * - `expand:false` → click only if currently expanded. + * - If no icon found (`iconInfo` is null) → click anyway (caller falls back to dblclick). + * + * @param {{isExpanded:boolean}|null} iconInfo + * @param {boolean|undefined} expand + * @param {boolean|undefined} toggle + * @returns {boolean} + */ +export function shouldClickToggle(iconInfo, expand, toggle) { + return toggle || !iconInfo + || (expand === true && !iconInfo.isExpanded) + || (expand === false && iconInfo.isExpanded); +} From 12c5cf5e66e400752fb6557b3628a6259122b4bd Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 11:58:26 +0300 Subject: [PATCH 12/47] =?UTF-8?q?fix(web-test):=20TDZ=20=D0=B2=20selectVal?= =?UTF-8?q?ue=20(detectNewForm)=20+=20missing=20import=20clipboardWarnLogg?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. В selectValue локальный const detectNewForm = () => ... объявлялся ниже composite-type ветки, которая его вызывала → TDZ ReferenceError "Cannot access 'detectNewForm' before initialization". Хелпер поднят в начало функции, дубликат-объявление убрано. 2. clipboardWarnLogged читается в restoreClipboard (line 92), но не был в списке импортов из core/state.mjs (импортировался только setter). ReferenceError срабатывал только когда clipboard.read() возвращал ошибку — в первом A-регрессе ветка не активировалась случайно. Регресс 18/19 (одна flake в 11-report — readSpreadsheet timing). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 4bfd5d55..64f03f11 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -26,7 +26,7 @@ import { import { browser, page, sessionPrefix, seanceId, recorder, lastCaptions, lastRecordingDuration, highlightMode, - persistentUserDataDir, preserveClipboard, + persistentUserDataDir, preserveClipboard, clipboardWarnLogged, contexts, activeContextName, activeMode, setBrowser, setPage, setSessionPrefix, setSeanceId, setRecorder, setLastCaptions, setLastRecordingDuration, setHighlightMode, @@ -2113,6 +2113,11 @@ export async function selectValue(fieldName, searchText, { type } = {}) { const formNum = await page.evaluate(detectFormScript()); if (formNum === null) throw new Error(`selectValue: no form found`); + // Detect any new form opened above this one (broad — includes type dialogs). + // Hoisted to the top so the composite-type branch can call it before its + // original declaration site further below. + const detectNewForm = () => helperDetectNewForm(formNum); + // 1. Find DLB button (fallback to CB — ERP uses Choose Button instead of DLB for some fields) let btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'DLB')); if (btn?.error === 'button_not_found') { @@ -2219,9 +2224,7 @@ export async function selectValue(fieldName, searchText, { type } = {}) { })()`); } - // Helper: detect any new form (broad — finds type dialogs whose a.press - // buttons have empty IDs). Looks for any visible element with id="form{N}_*". - const detectNewForm = () => helperDetectNewForm(formNum); + // detectNewForm is hoisted at the top of selectValue (see above). // Helper: open selection form and pick value async function openFormAndPick() { From c4b1aee9c967494aeb035d5c666f7dbcfc5cc5a2 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 12:16:56 +0300 Subject: [PATCH 13/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20C.7=20=E2=80=94=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=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(); +} From 3a6d5abffc7cd0b60c6d5eed43634bda2bcec8ea Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 12:28:31 +0300 Subject: [PATCH 14/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20C.8=20=E2=80=94=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20forms/select-value.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перенос selectValue + helpers из browser.mjs (~960 LOC): - scanGridRows, dblclickAndVerify, advancedSearchInline - pickFromSelectionForm, isTypeDialog, pickFromTypeDialog (экспортируются — вызываются из fillFields/fillTableRow в browser.mjs) - fillReferenceField (экспортируется — вызывается из fillFields) - selectValue Двумя слайсами вокруг fillFields/fillField/clickElement/closeForm, которые остаются в browser.mjs до этапов C.9/C.10. browser.mjs: 4095 → 2933 LOC. 56 публичных экспортов. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 945 +---------------- .../web-test/scripts/forms/select-value.mjs | 959 ++++++++++++++++++ 2 files changed, 967 insertions(+), 937 deletions(-) create mode 100644 .claude/skills/web-test/scripts/forms/select-value.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index fc4d5f78..64102952 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -757,634 +757,15 @@ export async function readSpreadsheet() { }; } -/** - * Scan visible grid rows for a text match (exact → startsWith → includes). - * Returns center coords of the matched row, or null if not found. - * When searchLower is empty, returns coords of the first row (fallback). - */ -async function scanGridRows(formNum, searchLower) { - return page.evaluate(`(() => { - const p = 'form${formNum}_'; - const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); - if (!grid) return null; - const body = grid.querySelector('.gridBody'); - if (!body) return null; - const lines = [...body.querySelectorAll('.gridLine')]; - if (!lines.length) return { rowCount: 0 }; - const searchLower = ${JSON.stringify(searchLower || '')}; - let sel = null; - if (searchLower) { - const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е'); - const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) })); - sel = rowData.find(r => r.text === searchLower)?.el - || rowData.find(r => r.text.startsWith(searchLower))?.el - || rowData.find(r => r.text.includes(searchLower))?.el; - } else { - sel = lines[0]; // empty search → first row - } - if (!sel) return null; - const imgBox = sel.querySelector('.gridBoxImg'); - const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false; - const r = sel.getBoundingClientRect(); - return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup }; - })()`); -} +// ============================================================ +// Value selection (DLB/CB) — extracted to forms/select-value.mjs +// ============================================================ +export { selectValue } from './forms/select-value.mjs'; +import { + selectValue, pickFromSelectionForm, isTypeDialog, pickFromTypeDialog, + fillReferenceField, +} from './forms/select-value.mjs'; -/** - * Select a row in a selection form via click + Enter, verify it closed. - * Uses click + Enter instead of dblclick because dblclick toggles - * expand/collapse in tree-style selection forms. - * Returns { field, ok: true, method: 'form' } on success, - * or { field, ok: false, reason: 'still_open' } if the item couldn't be selected (e.g. group row). - */ -async function dblclickAndVerify(coords, selFormNum, fieldName) { - // Click to highlight the row, then Enter to confirm selection. - // This works for both flat grids and tree forms (dblclick would - // toggle expand/collapse on tree group rows). - await page.mouse.click(coords.x, coords.y); - await page.waitForTimeout(200); - await page.keyboard.press('Enter'); - await waitForStable(selFormNum); - - // Verify selection form closed - const stillOpen = await page.evaluate(`(() => { - const p = 'form${selFormNum}_'; - return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); - })()`); - if (stillOpen) { - // Enter didn't select — item is likely a non-selectable group. - // Don't Escape here — let the caller decide (may want to try another row). - return { field: fieldName, ok: false, reason: 'still_open' }; - } - - // Check for 1C error modals after selection - const err = await page.evaluate(checkErrorsScript()); - if (err?.modal) { - try { - const btn = await page.$('a.press.pressDefault'); - if (btn) { await btn.click(); await page.waitForTimeout(500); } - } catch { /* OK */ } - } - return { field: fieldName, ok: true, method: 'form' }; -} - -/** - * Inline advanced search on a selection form via Alt+F. - * Does NOT click any column — FieldSelector auto-populates with main representation. - * Switches to "по части строки" (CompareType#1) to avoid composite type issues. - * Does not throw — returns silently on failure. - */ -async function advancedSearchInline(formNum, text) { - try { - // 1. Open advanced search via Alt+F - await page.keyboard.press('Alt+f'); - await page.waitForTimeout(2000); - - const dialogForm = await page.evaluate(detectFormScript()); - if (dialogForm === formNum || dialogForm === null) return; // Alt+F didn't open dialog - - // 2. Switch to "по части строки" (CompareType#1) - const radioClicked = await page.evaluate(`(() => { - const p = 'form${dialogForm}_'; - const el = document.getElementById(p + 'CompareType#1#radio'); - if (!el || el.offsetWidth === 0) return false; - if (el.classList.contains('select')) return true; // already selected - const r = el.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (radioClicked && typeof radioClicked === 'object') { - await page.mouse.click(radioClicked.x, radioClicked.y); - await page.waitForTimeout(300); - } - - // 3. Fill Pattern field via clipboard paste - const patternId = await page.evaluate(`(() => { - const p = 'form${dialogForm}_'; - const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); - return el ? el.id : null; - })()`); - if (!patternId) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - return; - } - await page.click(`[id="${patternId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await pasteText(text); - await page.waitForTimeout(300); - - // 4. Click "Найти" - const findBtn = await page.evaluate(`(() => { - const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); - const btn = btns.find(el => el.innerText?.trim() === 'Найти'); - if (!btn) return null; - const r = btn.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (findBtn) { - await page.mouse.click(findBtn.x, findBtn.y); - await page.waitForTimeout(2000); - } - - // 5. Close advanced search dialog - for (let attempt = 0; attempt < 3; attempt++) { - const dialogVisible = await page.evaluate(`(() => { - const p = 'form${dialogForm}_'; - return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); - })()`); - if (!dialogVisible) break; - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - } - await waitForStable(formNum); - } catch { /* silently fail — caller will re-scan and handle not_found */ } -} - -/** - * Pick a value from an opened selection form. - * - * Strategy (escalating): - * 1. Scan visible rows for text match (exact → startsWith → includes) - * 2. Advanced search (Alt+F, "по части строки") → re-scan - * 3. Fallback: simple search (search input + Enter) → re-scan - * 4. Not found → Escape → error - * - * For object search {field: value}: steps 1, then filterList(val, {field}) per entry, then re-scan. - * For empty search: pick first visible row. - * - * @param {number} selFormNum - selection form number - * @param {string} fieldName - field being filled (for error messages) - * @param {string|Object} search - string for simple search, or { field: value } for per-field search - * @param {number} origFormNum - original form number (to verify we returned) - * @returns {{ field, ok, method }} or {{ field, error, message }} - */ -async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) { - const searchText = typeof search === 'string' - ? search : (search ? Object.values(search).join(' ') : ''); - const searchLower = normYo((searchText || '').toLowerCase()); - - // Helper: try to select a row; returns result if ok, null if item wasn't selectable (group). - let hadUnselectableMatch = false; - async function trySelect(row) { - const r = await dblclickAndVerify(row, selFormNum, fieldName); - if (r.ok) return r; - hadUnselectableMatch = true; // found match but couldn't select (possibly group row or overlay) - return null; // form still open, try next step - } - - // Step 1: Scan visible rows (no filtering) - if (searchLower) { - const row = await scanGridRows(selFormNum, searchLower); - if (row?.x) { - const r = await trySelect(row); - if (r) return r; - } - } - - // Step 2: Advanced search (Alt+F — fast, no overlay issues) - if (typeof search === 'object' && search) { - // Per-field advanced search via filterList(val, {field}) - for (const [fld, val] of Object.entries(search)) { - try { await filterList(String(val), { field: fld }); } catch { /* proceed */ } - } - } else if (searchLower) { - // Inline advanced search (Alt+F, "по части строки") - await advancedSearchInline(selFormNum, searchText); - } - if (searchLower) { - const row = await scanGridRows(selFormNum, searchLower); - if (row?.x) { - const r = await trySelect(row); - if (r) return r; - } - } - - // Step 3: Fallback — simple search via search input (for forms without Alt+F support) - if (typeof search === 'string' && searchLower) { - const searchInputId = await page.evaluate(`(() => { - const p = 'form${selFormNum}_'; - const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); - return el ? el.id : null; - })()`); - if (searchInputId) { - try { - await page.click(`[id="${searchInputId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await pasteText(searchText); - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await waitForStable(selFormNum); - } catch { /* proceed */ } - const row = await scanGridRows(selFormNum, searchLower); - if (row?.x) { - const r = await trySelect(row); - if (r) return r; - } - } - } - - // Step 4: Empty search → pick first row; otherwise not found - if (!searchLower) { - const row = await scanGridRows(selFormNum, ''); - if (row?.x) { - const r = await trySelect(row); - if (r) return r; - } - } - - await page.keyboard.press('Escape'); - await waitForStable(); - const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search); - if (hadUnselectableMatch) { - return { field: fieldName, error: 'not_selectable', - message: 'Found ' + searchDesc + ' in selection form but it is not selectable (group/folder row)' }; - } - return { field: fieldName, error: 'not_found', - message: 'No matches in selection form for ' + searchDesc }; -} - -/** - * Detect whether a form is a type selection dialog ("Выбор типа данных"). - * Type dialogs appear when selecting a value for a composite-type field. - * - * Detection signals (any one is sufficient): - * - form{N}_OK element exists (selection forms use "Выбрать", not "OK") - * - form{N}_ValueList grid exists (specific to type/value list dialogs) - * - Window title contains "Выбор типа" (title attr on .toplineBoxTitle) - */ -async function isTypeDialog(formNum) { - return page.evaluate(`(() => { - const p = 'form' + ${formNum} + '_'; - const hasOK = !!document.getElementById(p + 'OK'); - const hasValueList = !!document.getElementById(p + 'ValueList'); - const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')] - .some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || '')); - return hasOK || hasValueList || hasTitle; - })()`); -} - -/** - * Select a type from the type selection dialog ("Выбор типа данных") - * using Ctrl+F search. The dialog has a virtual grid (~5 visible rows), - * so Ctrl+F is the only reliable way to find a type. - * - * Algorithm: Ctrl+F → paste typeName → Enter (search) → Escape (close Find) → - * verify selected row matches → Enter (OK) - * - * @param {number} formNum - type dialog form number - * @param {string} typeName - type name to search for (fuzzy, e.g. "Реализация (акт") - * @throws {Error} if type not found - */ -async function pickFromTypeDialog(formNum, typeName) { - // The type dialog is a modal ValueList grid. - // Strategy: scan visible rows first (fast path), fall back to Ctrl+F for large lists. - // - // Key constraints discovered during testing: - // - Grid focus: use evaluate(() => gridBody.focus()), NOT page.click({force:true}) - // which punches through the modal overlay to the form underneath - // - Ctrl+F only opens "Найти" if the GRID is focused (otherwise closes the type dialog) - // - Buttons: use page.click({force:true}), NOT evaluate(() => el.click()) - // because evaluate click doesn't trigger 1C's event chain properly - // - Enter/Escape in "Найти" close the ENTIRE dialog chain, not just "Найти" - // - Closing "Найти" via Cancel resets the search — verify grid while "Найти" is open - - const typeNorm = normYo(typeName.toLowerCase()); - - // Helper: read visible rows and find matching ones - async function readVisibleRows() { - return page.evaluate(`(() => { - const grid = document.getElementById('form${formNum}_ValueList'); - if (!grid) return { visible: [], matches: [] }; - const body = grid.querySelector('.gridBody'); - if (!body) return { visible: [], matches: [] }; - const lines = body.querySelectorAll('.gridLine'); - const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim(); - const typeNorm = ${JSON.stringify(typeNorm)}; - const visible = []; - const matches = []; - for (const line of lines) { - const text = norm(line.innerText); - if (!text) continue; - visible.push(text); - if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) { - const r = line.getBoundingClientRect(); - matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); - } - } - return { visible, matches }; - })()`); - } - - // Step 1: Scan visible rows (fast path — no Ctrl+F needed for small lists) - const scan = await readVisibleRows(); - - if (scan.matches.length === 1) { - // Single match — click to select, then OK - await page.mouse.click(scan.matches[0].x, scan.matches[0].y); - await page.waitForTimeout(200); - await page.click(`#form${formNum}_OK`, { force: true }); - await page.waitForTimeout(ACTION_WAIT); - return; - } - - if (scan.matches.length > 1) { - for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } - await waitForStable(); - throw new Error(`selectValue: multiple types match "${typeName}": ${scan.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`); - } - - // Step 2: Not found in visible rows — use Ctrl+F (virtual grid may have more items) - - // Focus the grid via evaluate (does NOT punch through modal like page.click) - await page.evaluate(`(() => { - const grid = document.getElementById('form${formNum}_ValueList'); - if (!grid) return; - const body = grid.querySelector('.gridBody'); - if (body) body.focus(); else grid.focus(); - })()`); - await page.waitForTimeout(300); - - // Ctrl+F to open "Найти" dialog - await page.keyboard.press('Control+f'); - await page.waitForTimeout(1000); - - // Paste search text (focus is on "Что искать" field) - await page.keyboard.press('Control+a'); - await pasteText(typeName); - await page.waitForTimeout(300); - - // Find the "Найти" dialog form number (it's > formNum) - const findFormNum = await page.evaluate(`(() => { - for (let n = ${formNum} + 1; n < ${formNum} + 20; n++) { - const btn = document.getElementById('form' + n + '_Find'); - if (btn && btn.offsetWidth > 0) return n; - } - return null; - })()`); - - if (findFormNum === null) { - await page.keyboard.press('Escape'); - await waitForStable(); - throw new Error('selectValue: Ctrl+F did not open "Найти" dialog in type selection'); - } - - // Click "Найти" — search is client-side (no server round-trip), 500ms is enough - await page.click(`#form${findFormNum}_Find`, { force: true }); - await page.waitForTimeout(500); - - // Re-read visible rows after search scrolled to match - const afterSearch = await readVisibleRows(); - - if (afterSearch.matches.length === 0) { - for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } - await waitForStable(); - throw new Error(`selectValue: type "${typeName}" not found in type selection dialog` + - `. Visible: ${(scan.visible || []).join(', ')}`); - } - - if (afterSearch.matches.length > 1) { - for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } - await waitForStable(); - throw new Error(`selectValue: multiple types match "${typeName}": ${afterSearch.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`); - } - - // Click OK on type dialog via page.click({force:true}) — bypasses "Найти" modal - await page.click(`#form${formNum}_OK`, { force: true }); - await page.waitForTimeout(ACTION_WAIT); -} - -/** - * Fill a reference field via clipboard paste + 1C autocomplete. - * - * Strategy: - * 1. Clear field if it has a value (Shift+F4 — native 1C mechanism, no JS errors) - * 2. Clipboard paste text (Ctrl+V = trusted event, triggers real 1C autocomplete) - * 3. Check editDropDown for autocomplete results → click match or Tab to resolve - * 4. Verify result: resolved → ok, not found → clear + error - * - * Clipboard paste was chosen because: - * - Ctrl+V produces trusted browser events that 1C respects for autocomplete - * - page.fill() + synthetic keydown/keyup only triggers hints, not real search - * - keyboard.type() garbles Cyrillic on some fields - * - * @returns {{ field, ok?, method?, error?, value?, message?, available? }} - */ -async function fillReferenceField(selector, fieldName, value, formNum) { - const text = String(value); - const escapedSel = selector.replace(/'/g, "\\'"); - - // Helper: detect new forms opened above the current one (strict — interactive - // elements only; fillReferenceField-specific) - const detectNewForm = () => helperDetectNewForm(formNum, { strict: true }); - - // Helper: clear the field using Shift+F4 (native 1C mechanism) - async function clearField() { - try { - await page.click(selector, { timeout: 3000 }); - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(300); - await page.keyboard.press('Tab'); - await page.waitForTimeout(300); - } catch { /* OK */ } - } - - // Helper: check for "not in list" cloud popup (1C shows positioned div with "нет в списке") - async function checkNotInListCloud() { - return page.evaluate(`(() => { - const divs = document.querySelectorAll('div'); - for (const el of divs) { - if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; - const style = getComputedStyle(el); - if (style.position !== 'absolute' && style.position !== 'fixed') continue; - const z = parseInt(style.zIndex) || 0; - if (z < 100) continue; - if ((el.innerText || '').includes('нет в списке')) return true; - } - return false; - })()`); - } - - // 0. Dismiss any leftover error modal from a previous operation - await dismissPendingErrors(); - - // 0a. Try DLB (DropListButton) first — works cleanly for combobox/enum fields - // and also for reference fields that show a dropdown. - const inputId = selector.match(/\[id="(.+)"\]/)?.[1]; - // DLB button ID uses field name without _iN suffix (e.g. form1_Field_DLB, not form1_Field_i0_DLB) - const dlbId = inputId.replace(/_i\d+$/, '') + '_DLB'; - const dlbSelector = `[id="${dlbId}"]`; - try { - const dlbVisible = await page.evaluate(`document.querySelector('${dlbSelector.replace(/'/g, "\\'")}')?.offsetWidth > 0`); - if (dlbVisible) { - await page.click(dlbSelector); - await page.waitForTimeout(1000); - const eddState = await readEdd(); - if (eddState.visible && eddState.items?.length > 0) { - const target = normYo(text.toLowerCase()); - const candidates = eddState.items.filter(i => !i.name.startsWith('Создать')); - let match = candidates.find(i => normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === target); - if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target)); - if (!match) match = candidates.find(i => { - const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); - return name.includes(target) || target.includes(name); - }); - if (match) { - await page.mouse.click(match.x, match.y); - await waitForStable(); - await dismissPendingErrors(); - return { field: fieldName, ok: true, method: 'dropdown', - value: match.name.replace(/\s*\([^)]*\)\s*$/, '') }; - } - // No match in DLB dropdown — close and fall through to paste approach - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - } else if (eddState.visible) { - // DLB opened a hint popup (no .eddText items) — close it before proceeding - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - } - } - } catch { /* DLB approach failed — fall through to paste */ } - - // 1. Focus (handle surface/modal overlay from previous interaction) - await safeClick(selector, { dismissErrors: true }); - - // 2. If field already has a value, clear using Shift+F4 (native 1C mechanism). - // This is needed for reference fields — Shift+F4 properly clears the ref link. - const currentVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`); - if (currentVal) { - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(500); - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - // Refocus - await page.click(selector); - } - - // 3. Paste text via clipboard (trusted event → triggers real 1C autocomplete) - await pasteText(text); - await page.waitForTimeout(2000); - - // 4. Check editDropDown for autocomplete suggestions - const eddState = await readEdd(); - - if (eddState.visible && eddState.items?.length > 0) { - const target = normYo(text.toLowerCase()); - // Separate real matches from "Создать:" items - const candidates = eddState.items.filter(i => !i.name.startsWith('Создать')); - - if (candidates.length > 0) { - // Find best match (items have format "Name (Code)" — match against name part) - let match = candidates.find(i => { - const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); - return name === target; - }); - if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target)); - if (!match) match = candidates.find(i => { - const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); - return name.includes(target) || target.includes(name); - }); - - if (match) { - await page.mouse.click(match.x, match.y); - await waitForStable(); - await dismissPendingErrors(); // business logic errors (e.g. СПАРК) may appear async - return { field: fieldName, ok: true, method: 'dropdown', - value: match.name.replace(/\s*\([^)]*\)\s*$/, '') }; - } - // Candidates exist but none match — report them - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await clearField(); - return { field: fieldName, error: 'not_matched', - available: candidates.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) }; - } - - // Only "Создать:" items — no existing matches - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await clearField(); - return { field: fieldName, error: 'not_found', - message: 'No existing values match "' + text + '"' }; - } - - // 4b. No edd — check for "not in list" cloud that may have appeared during paste - if (await checkNotInListCloud()) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await clearField(); - return { field: fieldName, error: 'not_found', - message: 'Value "' + text + '" not found (not in list)' }; - } - - // 5. No edd at all — press Tab to trigger direct resolve - await page.keyboard.press('Tab'); - await waitForStable(); - await dismissPendingErrors(); - - // 5x. Check for "not in list" cloud popup after Tab - if (await checkNotInListCloud()) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await clearField(); - return { field: fieldName, error: 'not_found', - message: 'Value "' + text + '" not found (not in list)' }; - } - - // 5a. New form opened? (creation form = value not found) - const newForm = await detectNewForm(); - if (newForm !== null) { - await page.keyboard.press('Escape'); - await waitForStable(); - await clearField(); - return { field: fieldName, error: 'not_found', - message: 'Value "' + text + '" not found' }; - } - - // 5b. Dropdown after Tab? - const popup = await page.evaluate(readSubmenuScript()); - if (Array.isArray(popup) && popup.length > 0) { - const realItems = popup.filter(i => !i.name.startsWith('Создать')); - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await clearField(); - if (realItems.length > 0) { - return { field: fieldName, error: 'ambiguous', - message: 'Multiple matches for "' + text + '"', - available: realItems.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) }; - } - return { field: fieldName, error: 'not_found', - message: 'Value "' + text + '" not found' }; - } - - // 5c. Check final value - const finalVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`); - if (!finalVal) { - // 6. Last resort: try F4 to open selection form and pick from there - try { - await page.click(selector); - await page.waitForTimeout(300); - } catch { /* OK — field may be unfocused */ } - await page.keyboard.press('F4'); - await page.waitForTimeout(ACTION_WAIT); - - const selFormNum = await detectNewForm(); - if (selFormNum !== null) { - const pickResult = await pickFromSelectionForm(selFormNum, fieldName, text, formNum); - if (pickResult.ok) return pickResult; - // pickFromSelectionForm already closed the form on error - } - - return { field: fieldName, error: 'not_found', - message: 'Value "' + text + '" not found (field is empty)' }; - } - - return { field: fieldName, ok: true, method: 'typeahead', value: finalVal }; -} /** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */ @@ -1871,316 +1252,6 @@ export async function closeForm({ save } = {}) { return state; } -/** - * Select a value from a reference field (compound operation). - * Handles three patterns: - * A) DLB opens an inline dropdown popup — click matching item - * B) DLB opens dropdown with history — click "Показать все" or F4 to open selection form - * C) DLB opens a separate selection form directly — search + dblclick in grid - */ -export async function selectValue(fieldName, searchText, { type } = {}) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error(`selectValue: no form found`); - - // Detect any new form opened above this one (broad — includes type dialogs). - // Hoisted to the top so the composite-type branch can call it before its - // original declaration site further below. - const detectNewForm = () => helperDetectNewForm(formNum); - - // 1. Find DLB button (fallback to CB — ERP uses Choose Button instead of DLB for some fields) - let btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'DLB')); - if (btn?.error === 'button_not_found') { - btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'CB')); - } - if (btn?.error) return btn; - if (highlightMode) try { await highlight(fieldName); await page.waitForTimeout(500); await unhighlight(); } catch {} - try { - - // === CLEAR FIELD if searchText is empty/null === - if (!searchText && searchText !== 0) { - const inputId = await findFieldInputId(formNum, btn.fieldName); - if (inputId) { - await page.click(`[id="${inputId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(300); - await page.keyboard.press('Tab'); - await waitForStable(); - } - if (highlightMode) try { await unhighlight(); } catch {} - const formData = await getFormState(); - return { ...formData, selected: { field: fieldName, search: null, method: 'clear' } }; - } - - // === COMPOSITE TYPE HANDLING === - // When `type` is specified, clear the field first to reset cached type, - // then open type selection dialog, pick the type, then pick the value. - if (type) { - // Find and focus the field input - const inputId = await findFieldInputId(formNum, btn.fieldName); - if (!inputId) throw new Error(`selectValue: field "${btn.fieldName}" input not found`); - - // Clear cached type + value with Shift+F4 - await page.click(`[id="${inputId}"]`); - await page.waitForTimeout(300); - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(500); - - // Re-focus and press F4 to open type selection dialog - await page.click(`[id="${inputId}"]`); - await page.waitForTimeout(300); - await page.keyboard.press('F4'); - await page.waitForTimeout(ACTION_WAIT); - await waitForStable(formNum); - - const newFormNum = await detectNewForm(); - if (newFormNum === null) { - throw new Error(`selectValue: F4 for composite field "${btn.fieldName}" did not open type selection dialog`); - } - - if (await isTypeDialog(newFormNum)) { - // Pick type from the dialog - await pickFromTypeDialog(newFormNum, type); - await waitForStable(newFormNum); - - // After type selection, the actual selection form should open - const selFormNum = await detectSelectionForm(); - if (selFormNum === null) { - throw new Error(`selectValue: after selecting type "${type}", no selection form opened for "${btn.fieldName}"`); - } - - const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText || null, type, method: 'form' }; - if (pickResult.error) state.selected.error = pickResult.error; - if (pickResult.message) state.selected.message = pickResult.message; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } else { - // Not a type dialog — field is not composite type, proceed with normal selection - const pickResult = await pickFromSelectionForm(newFormNum, btn.fieldName, searchText || '', formNum); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; - if (pickResult.error) state.selected.error = pickResult.error; - if (pickResult.message) state.selected.message = pickResult.message; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } - } - // === END COMPOSITE TYPE HANDLING === - - // Auto-enable DCS checkbox if resolved via label - if (btn.dcsCheckbox) { - const cbSel = `[id="${btn.dcsCheckbox.inputId}"]`; - const isChecked = await page.$eval(cbSel, el => - el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select')); - if (!isChecked) { await page.click(cbSel); await waitForStable(); } - } - - // Helper: detect selection form (form number > formNum) - async function detectSelectionForm() { - return page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { - if (el.offsetWidth === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - } - - // detectNewForm is hoisted at the top of selectValue (see above). - - // Helper: open selection form and pick value - async function openFormAndPick() { - await waitForStable(formNum); - const selFormNum = await detectSelectionForm(); - if (selFormNum !== null) { - const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; - if (pickResult.error) state.selected.error = pickResult.error; - if (pickResult.message) state.selected.message = pickResult.message; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } - return null; - } - - // Helper: click EDD item via evaluate (bypasses div.surface overlay from DLB) - // page.mouse.click() doesn't work here — surface intercepts pointer events. - // Dispatching mousedown directly on the element avoids this. - async function clickEddItem(itemName) { - return page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return null; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const target = ny(${JSON.stringify(itemName.toLowerCase())}); - const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0); - function clickEl(el) { - const r = el.getBoundingClientRect(); - const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - el.dispatchEvent(new MouseEvent('mousedown', opts)); - el.dispatchEvent(new MouseEvent('mouseup', opts)); - el.dispatchEvent(new MouseEvent('click', opts)); - return el.innerText.trim(); - } - // Pass 1: exact match (prefer over partial) - for (const el of items) { - const t = ny((el.innerText?.trim() || '').toLowerCase()); - if (t === target) return clickEl(el); - const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, ''); - if (stripped === target) return clickEl(el); - } - // Pass 2: partial match - for (const el of items) { - const t = ny((el.innerText?.trim() || '').toLowerCase()); - if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el); - } - return null; - })()`); - } - - // Helper: click "Показать все" in EDD footer via evaluate - async function clickShowAll() { - return page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return false; - let el = edd.querySelector('.eddBottom .hyperlink'); - if (!el || el.offsetWidth === 0) { - const candidates = [...edd.querySelectorAll('span, div, a')] - .filter(e => e.offsetWidth > 0 && e.children.length === 0); - el = candidates.find(e => { - const t = (e.innerText?.trim() || '').toLowerCase(); - return t === 'показать все' || t === 'show all'; - }); - } - if (!el) return false; - const r = el.getBoundingClientRect(); - const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - el.dispatchEvent(new MouseEvent('mousedown', opts)); - el.dispatchEvent(new MouseEvent('mouseup', opts)); - el.dispatchEvent(new MouseEvent('click', opts)); - return true; - })()`); - } - - // 2. Click DLB (handle funcPanel / surface overlay intercept) - const dlbSel = `[id="${btn.buttonId}"]`; - await safeClick(dlbSel, { timeout: 5000 }); - await page.waitForTimeout(ACTION_WAIT); - - // 3A. Check if a dropdown popup appeared (inline quick selection) - const popupItems = await page.evaluate(readSubmenuScript()); - if (Array.isArray(popupItems) && popupItems.length > 0) { - const regularItems = popupItems.filter(i => i.kind !== 'showAll'); - const showAllItem = popupItems.find(i => i.kind === 'showAll'); - - if (searchText) { - const target = normYo(searchText.toLowerCase()); - // Try to find match among regular dropdown items - let match = regularItems.find(i => normYo(i.name.toLowerCase()) === target); - if (!match) match = regularItems.find(i => normYo(i.name.toLowerCase()).includes(target)); - if (!match) match = regularItems.find(i => { - const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); - return name === target || name.includes(target) || target.includes(name); - }); - - if (match) { - // Click via evaluate to bypass div.surface overlay - await clickEddItem(match.name); - await waitForStable(); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText, method: 'dropdown' }; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } - - // No match in dropdown — try "Показать все" to open selection form - if (showAllItem) { - await clickShowAll(); - const formResult = await openFormAndPick(); - if (formResult) return formResult; - } - - // No "Показать все" — close dropdown, try F4 - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - - // Focus the field input and press F4 to open selection form - const inputId = await findFieldInputId(formNum, btn.fieldName); - if (inputId) { - await page.click(`[id="${inputId}"]`); - await page.waitForTimeout(300); - } - await page.keyboard.press('F4'); - await page.waitForTimeout(ACTION_WAIT); - - const formResult = await openFormAndPick(); - if (formResult) return formResult; - - // Still nothing — report available items from original dropdown - throw new Error(`selectValue: "${searchText}" not found for field "${btn.fieldName}". Available: ${regularItems.map(i => i.name).join(', ') || 'none'}`); - } - - // No search text — click first regular item - if (regularItems.length > 0) { - await clickEddItem(regularItems[0].name); - await waitForStable(); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: null, picked: regularItems[0].name, method: 'dropdown' }; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } - } - - // 3B. Check if a new selection form opened directly (use broad detection to also catch type dialogs) - const selFormNum = await detectNewForm(); - if (selFormNum !== null) { - // Auto-detect type selection dialog when `type` was not specified - if (await isTypeDialog(selFormNum)) { - await page.keyboard.press('Escape'); - await waitForStable(); - throw new Error(`selectValue: field "${btn.fieldName}" opened a type selection dialog — this is a composite-type field. Specify the type: selectValue('${btn.fieldName}', '${searchText || ''}', { type: 'ИмяТипа' })`); - } - const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; - if (pickResult.error) state.selected.error = pickResult.error; - if (pickResult.message) state.selected.message = pickResult.message; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } - - // 3C. Neither popup nor form — try F4 as last resort - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - - const inputId = await findFieldInputId(formNum, btn.fieldName); - if (inputId) { - await page.click(`[id="${inputId}"]`); - await page.waitForTimeout(300); - } - await page.keyboard.press('F4'); - await page.waitForTimeout(ACTION_WAIT); - - const formResult = await openFormAndPick(); - if (formResult) return formResult; - - throw new Error(`selectValue: DLB click for "${btn.fieldName}" did not open a popup or selection form`); - - } finally { if (highlightMode) try { await unhighlight(); } catch {} } -} /** * Fill cells in the current table row via Tab navigation. diff --git a/.claude/skills/web-test/scripts/forms/select-value.mjs b/.claude/skills/web-test/scripts/forms/select-value.mjs new file mode 100644 index 00000000..527dafaa --- /dev/null +++ b/.claude/skills/web-test/scripts/forms/select-value.mjs @@ -0,0 +1,959 @@ +// web-test forms/select-value v1.16 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { + page, ensureConnected, normYo, highlightMode, ACTION_WAIT, +} from '../core/state.mjs'; +import { + detectFormScript, findFieldButtonScript, resolveFieldsScript, + readSubmenuScript, checkErrorsScript, +} 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 { + safeClick, findFieldInputId, readEdd, + detectNewForm as helperDetectNewForm, +} from '../core/helpers.mjs'; +// pasteText + getFormState live in browser.mjs. +import { pasteText, getFormState } from '../browser.mjs'; + +/** + * Scan visible grid rows for a text match (exact → startsWith → includes). + * Returns center coords of the matched row, or null if not found. + * When searchLower is empty, returns coords of the first row (fallback). + */ +async function scanGridRows(formNum, searchLower) { + return page.evaluate(`(() => { + const p = 'form${formNum}_'; + const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const lines = [...body.querySelectorAll('.gridLine')]; + if (!lines.length) return { rowCount: 0 }; + const searchLower = ${JSON.stringify(searchLower || '')}; + let sel = null; + if (searchLower) { + const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е'); + const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) })); + sel = rowData.find(r => r.text === searchLower)?.el + || rowData.find(r => r.text.startsWith(searchLower))?.el + || rowData.find(r => r.text.includes(searchLower))?.el; + } else { + sel = lines[0]; // empty search → first row + } + if (!sel) return null; + const imgBox = sel.querySelector('.gridBoxImg'); + const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false; + const r = sel.getBoundingClientRect(); + return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup }; + })()`); +} + +/** + * Select a row in a selection form via click + Enter, verify it closed. + * Uses click + Enter instead of dblclick because dblclick toggles + * expand/collapse in tree-style selection forms. + * Returns { field, ok: true, method: 'form' } on success, + * or { field, ok: false, reason: 'still_open' } if the item couldn't be selected (e.g. group row). + */ +async function dblclickAndVerify(coords, selFormNum, fieldName) { + // Click to highlight the row, then Enter to confirm selection. + // This works for both flat grids and tree forms (dblclick would + // toggle expand/collapse on tree group rows). + await page.mouse.click(coords.x, coords.y); + await page.waitForTimeout(200); + await page.keyboard.press('Enter'); + await waitForStable(selFormNum); + + // Verify selection form closed + const stillOpen = await page.evaluate(`(() => { + const p = 'form${selFormNum}_'; + return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); + })()`); + if (stillOpen) { + // Enter didn't select — item is likely a non-selectable group. + // Don't Escape here — let the caller decide (may want to try another row). + return { field: fieldName, ok: false, reason: 'still_open' }; + } + + // Check for 1C error modals after selection + const err = await page.evaluate(checkErrorsScript()); + if (err?.modal) { + try { + const btn = await page.$('a.press.pressDefault'); + if (btn) { await btn.click(); await page.waitForTimeout(500); } + } catch { /* OK */ } + } + return { field: fieldName, ok: true, method: 'form' }; +} + +/** + * Inline advanced search on a selection form via Alt+F. + * Does NOT click any column — FieldSelector auto-populates with main representation. + * Switches to "по части строки" (CompareType#1) to avoid composite type issues. + * Does not throw — returns silently on failure. + */ +async function advancedSearchInline(formNum, text) { + try { + // 1. Open advanced search via Alt+F + await page.keyboard.press('Alt+f'); + await page.waitForTimeout(2000); + + const dialogForm = await page.evaluate(detectFormScript()); + if (dialogForm === formNum || dialogForm === null) return; // Alt+F didn't open dialog + + // 2. Switch to "по части строки" (CompareType#1) + const radioClicked = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + const el = document.getElementById(p + 'CompareType#1#radio'); + if (!el || el.offsetWidth === 0) return false; + if (el.classList.contains('select')) return true; // already selected + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (radioClicked && typeof radioClicked === 'object') { + await page.mouse.click(radioClicked.x, radioClicked.y); + await page.waitForTimeout(300); + } + + // 3. Fill Pattern field via clipboard paste + const patternId = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); + return el ? el.id : null; + })()`); + if (!patternId) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + return; + } + await page.click(`[id="${patternId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await pasteText(text); + await page.waitForTimeout(300); + + // 4. Click "Найти" + const findBtn = await page.evaluate(`(() => { + const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); + const btn = btns.find(el => el.innerText?.trim() === 'Найти'); + if (!btn) return null; + const r = btn.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (findBtn) { + await page.mouse.click(findBtn.x, findBtn.y); + await page.waitForTimeout(2000); + } + + // 5. Close advanced search dialog + for (let attempt = 0; attempt < 3; attempt++) { + const dialogVisible = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); + })()`); + if (!dialogVisible) break; + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + await waitForStable(formNum); + } catch { /* silently fail — caller will re-scan and handle not_found */ } +} + +/** + * Pick a value from an opened selection form. + * + * Strategy (escalating): + * 1. Scan visible rows for text match (exact → startsWith → includes) + * 2. Advanced search (Alt+F, "по части строки") → re-scan + * 3. Fallback: simple search (search input + Enter) → re-scan + * 4. Not found → Escape → error + * + * For object search {field: value}: steps 1, then filterList(val, {field}) per entry, then re-scan. + * For empty search: pick first visible row. + * + * @param {number} selFormNum - selection form number + * @param {string} fieldName - field being filled (for error messages) + * @param {string|Object} search - string for simple search, or { field: value } for per-field search + * @param {number} origFormNum - original form number (to verify we returned) + * @returns {{ field, ok, method }} or {{ field, error, message }} + */ +export async function pickFromSelectionForm(selFormNum, fieldName, search, origFormNum) { + const searchText = typeof search === 'string' + ? search : (search ? Object.values(search).join(' ') : ''); + const searchLower = normYo((searchText || '').toLowerCase()); + + // Helper: try to select a row; returns result if ok, null if item wasn't selectable (group). + let hadUnselectableMatch = false; + async function trySelect(row) { + const r = await dblclickAndVerify(row, selFormNum, fieldName); + if (r.ok) return r; + hadUnselectableMatch = true; // found match but couldn't select (possibly group row or overlay) + return null; // form still open, try next step + } + + // Step 1: Scan visible rows (no filtering) + if (searchLower) { + const row = await scanGridRows(selFormNum, searchLower); + if (row?.x) { + const r = await trySelect(row); + if (r) return r; + } + } + + // Step 2: Advanced search (Alt+F — fast, no overlay issues) + if (typeof search === 'object' && search) { + // Per-field advanced search via filterList(val, {field}) + for (const [fld, val] of Object.entries(search)) { + try { await filterList(String(val), { field: fld }); } catch { /* proceed */ } + } + } else if (searchLower) { + // Inline advanced search (Alt+F, "по части строки") + await advancedSearchInline(selFormNum, searchText); + } + if (searchLower) { + const row = await scanGridRows(selFormNum, searchLower); + if (row?.x) { + const r = await trySelect(row); + if (r) return r; + } + } + + // Step 3: Fallback — simple search via search input (for forms without Alt+F support) + if (typeof search === 'string' && searchLower) { + const searchInputId = await page.evaluate(`(() => { + const p = 'form${selFormNum}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); + return el ? el.id : null; + })()`); + if (searchInputId) { + try { + await page.click(`[id="${searchInputId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await pasteText(searchText); + await page.waitForTimeout(300); + await page.keyboard.press('Enter'); + await waitForStable(selFormNum); + } catch { /* proceed */ } + const row = await scanGridRows(selFormNum, searchLower); + if (row?.x) { + const r = await trySelect(row); + if (r) return r; + } + } + } + + // Step 4: Empty search → pick first row; otherwise not found + if (!searchLower) { + const row = await scanGridRows(selFormNum, ''); + if (row?.x) { + const r = await trySelect(row); + if (r) return r; + } + } + + await page.keyboard.press('Escape'); + await waitForStable(); + const searchDesc = typeof search === 'string' ? '"' + search + '"' : JSON.stringify(search); + if (hadUnselectableMatch) { + return { field: fieldName, error: 'not_selectable', + message: 'Found ' + searchDesc + ' in selection form but it is not selectable (group/folder row)' }; + } + return { field: fieldName, error: 'not_found', + message: 'No matches in selection form for ' + searchDesc }; +} + +/** + * Detect whether a form is a type selection dialog ("Выбор типа данных"). + * Type dialogs appear when selecting a value for a composite-type field. + * + * Detection signals (any one is sufficient): + * - form{N}_OK element exists (selection forms use "Выбрать", not "OK") + * - form{N}_ValueList grid exists (specific to type/value list dialogs) + * - Window title contains "Выбор типа" (title attr on .toplineBoxTitle) + */ +export async function isTypeDialog(formNum) { + return page.evaluate(`(() => { + const p = 'form' + ${formNum} + '_'; + const hasOK = !!document.getElementById(p + 'OK'); + const hasValueList = !!document.getElementById(p + 'ValueList'); + const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')] + .some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || '')); + return hasOK || hasValueList || hasTitle; + })()`); +} + +/** + * Select a type from the type selection dialog ("Выбор типа данных") + * using Ctrl+F search. The dialog has a virtual grid (~5 visible rows), + * so Ctrl+F is the only reliable way to find a type. + * + * Algorithm: Ctrl+F → paste typeName → Enter (search) → Escape (close Find) → + * verify selected row matches → Enter (OK) + * + * @param {number} formNum - type dialog form number + * @param {string} typeName - type name to search for (fuzzy, e.g. "Реализация (акт") + * @throws {Error} if type not found + */ +export async function pickFromTypeDialog(formNum, typeName) { + // The type dialog is a modal ValueList grid. + // Strategy: scan visible rows first (fast path), fall back to Ctrl+F for large lists. + // + // Key constraints discovered during testing: + // - Grid focus: use evaluate(() => gridBody.focus()), NOT page.click({force:true}) + // which punches through the modal overlay to the form underneath + // - Ctrl+F only opens "Найти" if the GRID is focused (otherwise closes the type dialog) + // - Buttons: use page.click({force:true}), NOT evaluate(() => el.click()) + // because evaluate click doesn't trigger 1C's event chain properly + // - Enter/Escape in "Найти" close the ENTIRE dialog chain, not just "Найти" + // - Closing "Найти" via Cancel resets the search — verify grid while "Найти" is open + + const typeNorm = normYo(typeName.toLowerCase()); + + // Helper: read visible rows and find matching ones + async function readVisibleRows() { + return page.evaluate(`(() => { + const grid = document.getElementById('form${formNum}_ValueList'); + if (!grid) return { visible: [], matches: [] }; + const body = grid.querySelector('.gridBody'); + if (!body) return { visible: [], matches: [] }; + const lines = body.querySelectorAll('.gridLine'); + const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim(); + const typeNorm = ${JSON.stringify(typeNorm)}; + const visible = []; + const matches = []; + for (const line of lines) { + const text = norm(line.innerText); + if (!text) continue; + visible.push(text); + if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) { + const r = line.getBoundingClientRect(); + matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); + } + } + return { visible, matches }; + })()`); + } + + // Step 1: Scan visible rows (fast path — no Ctrl+F needed for small lists) + const scan = await readVisibleRows(); + + if (scan.matches.length === 1) { + // Single match — click to select, then OK + await page.mouse.click(scan.matches[0].x, scan.matches[0].y); + await page.waitForTimeout(200); + await page.click(`#form${formNum}_OK`, { force: true }); + await page.waitForTimeout(ACTION_WAIT); + return; + } + + if (scan.matches.length > 1) { + for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } + await waitForStable(); + throw new Error(`selectValue: multiple types match "${typeName}": ${scan.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`); + } + + // Step 2: Not found in visible rows — use Ctrl+F (virtual grid may have more items) + + // Focus the grid via evaluate (does NOT punch through modal like page.click) + await page.evaluate(`(() => { + const grid = document.getElementById('form${formNum}_ValueList'); + if (!grid) return; + const body = grid.querySelector('.gridBody'); + if (body) body.focus(); else grid.focus(); + })()`); + await page.waitForTimeout(300); + + // Ctrl+F to open "Найти" dialog + await page.keyboard.press('Control+f'); + await page.waitForTimeout(1000); + + // Paste search text (focus is on "Что искать" field) + await page.keyboard.press('Control+a'); + await pasteText(typeName); + await page.waitForTimeout(300); + + // Find the "Найти" dialog form number (it's > formNum) + const findFormNum = await page.evaluate(`(() => { + for (let n = ${formNum} + 1; n < ${formNum} + 20; n++) { + const btn = document.getElementById('form' + n + '_Find'); + if (btn && btn.offsetWidth > 0) return n; + } + return null; + })()`); + + if (findFormNum === null) { + await page.keyboard.press('Escape'); + await waitForStable(); + throw new Error('selectValue: Ctrl+F did not open "Найти" dialog in type selection'); + } + + // Click "Найти" — search is client-side (no server round-trip), 500ms is enough + await page.click(`#form${findFormNum}_Find`, { force: true }); + await page.waitForTimeout(500); + + // Re-read visible rows after search scrolled to match + const afterSearch = await readVisibleRows(); + + if (afterSearch.matches.length === 0) { + for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } + await waitForStable(); + throw new Error(`selectValue: type "${typeName}" not found in type selection dialog` + + `. Visible: ${(scan.visible || []).join(', ')}`); + } + + if (afterSearch.matches.length > 1) { + for (let i = 0; i < 3; i++) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); } + await waitForStable(); + throw new Error(`selectValue: multiple types match "${typeName}": ${afterSearch.matches.map(m => '"' + m.text + '"').join(', ')}. Specify a more precise type name`); + } + + // Click OK on type dialog via page.click({force:true}) — bypasses "Найти" modal + await page.click(`#form${formNum}_OK`, { force: true }); + await page.waitForTimeout(ACTION_WAIT); +} + +/** + * Fill a reference field via clipboard paste + 1C autocomplete. + * + * Strategy: + * 1. Clear field if it has a value (Shift+F4 — native 1C mechanism, no JS errors) + * 2. Clipboard paste text (Ctrl+V = trusted event, triggers real 1C autocomplete) + * 3. Check editDropDown for autocomplete results → click match or Tab to resolve + * 4. Verify result: resolved → ok, not found → clear + error + * + * Clipboard paste was chosen because: + * - Ctrl+V produces trusted browser events that 1C respects for autocomplete + * - page.fill() + synthetic keydown/keyup only triggers hints, not real search + * - keyboard.type() garbles Cyrillic on some fields + * + * @returns {{ field, ok?, method?, error?, value?, message?, available? }} + */ +export async function fillReferenceField(selector, fieldName, value, formNum) { + const text = String(value); + const escapedSel = selector.replace(/'/g, "\\'"); + + // Helper: detect new forms opened above the current one (strict — interactive + // elements only; fillReferenceField-specific) + const detectNewForm = () => helperDetectNewForm(formNum, { strict: true }); + + // Helper: clear the field using Shift+F4 (native 1C mechanism) + async function clearField() { + try { + await page.click(selector, { timeout: 3000 }); + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(300); + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + } catch { /* OK */ } + } + + // Helper: check for "not in list" cloud popup (1C shows positioned div with "нет в списке") + async function checkNotInListCloud() { + return page.evaluate(`(() => { + const divs = document.querySelectorAll('div'); + for (const el of divs) { + if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; + const style = getComputedStyle(el); + if (style.position !== 'absolute' && style.position !== 'fixed') continue; + const z = parseInt(style.zIndex) || 0; + if (z < 100) continue; + if ((el.innerText || '').includes('нет в списке')) return true; + } + return false; + })()`); + } + + // 0. Dismiss any leftover error modal from a previous operation + await dismissPendingErrors(); + + // 0a. Try DLB (DropListButton) first — works cleanly for combobox/enum fields + // and also for reference fields that show a dropdown. + const inputId = selector.match(/\[id="(.+)"\]/)?.[1]; + // DLB button ID uses field name without _iN suffix (e.g. form1_Field_DLB, not form1_Field_i0_DLB) + const dlbId = inputId.replace(/_i\d+$/, '') + '_DLB'; + const dlbSelector = `[id="${dlbId}"]`; + try { + const dlbVisible = await page.evaluate(`document.querySelector('${dlbSelector.replace(/'/g, "\\'")}')?.offsetWidth > 0`); + if (dlbVisible) { + await page.click(dlbSelector); + await page.waitForTimeout(1000); + const eddState = await readEdd(); + if (eddState.visible && eddState.items?.length > 0) { + const target = normYo(text.toLowerCase()); + const candidates = eddState.items.filter(i => !i.name.startsWith('Создать')); + let match = candidates.find(i => normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === target); + if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target)); + if (!match) match = candidates.find(i => { + const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); + return name.includes(target) || target.includes(name); + }); + if (match) { + await page.mouse.click(match.x, match.y); + await waitForStable(); + await dismissPendingErrors(); + return { field: fieldName, ok: true, method: 'dropdown', + value: match.name.replace(/\s*\([^)]*\)\s*$/, '') }; + } + // No match in DLB dropdown — close and fall through to paste approach + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + } else if (eddState.visible) { + // DLB opened a hint popup (no .eddText items) — close it before proceeding + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + } + } + } catch { /* DLB approach failed — fall through to paste */ } + + // 1. Focus (handle surface/modal overlay from previous interaction) + await safeClick(selector, { dismissErrors: true }); + + // 2. If field already has a value, clear using Shift+F4 (native 1C mechanism). + // This is needed for reference fields — Shift+F4 properly clears the ref link. + const currentVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`); + if (currentVal) { + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(500); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + // Refocus + await page.click(selector); + } + + // 3. Paste text via clipboard (trusted event → triggers real 1C autocomplete) + await pasteText(text); + await page.waitForTimeout(2000); + + // 4. Check editDropDown for autocomplete suggestions + const eddState = await readEdd(); + + if (eddState.visible && eddState.items?.length > 0) { + const target = normYo(text.toLowerCase()); + // Separate real matches from "Создать:" items + const candidates = eddState.items.filter(i => !i.name.startsWith('Создать')); + + if (candidates.length > 0) { + // Find best match (items have format "Name (Code)" — match against name part) + let match = candidates.find(i => { + const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); + return name === target; + }); + if (!match) match = candidates.find(i => normYo(i.name.toLowerCase()).includes(target)); + if (!match) match = candidates.find(i => { + const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); + return name.includes(target) || target.includes(name); + }); + + if (match) { + await page.mouse.click(match.x, match.y); + await waitForStable(); + await dismissPendingErrors(); // business logic errors (e.g. СПАРК) may appear async + return { field: fieldName, ok: true, method: 'dropdown', + value: match.name.replace(/\s*\([^)]*\)\s*$/, '') }; + } + // Candidates exist but none match — report them + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + return { field: fieldName, error: 'not_matched', + available: candidates.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) }; + } + + // Only "Создать:" items — no existing matches + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + return { field: fieldName, error: 'not_found', + message: 'No existing values match "' + text + '"' }; + } + + // 4b. No edd — check for "not in list" cloud that may have appeared during paste + if (await checkNotInListCloud()) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found (not in list)' }; + } + + // 5. No edd at all — press Tab to trigger direct resolve + await page.keyboard.press('Tab'); + await waitForStable(); + await dismissPendingErrors(); + + // 5x. Check for "not in list" cloud popup after Tab + if (await checkNotInListCloud()) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found (not in list)' }; + } + + // 5a. New form opened? (creation form = value not found) + const newForm = await detectNewForm(); + if (newForm !== null) { + await page.keyboard.press('Escape'); + await waitForStable(); + await clearField(); + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found' }; + } + + // 5b. Dropdown after Tab? + const popup = await page.evaluate(readSubmenuScript()); + if (Array.isArray(popup) && popup.length > 0) { + const realItems = popup.filter(i => !i.name.startsWith('Создать')); + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await clearField(); + if (realItems.length > 0) { + return { field: fieldName, error: 'ambiguous', + message: 'Multiple matches for "' + text + '"', + available: realItems.map(i => i.name.replace(/\s*\([^)]*\)\s*$/, '')) }; + } + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found' }; + } + + // 5c. Check final value + const finalVal = await page.evaluate(`document.querySelector('${escapedSel}')?.value || ''`); + if (!finalVal) { + // 6. Last resort: try F4 to open selection form and pick from there + try { + await page.click(selector); + await page.waitForTimeout(300); + } catch { /* OK — field may be unfocused */ } + await page.keyboard.press('F4'); + await page.waitForTimeout(ACTION_WAIT); + + const selFormNum = await detectNewForm(); + if (selFormNum !== null) { + const pickResult = await pickFromSelectionForm(selFormNum, fieldName, text, formNum); + if (pickResult.ok) return pickResult; + // pickFromSelectionForm already closed the form on error + } + + return { field: fieldName, error: 'not_found', + message: 'Value "' + text + '" not found (field is empty)' }; + } + + return { field: fieldName, ok: true, method: 'typeahead', value: finalVal }; +} + +/** + * Select a value from a reference field (compound operation). + * Handles three patterns: + * A) DLB opens an inline dropdown popup — click matching item + * B) DLB opens dropdown with history — click "Показать все" or F4 to open selection form + * C) DLB opens a separate selection form directly — search + dblclick in grid + */ +export async function selectValue(fieldName, searchText, { type } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error(`selectValue: no form found`); + + // Detect any new form opened above this one (broad — includes type dialogs). + // Hoisted to the top so the composite-type branch can call it before its + // original declaration site further below. + const detectNewForm = () => helperDetectNewForm(formNum); + + // 1. Find DLB button (fallback to CB — ERP uses Choose Button instead of DLB for some fields) + let btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'DLB')); + if (btn?.error === 'button_not_found') { + btn = await page.evaluate(findFieldButtonScript(formNum, fieldName, 'CB')); + } + if (btn?.error) return btn; + if (highlightMode) try { await highlight(fieldName); await page.waitForTimeout(500); await unhighlight(); } catch {} + try { + + // === CLEAR FIELD if searchText is empty/null === + if (!searchText && searchText !== 0) { + const inputId = await findFieldInputId(formNum, btn.fieldName); + if (inputId) { + await page.click(`[id="${inputId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(300); + await page.keyboard.press('Tab'); + await waitForStable(); + } + if (highlightMode) try { await unhighlight(); } catch {} + const formData = await getFormState(); + return { ...formData, selected: { field: fieldName, search: null, method: 'clear' } }; + } + + // === COMPOSITE TYPE HANDLING === + // When `type` is specified, clear the field first to reset cached type, + // then open type selection dialog, pick the type, then pick the value. + if (type) { + // Find and focus the field input + const inputId = await findFieldInputId(formNum, btn.fieldName); + if (!inputId) throw new Error(`selectValue: field "${btn.fieldName}" input not found`); + + // Clear cached type + value with Shift+F4 + await page.click(`[id="${inputId}"]`); + await page.waitForTimeout(300); + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(500); + + // Re-focus and press F4 to open type selection dialog + await page.click(`[id="${inputId}"]`); + await page.waitForTimeout(300); + await page.keyboard.press('F4'); + await page.waitForTimeout(ACTION_WAIT); + await waitForStable(formNum); + + const newFormNum = await detectNewForm(); + if (newFormNum === null) { + throw new Error(`selectValue: F4 for composite field "${btn.fieldName}" did not open type selection dialog`); + } + + if (await isTypeDialog(newFormNum)) { + // Pick type from the dialog + await pickFromTypeDialog(newFormNum, type); + await waitForStable(newFormNum); + + // After type selection, the actual selection form should open + const selFormNum = await detectSelectionForm(); + if (selFormNum === null) { + throw new Error(`selectValue: after selecting type "${type}", no selection form opened for "${btn.fieldName}"`); + } + + const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText || null, type, method: 'form' }; + if (pickResult.error) state.selected.error = pickResult.error; + if (pickResult.message) state.selected.message = pickResult.message; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } else { + // Not a type dialog — field is not composite type, proceed with normal selection + const pickResult = await pickFromSelectionForm(newFormNum, btn.fieldName, searchText || '', formNum); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; + if (pickResult.error) state.selected.error = pickResult.error; + if (pickResult.message) state.selected.message = pickResult.message; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + } + // === END COMPOSITE TYPE HANDLING === + + // Auto-enable DCS checkbox if resolved via label + if (btn.dcsCheckbox) { + const cbSel = `[id="${btn.dcsCheckbox.inputId}"]`; + const isChecked = await page.$eval(cbSel, el => + el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select')); + if (!isChecked) { await page.click(cbSel); await waitForStable(); } + } + + // Helper: detect selection form (form number > formNum) + async function detectSelectionForm() { + return page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { + if (el.offsetWidth === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + } + + // detectNewForm is hoisted at the top of selectValue (see above). + + // Helper: open selection form and pick value + async function openFormAndPick() { + await waitForStable(formNum); + const selFormNum = await detectSelectionForm(); + if (selFormNum !== null) { + const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; + if (pickResult.error) state.selected.error = pickResult.error; + if (pickResult.message) state.selected.message = pickResult.message; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + return null; + } + + // Helper: click EDD item via evaluate (bypasses div.surface overlay from DLB) + // page.mouse.click() doesn't work here — surface intercepts pointer events. + // Dispatching mousedown directly on the element avoids this. + async function clickEddItem(itemName) { + return page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return null; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const target = ny(${JSON.stringify(itemName.toLowerCase())}); + const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0); + function clickEl(el) { + const r = el.getBoundingClientRect(); + const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + el.dispatchEvent(new MouseEvent('mousedown', opts)); + el.dispatchEvent(new MouseEvent('mouseup', opts)); + el.dispatchEvent(new MouseEvent('click', opts)); + return el.innerText.trim(); + } + // Pass 1: exact match (prefer over partial) + for (const el of items) { + const t = ny((el.innerText?.trim() || '').toLowerCase()); + if (t === target) return clickEl(el); + const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, ''); + if (stripped === target) return clickEl(el); + } + // Pass 2: partial match + for (const el of items) { + const t = ny((el.innerText?.trim() || '').toLowerCase()); + if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el); + } + return null; + })()`); + } + + // Helper: click "Показать все" in EDD footer via evaluate + async function clickShowAll() { + return page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return false; + let el = edd.querySelector('.eddBottom .hyperlink'); + if (!el || el.offsetWidth === 0) { + const candidates = [...edd.querySelectorAll('span, div, a')] + .filter(e => e.offsetWidth > 0 && e.children.length === 0); + el = candidates.find(e => { + const t = (e.innerText?.trim() || '').toLowerCase(); + return t === 'показать все' || t === 'show all'; + }); + } + if (!el) return false; + const r = el.getBoundingClientRect(); + const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + el.dispatchEvent(new MouseEvent('mousedown', opts)); + el.dispatchEvent(new MouseEvent('mouseup', opts)); + el.dispatchEvent(new MouseEvent('click', opts)); + return true; + })()`); + } + + // 2. Click DLB (handle funcPanel / surface overlay intercept) + const dlbSel = `[id="${btn.buttonId}"]`; + await safeClick(dlbSel, { timeout: 5000 }); + await page.waitForTimeout(ACTION_WAIT); + + // 3A. Check if a dropdown popup appeared (inline quick selection) + const popupItems = await page.evaluate(readSubmenuScript()); + if (Array.isArray(popupItems) && popupItems.length > 0) { + const regularItems = popupItems.filter(i => i.kind !== 'showAll'); + const showAllItem = popupItems.find(i => i.kind === 'showAll'); + + if (searchText) { + const target = normYo(searchText.toLowerCase()); + // Try to find match among regular dropdown items + let match = regularItems.find(i => normYo(i.name.toLowerCase()) === target); + if (!match) match = regularItems.find(i => normYo(i.name.toLowerCase()).includes(target)); + if (!match) match = regularItems.find(i => { + const name = normYo(i.name.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()); + return name === target || name.includes(target) || target.includes(name); + }); + + if (match) { + // Click via evaluate to bypass div.surface overlay + await clickEddItem(match.name); + await waitForStable(); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText, method: 'dropdown' }; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + + // No match in dropdown — try "Показать все" to open selection form + if (showAllItem) { + await clickShowAll(); + const formResult = await openFormAndPick(); + if (formResult) return formResult; + } + + // No "Показать все" — close dropdown, try F4 + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + + // Focus the field input and press F4 to open selection form + const inputId = await findFieldInputId(formNum, btn.fieldName); + if (inputId) { + await page.click(`[id="${inputId}"]`); + await page.waitForTimeout(300); + } + await page.keyboard.press('F4'); + await page.waitForTimeout(ACTION_WAIT); + + const formResult = await openFormAndPick(); + if (formResult) return formResult; + + // Still nothing — report available items from original dropdown + throw new Error(`selectValue: "${searchText}" not found for field "${btn.fieldName}". Available: ${regularItems.map(i => i.name).join(', ') || 'none'}`); + } + + // No search text — click first regular item + if (regularItems.length > 0) { + await clickEddItem(regularItems[0].name); + await waitForStable(); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: null, picked: regularItems[0].name, method: 'dropdown' }; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + } + + // 3B. Check if a new selection form opened directly (use broad detection to also catch type dialogs) + const selFormNum = await detectNewForm(); + if (selFormNum !== null) { + // Auto-detect type selection dialog when `type` was not specified + if (await isTypeDialog(selFormNum)) { + await page.keyboard.press('Escape'); + await waitForStable(); + throw new Error(`selectValue: field "${btn.fieldName}" opened a type selection dialog — this is a composite-type field. Specify the type: selectValue('${btn.fieldName}', '${searchText || ''}', { type: 'ИмяТипа' })`); + } + const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); + const state = await getFormState(); + state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; + if (pickResult.error) state.selected.error = pickResult.error; + if (pickResult.message) state.selected.message = pickResult.message; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + + // 3C. Neither popup nor form — try F4 as last resort + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + + const inputId = await findFieldInputId(formNum, btn.fieldName); + if (inputId) { + await page.click(`[id="${inputId}"]`); + await page.waitForTimeout(300); + } + await page.keyboard.press('F4'); + await page.waitForTimeout(ACTION_WAIT); + + const formResult = await openFormAndPick(); + if (formResult) return formResult; + + throw new Error(`selectValue: DLB click for "${btn.fieldName}" did not open a popup or selection form`); + + } finally { if (highlightMode) try { await unhighlight(); } catch {} } +} From cbd580a0bdabd3c71b964b1b67a2e9be44c85487 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 13:04:09 +0300 Subject: [PATCH 15/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20C.9=20=E2=80=94=20=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=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 }); +} From 9ee047341229071294b0d40b3f2e36762f8fc0ce Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 13:07:22 +0300 Subject: [PATCH 16/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20C.10=20=E2=80=94=20clickElement=20=E2=86=92=20core/cli?= =?UTF-8?q?ck.mjs=20(=D1=86=D0=B5=D0=BB=D0=B8=D0=BA=D0=BE=D0=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clickElement (~300 LOC) перенесён единым блоком в core/click.mjs. Поведение 1-в-1. Внутри остаётся всё ветвление (spreadsheet, submenu, gridGroup/Parent, gridTreeNode, gridRow, tab, button) — разнос на forms/click-form.mjs + nav/click-popup.mjs + finer table-toggle отложен на E.13 для безопасности. clickSpreadsheetCell + findSpreadsheetCellByText временно exported из browser.mjs (нужны core/click.mjs). На C.11 они переедут в table/spreadsheet.mjs, экспорт из browser.mjs можно будет убрать. browser.mjs: 2768 → 2470 LOC. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 310 +---------------- .../skills/web-test/scripts/core/click.mjs | 321 ++++++++++++++++++ 2 files changed, 327 insertions(+), 304 deletions(-) create mode 100644 .claude/skills/web-test/scripts/core/click.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index ef0d02ba..31ddb0a5 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -547,7 +547,7 @@ async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) { * target: { row: number|'totals'|{colName: value}, column: string } * Internal helper — called from clickElement when first arg is an object. */ -async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) { +export async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) { ensureConnected(); const formNum = await page.evaluate(detectFormScript()); const { allCells, frameMap } = await scanSpreadsheetCells(formNum); @@ -640,7 +640,7 @@ async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) { * Search spreadsheet iframes for a cell matching text (for text fallback in clickElement). * Returns { frameIndex, physRow, physCol, box } or null if not found. */ -async function findSpreadsheetCellByText(formNum, searchText) { +export async function findSpreadsheetCellByText(formNum, searchText) { const { allCells, frameMap } = await scanSpreadsheetCells(formNum); if (allCells.size === 0) return null; @@ -774,308 +774,10 @@ import { export { fillFields, fillField } from './forms/fill.mjs'; -/** 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. */ -export async function clickElement(text, { dblclick, table, toggle, expand, modifier, timeout } = {}) { - ensureConnected(); - // Dispatch to spreadsheet cell handler when first arg is { row, column } - if (typeof text === 'object' && text !== null && text.column != null) { - await dismissPendingErrors(); - return clickSpreadsheetCell(text, { dblclick, modifier }); - } - await dismissPendingErrors(); - if (highlightMode) try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {} - let netMonitor = null; - try { - - // First check if there's a confirmation dialog — click matching button - const pending = await checkForErrors(); - if (pending?.confirmation) { - const btnResult = await page.evaluate(`(() => { - const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || ''; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const target = ny(${JSON.stringify(text.toLowerCase())}); - const btns = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0); - let best = btns.find(el => ny(norm(el.innerText).toLowerCase()) === target); - if (!best) best = btns.find(el => ny(norm(el.innerText).toLowerCase()).includes(target)); - if (best) { - const r = best.getBoundingClientRect(); - return { name: norm(best.innerText), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }; - } - return { error: 'not_found', available: btns.map(el => norm(el.innerText)).filter(Boolean) }; - })()`); - if (btnResult?.error) throw new Error(`clickElement: "${text}" not found among confirmation buttons. Available: ${btnResult.available?.join(', ') || 'none'}`); - await page.mouse.click(btnResult.x, btnResult.y); - await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'confirmation', name: btnResult.name }; - return state; - } - - // Check if there's an open popup — if so, try to click inside it - const popupItems = await page.evaluate(readSubmenuScript()); - if (Array.isArray(popupItems) && popupItems.length > 0) { - const target = normYo(text.toLowerCase()); - let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target); - if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target)); - if (found) { - // submenuArrow items (group headers like "Создать", "Печать") — hover to expand nested submenu - if (found.kind === 'submenuArrow') { - // page.hover(selector) is more reliable than page.mouse.move(x,y) — - // some submenu groups don't expand with plain mouse.move - if (found.id) { - await page.hover(`[id="${found.id}"]`); - } else { - await page.mouse.move(found.x, found.y); - } - await page.waitForTimeout(ACTION_WAIT); - const nestedItems = await page.evaluate(readSubmenuScript()); - const state = await getFormState(); - state.clicked = { kind: 'submenuArrow', name: found.name }; - if (Array.isArray(nestedItems)) { - state.submenu = nestedItems.map(i => i.name); - state.hint = 'Call web_click again with a submenu item name to select it'; - } - return state; - } - // Regular submenu/dropdown items — trusted events required. - // Use mouse.click(x,y) when in viewport; use :visible selector for clipped items - // (same ID can exist hidden in parent cloud AND visible in nested cloud). - const vpHeight = await page.evaluate('window.innerHeight'); - if (found.x && found.y && found.y > 0 && found.y < vpHeight) { - await page.mouse.click(found.x, found.y); - } else if (found.id) { - await page.click(`[id="${found.id}"]:visible`); - } else if (found.x && found.y) { - await page.mouse.click(found.x, found.y); - } - await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'popupItem', name: found.name }; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; - } - // No match in popup — fall through to form elements - } - - let formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error(`clickElement: no form found`); - - // Pre-resolve grid when table is specified - let gridSelector; - if (table) { - const resolved = await page.evaluate(resolveGridScript(formNum, table)); - if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); - gridSelector = resolved.gridSelector; - } - - // Find the target element ID - let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector })); - - // Retry: if not found, a modal form may still be loading (e.g. after F4). - // Wait up to 2s for a new form to appear and re-detect. - if (target?.error) { - for (let retry = 0; retry < 4; retry++) { - await page.waitForTimeout(500); - const newForm = await page.evaluate(detectFormScript()); - if (newForm !== null && newForm !== formNum) { - formNum = newForm; - target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector })); - if (!target?.error) break; - } - } - } - // Fallback: search spreadsheet iframes for text match before giving up - if (target?.error) { - const ssCell = await findSpreadsheetCellByText(formNum, text); - if (ssCell) { - const cx = ssCell.box.x + ssCell.box.width / 2; - const cy = ssCell.box.y + ssCell.box.height / 2; - const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; - if (modKey) await page.keyboard.down(modKey); - if (dblclick) await page.mouse.dblclick(cx, cy); - else await page.mouse.click(cx, cy); - if (modKey) await page.keyboard.up(modKey); - await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) }; - return state; - } - throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`); - } - - // Helper: click with optional modifier key (Ctrl/Shift for multi-select) - const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; - async function modClick(x, y) { - if (modKey) await page.keyboard.down(modKey); - await page.mouse.click(x, y); - if (modKey) await page.keyboard.up(modKey); - } - async function modDblClick(x, y) { - if (modKey) await page.keyboard.down(modKey); - await page.mouse.dblclick(x, y); - if (modKey) await page.keyboard.up(modKey); - } - - // Grid row targets — use coordinate click (single or double) - if (target.kind === 'gridGroup' || target.kind === 'gridParent') { - if (expand != null || toggle) { - // Expand/collapse group in hierarchy mode — click the triangle icon (.gridListH/.gridListV). - // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click. - const levelIconInfo = await getGridToggleIcon(target, formNum, { - iconSelector: '.gridListH, .gridListV', - isExpandedExpr: "icon.classList.contains('gridListV')", - }); - const shouldClick = shouldClickToggle(levelIconInfo, expand, toggle); - if (shouldClick) { - if (levelIconInfo) { - await modClick(levelIconInfo.x, levelIconInfo.y); - } else { - // Fallback: dblclick (standard hierarchy navigation) - await modDblClick(target.x, target.y); - } - } - await waitForStable(formNum); - const state = await getFormState(); - state.clicked = { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }; - state.hint = shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.'; - return state; - } - // Default: dblclick to enter group / go up to parent - await modDblClick(target.x, target.y); - await waitForStable(formNum); - const state = await getFormState(); - state.clicked = { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) }; - return state; - } - if (target.kind === 'gridTreeNode') { - if (expand != null || toggle) { - // Expand/collapse tree node — click the tree icon [tree="true"]. - // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click. - const treeIconInfo = await getGridToggleIcon(target, formNum, { - iconSelector: '.gridBoxImg [tree="true"]', - isExpandedExpr: '(icon.style.backgroundImage || "").includes("gx=0")', - }); - const shouldClick = shouldClickToggle(treeIconInfo, expand, toggle); - if (shouldClick) { - if (treeIconInfo) { - await modClick(treeIconInfo.x, treeIconInfo.y); - } else { - // Fallback: dblclick on row (works for trees without clickable +/- icons) - await modDblClick(target.x, target.y); - } - } - await waitForStable(formNum); - const state = await getFormState(); - state.clicked = { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }; - state.hint = shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.'; - return state; - } - // Default: select row (click text, no expand/collapse) - await modClick(target.x, target.y); - await waitForStable(formNum); - const state = await getFormState(); - state.clicked = { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) }; - state.hint = 'Row selected. Use { expand: true } to expand/collapse.'; - return state; - } - if (target.kind === 'gridRow') { - if (dblclick) { - await modDblClick(target.x, target.y); - await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'gridRow', name: target.name, dblclick: true, ...(modifier ? { modifier } : {}) }; - return state; - } - await modClick(target.x, target.y); - await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'gridRow', name: target.name, ...(modifier ? { modifier } : {}) }; - return state; - } - - // Start CDP network monitor BEFORE the click for buttons — - // so we capture all server requests triggered by the click. - if (target.kind === 'button') { - try { netMonitor = await startNetworkMonitor(); } catch {} - } - - // Tabs without ID — use coordinate click to avoid global [data-content] ambiguity - if (target.kind === 'tab' && !target.id && target.x && target.y) { - await page.mouse.click(target.x, target.y); - } else { - const selector = `[id="${target.id}"]`; - // Use Playwright click for proper mousedown/mouseup events - await safeClick(selector, { timeout: 5000 }); - } - - // If submenu button — read popup items and return them as hints - if (target.kind === 'submenu') { - await page.waitForTimeout(ACTION_WAIT); - const submenuItems = await page.evaluate(readSubmenuScript()); - const state = await getFormState(); - state.clicked = { kind: 'submenu', name: target.name }; - if (Array.isArray(submenuItems)) { - state.submenu = submenuItems.map(i => i.name); - state.hint = 'Call web_click again with a submenu item name to select it'; - } - return state; - } - - await waitForStable(formNum); - - // Check if the click opened a popup/submenu (split buttons like "Создать на основании") - const openedPopup = await page.evaluate(readSubmenuScript()); - if (Array.isArray(openedPopup) && openedPopup.length > 0) { - const state = await getFormState(); - state.clicked = { kind: 'submenu', name: target.name }; - state.submenu = openedPopup.map(i => i.name); - state.hint = 'Call web_click again with a submenu item name to select it'; - return state; - } - - // For buttons that trigger server-side operations (post, write, etc.), - // the DOM may stabilize BEFORE the server response arrives. - // Use waitForSelector to detect error modal — this doesn't block the JS event loop. - // Skip for grid edit mode (e.g. "Добавить" row) — no server round-trip expected. - if (target.kind === 'button') { - const postForm = await page.evaluate(detectFormScript()); - if (postForm === formNum) { - const inGridEdit = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; - let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } - return false; - })()`); - if (!inGridEdit && netMonitor) { - // Form didn't change — server might still be processing. - // CDP monitor was started before click — wait for all requests to complete - // (300ms debounce) or for a modal/balloon/confirm to appear. - await netMonitor.waitDone(timeout); - await waitForStable(); - } - } - } - - // Form may have changed — re-detect - const state = await getFormState(); - state.clicked = { kind: target.kind, name: target.name }; - const err = await checkForErrors(); - if (err) { - state.errors = err; - if (err.confirmation) { - state.confirmation = err.confirmation; - state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond'; - } - } - return state; - - } finally { - if (netMonitor) try { await netMonitor.cleanup(); } catch {} - if (highlightMode) try { await unhighlight(); } catch {} - } -} +// ============================================================ +// clickElement dispatcher — extracted to core/click.mjs +// ============================================================ +export { clickElement } from './core/click.mjs'; // ============================================================ // Close form — extracted to forms/close.mjs diff --git a/.claude/skills/web-test/scripts/core/click.mjs b/.claude/skills/web-test/scripts/core/click.mjs new file mode 100644 index 00000000..c1567c76 --- /dev/null +++ b/.claude/skills/web-test/scripts/core/click.mjs @@ -0,0 +1,321 @@ +// web-test core/click v1.16 — clickElement dispatcher: spreadsheet cells, submenus, grid groups/trees, buttons/links, tabs. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { + page, ensureConnected, ACTION_WAIT, highlightMode, normYo, +} from './state.mjs'; +import { + detectFormScript, findClickTargetScript, resolveGridScript, readSubmenuScript, +} from '../dom.mjs'; +import { dismissPendingErrors, checkForErrors, fetchErrorStack } from './errors.mjs'; +import { waitForStable, startNetworkMonitor } from './wait.mjs'; +import { highlight, unhighlight } from '../recording/highlight.mjs'; +import { safeClick } from './helpers.mjs'; +import { getGridToggleIcon, shouldClickToggle } from '../table/grid-toggle.mjs'; +// Spreadsheet cell handlers and getFormState live in browser.mjs. +import { + clickSpreadsheetCell, findSpreadsheetCellByText, getFormState, +} from '../browser.mjs'; + +/** 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. */ +export async function clickElement(text, { dblclick, table, toggle, expand, modifier, timeout } = {}) { + ensureConnected(); + // Dispatch to spreadsheet cell handler when first arg is { row, column } + if (typeof text === 'object' && text !== null && text.column != null) { + await dismissPendingErrors(); + return clickSpreadsheetCell(text, { dblclick, modifier }); + } + await dismissPendingErrors(); + if (highlightMode) try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {} + let netMonitor = null; + try { + + // First check if there's a confirmation dialog — click matching button + const pending = await checkForErrors(); + if (pending?.confirmation) { + const btnResult = await page.evaluate(`(() => { + const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || ''; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const target = ny(${JSON.stringify(text.toLowerCase())}); + const btns = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0); + let best = btns.find(el => ny(norm(el.innerText).toLowerCase()) === target); + if (!best) best = btns.find(el => ny(norm(el.innerText).toLowerCase()).includes(target)); + if (best) { + const r = best.getBoundingClientRect(); + return { name: norm(best.innerText), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }; + } + return { error: 'not_found', available: btns.map(el => norm(el.innerText)).filter(Boolean) }; + })()`); + if (btnResult?.error) throw new Error(`clickElement: "${text}" not found among confirmation buttons. Available: ${btnResult.available?.join(', ') || 'none'}`); + await page.mouse.click(btnResult.x, btnResult.y); + await waitForStable(); + const state = await getFormState(); + state.clicked = { kind: 'confirmation', name: btnResult.name }; + return state; + } + + // Check if there's an open popup — if so, try to click inside it + const popupItems = await page.evaluate(readSubmenuScript()); + if (Array.isArray(popupItems) && popupItems.length > 0) { + const target = normYo(text.toLowerCase()); + let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target); + if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target)); + if (found) { + // submenuArrow items (group headers like "Создать", "Печать") — hover to expand nested submenu + if (found.kind === 'submenuArrow') { + // page.hover(selector) is more reliable than page.mouse.move(x,y) — + // some submenu groups don't expand with plain mouse.move + if (found.id) { + await page.hover(`[id="${found.id}"]`); + } else { + await page.mouse.move(found.x, found.y); + } + await page.waitForTimeout(ACTION_WAIT); + const nestedItems = await page.evaluate(readSubmenuScript()); + const state = await getFormState(); + state.clicked = { kind: 'submenuArrow', name: found.name }; + if (Array.isArray(nestedItems)) { + state.submenu = nestedItems.map(i => i.name); + state.hint = 'Call web_click again with a submenu item name to select it'; + } + return state; + } + // Regular submenu/dropdown items — trusted events required. + // Use mouse.click(x,y) when in viewport; use :visible selector for clipped items + // (same ID can exist hidden in parent cloud AND visible in nested cloud). + const vpHeight = await page.evaluate('window.innerHeight'); + if (found.x && found.y && found.y > 0 && found.y < vpHeight) { + await page.mouse.click(found.x, found.y); + } else if (found.id) { + await page.click(`[id="${found.id}"]:visible`); + } else if (found.x && found.y) { + await page.mouse.click(found.x, found.y); + } + await waitForStable(); + const state = await getFormState(); + state.clicked = { kind: 'popupItem', name: found.name }; + const err = await checkForErrors(); + if (err) state.errors = err; + return state; + } + // No match in popup — fall through to form elements + } + + let formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error(`clickElement: no form found`); + + // Pre-resolve grid when table is specified + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + + // Find the target element ID + let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector })); + + // Retry: if not found, a modal form may still be loading (e.g. after F4). + // Wait up to 2s for a new form to appear and re-detect. + if (target?.error) { + for (let retry = 0; retry < 4; retry++) { + await page.waitForTimeout(500); + const newForm = await page.evaluate(detectFormScript()); + if (newForm !== null && newForm !== formNum) { + formNum = newForm; + target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector })); + if (!target?.error) break; + } + } + } + // Fallback: search spreadsheet iframes for text match before giving up + if (target?.error) { + const ssCell = await findSpreadsheetCellByText(formNum, text); + if (ssCell) { + const cx = ssCell.box.x + ssCell.box.width / 2; + const cy = ssCell.box.y + ssCell.box.height / 2; + const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; + if (modKey) await page.keyboard.down(modKey); + if (dblclick) await page.mouse.dblclick(cx, cy); + else await page.mouse.click(cx, cy); + if (modKey) await page.keyboard.up(modKey); + await waitForStable(); + const state = await getFormState(); + state.clicked = { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) }; + return state; + } + throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`); + } + + // Helper: click with optional modifier key (Ctrl/Shift for multi-select) + const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; + async function modClick(x, y) { + if (modKey) await page.keyboard.down(modKey); + await page.mouse.click(x, y); + if (modKey) await page.keyboard.up(modKey); + } + async function modDblClick(x, y) { + if (modKey) await page.keyboard.down(modKey); + await page.mouse.dblclick(x, y); + if (modKey) await page.keyboard.up(modKey); + } + + // Grid row targets — use coordinate click (single or double) + if (target.kind === 'gridGroup' || target.kind === 'gridParent') { + if (expand != null || toggle) { + // Expand/collapse group in hierarchy mode — click the triangle icon (.gridListH/.gridListV). + // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click. + const levelIconInfo = await getGridToggleIcon(target, formNum, { + iconSelector: '.gridListH, .gridListV', + isExpandedExpr: "icon.classList.contains('gridListV')", + }); + const shouldClick = shouldClickToggle(levelIconInfo, expand, toggle); + if (shouldClick) { + if (levelIconInfo) { + await modClick(levelIconInfo.x, levelIconInfo.y); + } else { + // Fallback: dblclick (standard hierarchy navigation) + await modDblClick(target.x, target.y); + } + } + await waitForStable(formNum); + const state = await getFormState(); + state.clicked = { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }; + state.hint = shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.'; + return state; + } + // Default: dblclick to enter group / go up to parent + await modDblClick(target.x, target.y); + await waitForStable(formNum); + const state = await getFormState(); + state.clicked = { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) }; + return state; + } + if (target.kind === 'gridTreeNode') { + if (expand != null || toggle) { + // Expand/collapse tree node — click the tree icon [tree="true"]. + // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click. + const treeIconInfo = await getGridToggleIcon(target, formNum, { + iconSelector: '.gridBoxImg [tree="true"]', + isExpandedExpr: '(icon.style.backgroundImage || "").includes("gx=0")', + }); + const shouldClick = shouldClickToggle(treeIconInfo, expand, toggle); + if (shouldClick) { + if (treeIconInfo) { + await modClick(treeIconInfo.x, treeIconInfo.y); + } else { + // Fallback: dblclick on row (works for trees without clickable +/- icons) + await modDblClick(target.x, target.y); + } + } + await waitForStable(formNum); + const state = await getFormState(); + state.clicked = { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }; + state.hint = shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.'; + return state; + } + // Default: select row (click text, no expand/collapse) + await modClick(target.x, target.y); + await waitForStable(formNum); + const state = await getFormState(); + state.clicked = { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) }; + state.hint = 'Row selected. Use { expand: true } to expand/collapse.'; + return state; + } + if (target.kind === 'gridRow') { + if (dblclick) { + await modDblClick(target.x, target.y); + await waitForStable(); + const state = await getFormState(); + state.clicked = { kind: 'gridRow', name: target.name, dblclick: true, ...(modifier ? { modifier } : {}) }; + return state; + } + await modClick(target.x, target.y); + await waitForStable(); + const state = await getFormState(); + state.clicked = { kind: 'gridRow', name: target.name, ...(modifier ? { modifier } : {}) }; + return state; + } + + // Start CDP network monitor BEFORE the click for buttons — + // so we capture all server requests triggered by the click. + if (target.kind === 'button') { + try { netMonitor = await startNetworkMonitor(); } catch {} + } + + // Tabs without ID — use coordinate click to avoid global [data-content] ambiguity + if (target.kind === 'tab' && !target.id && target.x && target.y) { + await page.mouse.click(target.x, target.y); + } else { + const selector = `[id="${target.id}"]`; + // Use Playwright click for proper mousedown/mouseup events + await safeClick(selector, { timeout: 5000 }); + } + + // If submenu button — read popup items and return them as hints + if (target.kind === 'submenu') { + await page.waitForTimeout(ACTION_WAIT); + const submenuItems = await page.evaluate(readSubmenuScript()); + const state = await getFormState(); + state.clicked = { kind: 'submenu', name: target.name }; + if (Array.isArray(submenuItems)) { + state.submenu = submenuItems.map(i => i.name); + state.hint = 'Call web_click again with a submenu item name to select it'; + } + return state; + } + + await waitForStable(formNum); + + // Check if the click opened a popup/submenu (split buttons like "Создать на основании") + const openedPopup = await page.evaluate(readSubmenuScript()); + if (Array.isArray(openedPopup) && openedPopup.length > 0) { + const state = await getFormState(); + state.clicked = { kind: 'submenu', name: target.name }; + state.submenu = openedPopup.map(i => i.name); + state.hint = 'Call web_click again with a submenu item name to select it'; + return state; + } + + // For buttons that trigger server-side operations (post, write, etc.), + // the DOM may stabilize BEFORE the server response arrives. + // Use waitForSelector to detect error modal — this doesn't block the JS event loop. + // Skip for grid edit mode (e.g. "Добавить" row) — no server round-trip expected. + if (target.kind === 'button') { + const postForm = await page.evaluate(detectFormScript()); + if (postForm === formNum) { + const inGridEdit = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; + let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } + return false; + })()`); + if (!inGridEdit && netMonitor) { + // Form didn't change — server might still be processing. + // CDP monitor was started before click — wait for all requests to complete + // (300ms debounce) or for a modal/balloon/confirm to appear. + await netMonitor.waitDone(timeout); + await waitForStable(); + } + } + } + + // Form may have changed — re-detect + const state = await getFormState(); + state.clicked = { kind: target.kind, name: target.name }; + const err = await checkForErrors(); + if (err) { + state.errors = err; + if (err.confirmation) { + state.confirmation = err.confirmation; + state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond'; + } + } + return state; + + } finally { + if (netMonitor) try { await netMonitor.cleanup(); } catch {} + if (highlightMode) try { await unhighlight(); } catch {} + } +} From 0ba8127d527ddc9486099572ee796e3778690d18 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 13:14:49 +0300 Subject: [PATCH 17/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20C.11=20=E2=80=94=20table/spreadsheet.mjs=20+=20table/f?= =?UTF-8?q?ilter.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit table/spreadsheet.mjs (~580 LOC): - readTable, readSpreadsheet - scanSpreadsheetCells, buildSpreadsheetMapping (private) - scrollSpreadsheetToCell, clickSpreadsheetCell, findSpreadsheetCellByText table/filter.mjs (~390 LOC): filterList, unfilterList После C.11 + C.10 clean-up: - core/click.mjs импортит clickSpreadsheetCell/findSpreadsheetCellByText напрямую из table/spreadsheet.mjs (а не из browser.mjs) - browser.mjs больше не реэкспортирует эти два — публичный API остаётся 56 экспортов как до рефакторинга - Добавил import { clickElement } в browser.mjs для внутренних вызовов из fillTableRow/deleteTableRow browser.mjs: 2470 → 1532 LOC (≈75% от исходных 6293). 05-table + 09-filter регресс зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 958 +----------------- .../skills/web-test/scripts/core/click.mjs | 7 +- .../skills/web-test/scripts/table/filter.mjs | 389 +++++++ .../web-test/scripts/table/spreadsheet.mjs | 582 +++++++++++ 4 files changed, 984 insertions(+), 952 deletions(-) create mode 100644 .claude/skills/web-test/scripts/table/filter.mjs create mode 100644 .claude/skills/web-test/scripts/table/spreadsheet.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 31ddb0a5..9f32ebb9 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -181,581 +181,11 @@ export async function getFormState() { return state; } -/** Read structured table data with pagination. Returns columns, rows, total count. */ -export async function readTable({ maxRows = 20, offset = 0, table } = {}) { - ensureConnected(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('readTable: no form found'); - let gridSelector; - if (table) { - const resolved = await page.evaluate(resolveGridScript(formNum, table)); - if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); - gridSelector = resolved.gridSelector; - } - return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector })); -} +// ============================================================ +// Table reading + SpreadsheetDocument — extracted to table/spreadsheet.mjs +// ============================================================ +export { readTable, readSpreadsheet } from './table/spreadsheet.mjs'; -// --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) --- - -/** - * Scan spreadsheet iframes for the current form and collect all cells. - * Returns { allCells: Map<'r_c', {r,c,t}>, frameMap: Map<'r_c', frameIndex> } - * where frameIndex is the Playwright frames[] index (1-based, 0 = main). - */ -async function scanSpreadsheetCells(formNum) { - const prefix = `form${formNum ?? 0}_`; - const iframeHandles = await page.$$('iframe'); - - const allCells = new Map(); - const frameMap = new Map(); // key 'r_c' → Playwright Frame object - - for (const handle of iframeHandles) { - const ok = await handle.evaluate((f, pfx) => { - if (f.offsetWidth < 100) return false; - let el = f.parentElement; - for (let d = 0; el && d < 30; d++, el = el.parentElement) { - if (el.id && el.id.startsWith(pfx)) return true; - } - return false; - }, prefix); - if (!ok) continue; - - const frame = await handle.contentFrame(); - if (!frame) continue; - - try { - const cells = await frame.evaluate(`(() => { - const cells = []; - document.querySelectorAll('div[x]').forEach(d => { - const span = d.querySelector('span'); - const text = span?.innerText?.replace(/\\n/g, ' ')?.trim() || ''; - if (!text) return; - const rowDiv = d.parentElement; - const row = rowDiv?.getAttribute('y') || rowDiv?.className?.match(/R(\\d+)/)?.[1] || null; - const col = d.getAttribute('x'); - if (row != null && col != null) cells.push({ r: parseInt(row), c: parseInt(col), t: text }); - }); - return cells; - })()`); - for (const cell of cells) { - const key = `${cell.r}_${cell.c}`; - if (!allCells.has(key) || cell.t.length > allCells.get(key).t.length) { - allCells.set(key, cell); - frameMap.set(key, frame); - } - } - } catch { /* skip inaccessible frames */ } - } - return { allCells, frameMap }; -} - -/** - * Build structured mapping from raw cells: headers, column map, data/totals row indices. - * Returns { rows, sortedRows, maxCol, colNames, headerRowIdx, dataStartIdx, totalsRowIdx, rowMap } - * or null if header detection fails. - */ -function buildSpreadsheetMapping(allCells) { - const rowMap = new Map(); - let maxCol = 0; - for (const cell of allCells.values()) { - if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map()); - rowMap.get(cell.r).set(cell.c, cell.t); - if (cell.c > maxCol) maxCol = cell.c; - } - - const sortedRows = [...rowMap.keys()].sort((a, b) => a - b); - const rows = sortedRows.map(r => { - const cm = rowMap.get(r); - const arr = []; - for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || ''); - return arr; - }); - - // Generic numeric check: digits with optional spaces/commas, excludes codes like "68/78" - // Accepts bare integers (e.g. account codes "50", "84") — used for hasNumber / totals classification. - const isNumericVal = (c) => { - if (!c || !/\d/.test(c)) return false; - const s = c.replace(/^[-\s\u00a0]+/, '').replace(/[\s\u00a0]/g, ''); - return /^\d[\d,]*$/.test(s); - }; - // Data-formatted numeric value: requires a formatting signal (grouping space, decimal comma, or leading minus). - // Used as the anchor for first data row — avoids false positives on bare account codes like "50", "51". - const isDataNumericVal = (c) => { - if (!isNumericVal(c)) return false; - return /[\s\u00a0,]/.test(c) || /^-/.test(c); - }; - const hasNumber = (row) => row.some(c => isNumericVal(c)); - const nonEmpty = (row) => row.filter(c => c !== '').length; - - // Build a rich mapping (group/super/DCS) anchored at a known detailIdx + firstDataIdx. - // Shared by Level 1 (DCS-code anchor) and Level 2 (formatted-number anchor). - const buildRichMapping = (detailIdx, firstDataIdx) => { - let groupIdx = -1; - if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1; - - const detailRow = rows[detailIdx]; - const groupRow = groupIdx >= 0 ? rows[groupIdx] : null; - - // Detect optional third header level above group row (bounds carry-forward) - let superRow = null; - if (groupIdx > 0 && nonEmpty(rows[groupIdx - 1]) >= 2) { - superRow = rows[groupIdx - 1]; - } - - // Build column names (group + detail merge) - const groupFilled = new Array(maxCol + 1).fill(''); - if (groupRow) { - let cur = ''; - for (let c = 0; c <= maxCol; c++) { - if (groupRow[c]) { - cur = groupRow[c]; - } else if (superRow && superRow[c]) { - // New top-level header starts here — stop carry-forward - cur = ''; - } - groupFilled[c] = cur; - } - } - - const detailCounts = {}; - for (let c = 0; c <= maxCol; c++) { - const n = detailRow[c]; - if (n) detailCounts[n] = (detailCounts[n] || 0) + 1; - } - - // Detect DCS column codes (К1, К2, ...) — always prefix with group when present - const detailNonEmpty = detailRow.filter(c => c); - const isDcsCodeRow = detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c)); - - const colNames = []; - for (let c = 0; c <= maxCol; c++) { - const detail = detailRow[c]; - const group = groupFilled[c]; - const sup = superRow ? superRow[c] : ''; - if (detail) { - // Prefer group prefix; fall back to superRow for DCS code columns without sub-group - const prefix = group && group !== detail ? group : (isDcsCodeRow && sup ? sup : ''); - const needPrefix = prefix && (isDcsCodeRow || detailCounts[detail] > 1 || (groupRow && groupRow[c] === '')); - colNames.push(needPrefix ? `${prefix} / ${detail}` : detail); - } else if (group) { - colNames.push(group); - } else if (sup) { - colNames.push(sup); - } else { - colNames.push(null); - } - } - - const colMap = new Map(); - for (let c = 0; c < colNames.length; c++) { - if (colNames[c]) colMap.set(colNames[c], c); - } - - // Classify data rows: separate data indices and totals index - const dataRowIndices = []; - let totalsRowIdx = -1; - for (let i = firstDataIdx; i < rows.length; i++) { - if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; - const first = rows[i][0]?.trim().toLowerCase(); - if (first === 'итого' || first === 'всего') { - totalsRowIdx = i; - } else { - dataRowIndices.push(i); - } - } - - const superRowIdx = superRow ? groupIdx - 1 : -1; - - return { - rows, sortedRows, maxCol, colNames, colMap, - headerRowIdx: detailIdx, groupRowIdx: groupIdx, superRowIdx, - dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx, - rowMap, hasNumber, nonEmpty, - }; - }; - - // --- Level 1: DCS-code row anchor --- - // ФСД / СКД-отчёты всегда содержат строку "К1, К2, ..." — rock-solid structural marker. - // Якорение через неё — детерминированное, работает даже если все данные — голые целые (отчёт в "тыс.руб"). - for (let i = 0; i < rows.length; i++) { - const detailNonEmpty = rows[i].filter(c => c); - if (detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c))) { - // Find first non-empty row after the К-codes row as data start - let firstDataIdx = rows.length; - for (let j = i + 1; j < rows.length; j++) { - if (nonEmpty(rows[j]) > 0) { firstDataIdx = j; break; } - } - return buildRichMapping(i, firstDataIdx); - } - } - - // --- Level 2: formatted-number anchor (heuristic for reports without DCS codes) --- - let firstDataIdx = rows.length; - for (let i = 0; i < rows.length; i++) { - if (rows[i].filter(c => isDataNumericVal(c)).length >= 2) { firstDataIdx = i; break; } - } - if (firstDataIdx === rows.length) { - for (let i = 0; i < rows.length; i++) { - if (rows[i].some(c => isDataNumericVal(c))) { firstDataIdx = i; break; } - } - } - - if (firstDataIdx < rows.length) { - let detailIdx = -1; - for (let i = firstDataIdx - 1; i >= 0; i--) { - if (nonEmpty(rows[i]) >= Math.min(3, maxCol + 1)) { detailIdx = i; break; } - } - if (detailIdx !== -1) return buildRichMapping(detailIdx, firstDataIdx); - } - - // --- Level 3: single-row header fallback (text-only data, query console) --- - // First "wide" row (nonEmpty >= 2) = headers, rest = data. No multi-level composition. - let headerIdx = -1; - for (let i = 0; i < rows.length; i++) { - if (nonEmpty(rows[i]) >= 2) { headerIdx = i; break; } - } - // Single-column tables: accept nonEmpty >= 1 - if (headerIdx === -1 && maxCol === 0) { - for (let i = 0; i < rows.length; i++) { - if (nonEmpty(rows[i]) >= 1) { headerIdx = i; break; } - } - } - if (headerIdx === -1) return null; // truly empty — top-level fallback to { rows, total } - - const detailRow = rows[headerIdx]; - const colNames = []; - for (let c = 0; c <= maxCol; c++) colNames.push(detailRow[c] || null); - const colMap = new Map(); - for (let c = 0; c < colNames.length; c++) { - if (colNames[c]) colMap.set(colNames[c], c); - } - - const dataRowIndices = []; - let totalsRowIdx = -1; - for (let i = headerIdx + 1; i < rows.length; i++) { - if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; - const first = rows[i][0]?.trim().toLowerCase(); - if (first === 'итого' || first === 'всего') { - totalsRowIdx = i; - } else { - dataRowIndices.push(i); - } - } - - return { - rows, sortedRows, maxCol, colNames, colMap, - headerRowIdx: headerIdx, groupRowIdx: -1, superRowIdx: -1, - dataStartIdx: headerIdx + 1, dataRowIndices, totalsRowIdx, - rowMap, hasNumber, nonEmpty, - }; -} - -/** - * Scroll SpreadsheetDocument to make a cell visible using arrow keys. - * Uses native platform scroll — keeps headers, data, and scrollbar synchronized. - * - * How it works: - * 1. Check target cell visibility via Playwright boundingBox (page-level coords). - * 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay. - * This is the same native click that clickSpreadsheetCell uses — it gives keyboard - * focus to the spreadsheet and keeps headers/data/scrollbar in sync. - * (frame.locator().click() bypasses overlay → desyncs frozen headers; - * page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.) - * 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport. - * - * @param {Frame} frame - Playwright Frame containing the spreadsheet cells - * @param {number} physRow - physical row (y attribute) in the frame - * @param {number} physCol - physical column (x attribute) in the frame - * @param {Locator} cellLoc - Playwright locator for the target cell (from caller) - */ -async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) { - const pageVw = await page.evaluate('window.innerWidth'); - // Get iframe bounds — the actual visible region on page. - // The iframe may extend behind the section panel on the left, so cells with - // x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel. - const frameElm = await frame.frameElement(); - const frameBox = await frameElm.boundingBox(); - const visLeft = frameBox ? frameBox.x : 0; - const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw; - - const getBox = async () => { - try { return await cellLoc.boundingBox({ timeout: 500 }); } - catch { return null; } - }; - const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight; - - let box = await getBox(); - if (!box) return; // cell not in DOM - if (isFullyVisible(box)) return; - - const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft'; - - // Find a fully-visible cell to click for focus. - // Prefer cells in the target row (scrollable area), fall back to any row. - const targetRowSel = `div[y="${physRow}"] div[x]`; - const anyRowSel = 'div[x]'; - let focusClicked = false; - for (const sel of [targetRowSel, anyRowSel]) { - const locs = frame.locator(sel); - const count = await locs.count(); - const candidates = []; - for (let ci = 0; ci < count; ci++) { - const b = await locs.nth(ci).boundingBox(); - if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) { - candidates.push({ ci, box: b }); - } - } - if (candidates.length === 0) continue; - candidates.sort((a, b) => a.box.x - b.box.x); - // ArrowRight → rightmost fully-visible (each press scrolls right immediately) - // ArrowLeft → leftmost fully-visible (each press scrolls left immediately) - const pick = direction === 'ArrowRight' - ? candidates[candidates.length - 1] - : candidates[0]; - // Native click through overlay — gives keyboard focus + no header desync. - await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2); - await page.waitForTimeout(100); - focusClicked = true; - break; - } - if (!focusClicked) return; // no visible cells — can't scroll - - // Arrow keys until cell is fully visible or we detect no progress. - const MAX_STALE = 5; // bail out if arrows aren't scrolling (lost focus?) - let prevCx = box.x + box.width / 2; - let staleCount = 0; - for (let i = 0; i < 100; i++) { - await page.keyboard.press(direction); - await page.waitForTimeout(50); - box = await getBox(); - if (!box) break; - if (isFullyVisible(box)) break; - const cx = box.x + box.width / 2; - if (Math.abs(cx - prevCx) >= 1) { - staleCount = 0; - } else { - staleCount++; - if (staleCount >= MAX_STALE) break; - } - prevCx = cx; - } - await page.waitForTimeout(200); -} - -/** - * Click a cell in SpreadsheetDocument by logical coordinates. - * target: { row: number|'totals'|{colName: value}, column: string } - * Internal helper — called from clickElement when first arg is an object. - */ -export async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) { - ensureConnected(); - const formNum = await page.evaluate(detectFormScript()); - const { allCells, frameMap } = await scanSpreadsheetCells(formNum); - if (allCells.size === 0) throw new Error('clickElement: no SpreadsheetDocument found on current form.'); - - const mapping = buildSpreadsheetMapping(allCells); - if (!mapping) throw new Error('clickElement: could not detect spreadsheet headers. Use readSpreadsheet() to check report structure.'); - - const { rows, sortedRows, colNames, colMap, dataRowIndices, totalsRowIdx } = mapping; - - // Resolve column (exact → endsWith " / X" → includes) - let colName = target.column; - if (!colMap.has(colName)) { - const available = colNames.filter(n => n); - const suffix = ' / ' + colName; - const match = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(colName)); - if (!match) throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`); - colName = match; - } - const physCol = colMap.get(colName); - - // Resolve row → index into rows[] array - let rowIdx; - const row = target.row; - if (row === 'totals') { - if (totalsRowIdx === -1) throw new Error('clickElement: no totals row found in spreadsheet.'); - rowIdx = totalsRowIdx; - } else if (typeof row === 'number') { - if (row < 0 || row >= dataRowIndices.length) throw new Error(`clickElement: row index ${row} out of range (0..${dataRowIndices.length - 1}).`); - rowIdx = dataRowIndices[row]; - } else if (typeof row === 'object') { - // Filter: { colName: value } — find first data row where column matches - const filterEntries = Object.entries(row); - const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || ''; - const resolveCol = (name) => { - if (colMap.has(name)) return colMap.get(name); - const suffix = ' / ' + name; - const available = colNames.filter(n => n); - const m = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(name)); - return m ? colMap.get(m) : null; - }; - rowIdx = dataRowIndices.find(i => { - return filterEntries.every(([fCol, fVal]) => { - const fColIdx = resolveCol(fCol); - if (fColIdx == null) return false; - const cellText = norm(rows[i][fColIdx]); - const search = norm(fVal); - return cellText === search || cellText.includes(search); - }); - }); - if (rowIdx == null) throw new Error(`clickElement: no row matching ${JSON.stringify(row)} found in spreadsheet data.`); - } else { - throw new Error('clickElement: row must be a number, "totals", or { colName: value } filter object.'); - } - - // Map rows[] index → physical row number - const physRow = sortedRows[rowIdx]; - const cellKey = `${physRow}_${physCol}`; - const frame = frameMap.get(cellKey); - if (!frame) { - // Cell exists in mapping but might be empty — try clicking anyway - throw new Error(`clickElement: cell at row=${JSON.stringify(target.row)}, column="${colName}" is empty or not rendered.`); - } - // Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs. - const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first(); - // Scroll cell into view using arrow keys — the only reliable way to scroll - // 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar. - await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv); - const box = await cellDiv.boundingBox(); - if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`); - - const x = box.x + box.width / 2; - const y = box.y + box.height / 2; - const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; - if (modKey) await page.keyboard.down(modKey); - if (dbl) { - await page.mouse.dblclick(x, y); - } else { - await page.mouse.click(x, y); - } - if (modKey) await page.keyboard.up(modKey); - - await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) }; - return state; -} - -/** - * Search spreadsheet iframes for a cell matching text (for text fallback in clickElement). - * Returns { frameIndex, physRow, physCol, box } or null if not found. - */ -export async function findSpreadsheetCellByText(formNum, searchText) { - const { allCells, frameMap } = await scanSpreadsheetCells(formNum); - if (allCells.size === 0) return null; - - const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || ''; - const target = norm(searchText); - - // Exact match first, then includes - let found = null; - for (const [key, cell] of allCells) { - if (norm(cell.t) === target) { found = { key, cell }; break; } - } - if (!found) { - for (const [key, cell] of allCells) { - if (norm(cell.t).includes(target)) { found = { key, cell }; break; } - } - } - if (!found) return null; - - const frame = frameMap.get(found.key); - if (!frame) return null; - - // Scroll cell into view using native arrow-key mechanism - const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first(); - await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv); - const box = await cellDiv.boundingBox(); - if (!box) return null; - - return { frame, physRow: found.cell.r, physCol: found.cell.c, text: found.cell.t, box }; -} - -/** - * Read report output (SpreadsheetDocumentField) rendered in iframes. - * 1C renders spreadsheet documents as absolutely-positioned div cells inside iframes. - * Each cell is a div[x] inside a row div[y], text content in . - * - * Returns structured data: - * { title, headers, data: [{col: val}], totals: {col: val}, total } - * If header detection fails, falls back to { rows: string[][], total }. - */ -export async function readSpreadsheet() { - ensureConnected(); - const formNum = await page.evaluate(detectFormScript()); - - const { allCells } = await scanSpreadsheetCells(formNum); - - if (allCells.size === 0) { - // Check for state window messages (info bar) that explain why the report is empty - const err = await checkForErrors(); - const hint = err?.stateText?.length ? err.stateText.join('; ') : ''; - throw new Error('readSpreadsheet: no SpreadsheetDocument found.' + (hint ? ' State: ' + hint : ' Report may not be generated yet.')); - } - - const mapping = buildSpreadsheetMapping(allCells); - if (!mapping) { - // Fallback: return raw rows - const rowMap = new Map(); - let maxCol = 0; - for (const cell of allCells.values()) { - if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map()); - rowMap.get(cell.r).set(cell.c, cell.t); - if (cell.c > maxCol) maxCol = cell.c; - } - const sortedRows = [...rowMap.keys()].sort((a, b) => a - b); - const rows = sortedRows.map(r => { - const cm = rowMap.get(r); - const arr = []; - for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || ''); - return arr; - }); - return { rows, total: rows.length }; - } - - const { rows, colNames, dataStartIdx, maxCol, groupRowIdx, headerRowIdx, superRowIdx, hasNumber, nonEmpty } = mapping; - - // Convert data rows to objects - const data = []; - let totals = null; - const toObj = (row) => { - const obj = {}; - for (let c = 0; c < colNames.length; c++) { - if (colNames[c] && row[c]) obj[colNames[c]] = row[c]; - } - return obj; - }; - - for (let i = dataStartIdx; i < rows.length; i++) { - if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; - const first = rows[i][0]?.trim().toLowerCase(); - if (first === 'итого' || first === 'всего') { - totals = toObj(rows[i]); - } else { - data.push(toObj(rows[i])); - } - } - - // Meta: title, params, filters from rows before header (superRow is part of header, not meta) - const metaEnd = superRowIdx >= 0 ? superRowIdx : (groupRowIdx >= 0 ? groupRowIdx : headerRowIdx); - let title = ''; - const meta = []; - for (let i = 0; i < metaEnd; i++) { - const parts = rows[i].filter(c => c); - if (!parts.length) continue; - if (!title) { title = parts.join(' '); continue; } - meta.push(parts.join(' ')); - } - - return { - title: title || undefined, - meta: meta.length ? meta : undefined, - headers: colNames.filter(n => n), - data, - totals: totals || undefined, - total: data.length, - }; -} // ============================================================ // Value selection (DLB/CB) — extracted to forms/select-value.mjs @@ -778,6 +208,7 @@ export { fillFields, fillField } from './forms/fill.mjs'; // clickElement dispatcher — extracted to core/click.mjs // ============================================================ export { clickElement } from './core/click.mjs'; +import { clickElement } from './core/click.mjs'; // ============================================================ // Close form — extracted to forms/close.mjs @@ -2074,382 +1505,11 @@ export async function deleteTableRow(row, { tab, table } = {}) { return { deleted: row, rowsBefore, rowsAfter, form: formData }; } -/** - * Filter the current list by field value, or search via search bar. - * - * Without field: simple search via the search bar (filters by all columns, no badge). - * With field: advanced search — clicks target column cell to auto-populate FieldSelector, - * opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge. - * Handles text, reference (with Tab autocomplete), and date fields automatically. - * Multiple filters can be chained by calling filterList multiple times. - * - * @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016") - * @param {object} [opts] - * @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата") - * @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact) - */ -export async function filterList(text, { field, exact } = {}) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('filterList: no form found'); +// ============================================================ +// List filters — extracted to table/filter.mjs +// ============================================================ +export { filterList, unfilterList } from './table/filter.mjs'; - if (!field) { - // --- Simple search: fill search input + Enter --- - const searchId = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); - return el ? el.id : null; - })()`); - - if (searchId) { - await page.click(`[id="${searchId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await pasteText(text); - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await waitForStable(formNum); - - const state = await getFormState(); - state.filtered = { type: 'search', text }; - return state; - } - - // No search input — Ctrl+F opens advanced search on such forms. - // Click first grid cell then fall through to advanced search path below. - const firstCell = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] - .find(g => g.offsetWidth > 0); - if (!grid) return null; - const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; - if (!rows.length) return null; - const cells = [...rows[0].querySelectorAll('.gridBox')]; - if (!cells.length) return null; - const r = cells[0].getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (!firstCell) throw new Error('filterList: no search input and no grid found on this form'); - await page.mouse.click(firstCell.x, firstCell.y); - await page.waitForTimeout(300); - field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected) - } - - // --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти --- - // Clicking a cell in the target column makes it active, so when Alt+F opens the - // advanced search dialog, FieldSelector is auto-populated with the correct field name. - // This avoids changing FieldSelector programmatically (which can cause errors). - const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim()); - - // 1. Click a cell in the target column to activate it (auto-populates FieldSelector). - // If the column isn't visible in the grid, click any cell and use DLB fallback later. - let needDlb = false; - const gridEl = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] - .find(g => g.offsetWidth > 0); - if (!grid) return { error: 'no_grid' }; - const targetField = ${JSON.stringify(field)}; - const headers = [...grid.querySelectorAll('.gridHead .gridBox')]; - let colIndex = -1; - let startsWithIdx = -1; - let includesIdx = -1; - for (let i = 0; i < headers.length; i++) { - const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' '); - if (!t) continue; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase()); - if (tl === fl) { colIndex = i; break; } - if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; } - else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; } - } - if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx; - const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; - if (!rows.length) return { error: 'no_rows' }; - if (colIndex < 0) { - // Column not in grid — click first cell of first row, will use DLB to change field - const cells = [...rows[0].querySelectorAll('.gridBox')]; - if (!cells.length) return { error: 'no_cells' }; - const r = cells[0].getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true }; - } - const cells = [...rows[0].querySelectorAll('.gridBox')]; - if (colIndex >= cells.length) return { error: 'cell_not_found' }; - const r = cells[colIndex].getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`); - needDlb = !!gridEl.needDlb; - await page.mouse.click(gridEl.x, gridEl.y); - await page.waitForTimeout(500); - - // 2. Open advanced search dialog via Alt+F (with fallback to Еще menu) - await page.keyboard.press('Alt+f'); - await page.waitForTimeout(2000); - - let dialogForm = await page.evaluate(detectFormScript()); - if (dialogForm === formNum) { - // Alt+F didn't open dialog — fallback to Еще → Расширенный поиск - await clickElement('Еще'); - await page.waitForTimeout(500); - const menu = await page.evaluate(readSubmenuScript()); - const searchItem = Array.isArray(menu) && menu.find(i => - i.name.replace(/\u00a0/g, ' ').toLowerCase().includes('расширенный поиск')); - if (!searchItem) { - await page.keyboard.press('Escape'); - throw new Error('filterList: advanced search dialog could not be opened'); - } - await page.mouse.click(searchItem.x, searchItem.y); - await page.waitForTimeout(2000); - dialogForm = await page.evaluate(detectFormScript()); - if (dialogForm === formNum) { - throw new Error('filterList: advanced search dialog did not open'); - } - } - - // 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown - // Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field) - if (needDlb && field) { - const fsInfo = await page.evaluate(`(() => { - const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; - const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); - const dlb = document.getElementById(p + 'FieldSelector_DLB'); - return { - current: fsInput?.value?.trim() || '', - dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0, - dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0 - }; - })()`); - - if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) { - await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY); - await page.waitForTimeout(1500); - - const ddResult = await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' }; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const target = ny(${JSON.stringify(field.toLowerCase())}); - const items = [...edd.querySelectorAll('div')].filter(el => - el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n')); - const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target) - || items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target)); - if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) }; - const r = match.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() }; - })()`); - - if (ddResult.error) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`); - } - await page.mouse.click(ddResult.x, ddResult.y); - await page.waitForTimeout(3000); - } - } - - // 3. Read dialog state and fill Pattern - // Detect field type by Pattern's sibling buttons: - // - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value) - // - iDLB on Pattern → reference field (paste + Tab for autocomplete) - // - neither → plain text field (just paste) - const dialogInfo = await page.evaluate(`(() => { - const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; - const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); - const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); - const ptLabel = ptInput?.closest('label'); - const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : []; - const isDate = btns.some(c => c.includes('iCalendB')); - const isRef = !isDate && btns.some(c => c.includes('iDLB')); - return { - fieldSelector: fsInput?.value?.trim() || '', - patternValue: ptInput?.value?.trim() || '', - patternId: ptInput?.id || '', - isDate, - isRef - }; - })()`); - - if (dialogInfo.isDate) { - // Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste) - if (isDateValue && dialogInfo.patternValue !== text.trim()) { - await page.click(`[id="${dialogInfo.patternId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Home'); - await page.waitForTimeout(100); - await page.keyboard.press('Shift+End'); - await page.waitForTimeout(100); - await pasteText(text); - await page.waitForTimeout(500); - } - } else { - // Text or reference field: fill Pattern via clipboard paste - await page.click(`[id="${dialogInfo.patternId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await pasteText(text); - await page.waitForTimeout(300); - - if (dialogInfo.isRef) { - // Reference field: Tab triggers autocomplete to resolve text → reference value - await page.keyboard.press('Tab'); - await page.waitForTimeout(2000); - } - } - - // 3b. Switch CompareType if exact match requested (text fields only). - // Date/number: always exact, CompareType disabled. Reference: default exact (selects ref). - if (exact && !dialogInfo.isDate && !dialogInfo.isRef) { - const exactRadio = await page.evaluate(`(() => { - const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; - // Check if CompareType group is disabled (dates, numbers) - const group = document.getElementById(p + 'CompareType'); - if (group && group.classList.contains('disabled')) return { already: true }; - const el = document.getElementById(p + 'CompareType#2#radio'); - if (!el || el.offsetWidth === 0) return null; - if (el.classList.contains('select')) return { already: true }; - const r = el.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (exactRadio && !exactRadio.already) { - await page.mouse.click(exactRadio.x, exactRadio.y); - await page.waitForTimeout(300); - } - } - - // 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked) - const findBtnCoords = await page.evaluate(`(() => { - const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); - const btn = btns.find(el => el.innerText?.trim() === 'Найти'); - if (!btn) return null; - const r = btn.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (findBtnCoords) { - await page.mouse.click(findBtnCoords.x, findBtnCoords.y); - } else { - await clickElement('Найти'); - } - await page.waitForTimeout(2000); - - // 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти). - // Check the specific dialog form — not generic modalSurface — to avoid closing parent modals - // (e.g. a selection form that opened this advanced search). - for (let attempt = 0; attempt < 3; attempt++) { - const dialogVisible = await page.evaluate(`(() => { - const p = 'form${dialogForm}_'; - return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); - })()`); - if (!dialogVisible) break; - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - } - await waitForStable(formNum); - - const state = await getFormState(); - state.filtered = { type: 'advanced', field, text, exact: !!exact }; - return state; -} - -/** - * Remove active filters/search from the current list. - * - * Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field). - * With field: clicks the × button on the specific filter badge (selective removal). - * - * @param {object} [opts] - * @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×) - */ -export async function unfilterList({ field } = {}) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('unfilterList: no form found'); - - if (field) { - // --- Selective: click × on specific filter badge --- - const closeBtn = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || ''; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const target = ny(${JSON.stringify(field.toLowerCase())}); - const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0); - for (const item of items) { - const titleEl = item.querySelector('.trainName'); - const title = ny(norm(titleEl?.innerText).toLowerCase()); - if (title === target || title.includes(target)) { - const close = item.querySelector('.trainClose'); - if (close) { - const r = close.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) }; - } - } - } - const available = items.map(item => norm(item.querySelector('.trainName')?.innerText)); - return { error: 'not_found', available }; - })()`); - - if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`); - await page.mouse.click(closeBtn.x, closeBtn.y); - await waitForStable(formNum); - - const state = await getFormState(); - state.unfiltered = { field: closeBtn.field }; - return state; - } - - // --- Clear ALL filters --- - - // 1. Remove all advanced filter badges (.trainItem × buttons) - for (let attempt = 0; attempt < 20; attempt++) { - const badge = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')] - .find(el => el.offsetWidth > 0); - if (!item) return null; - const close = item.querySelector('.trainClose'); - if (!close) return null; - const r = close.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (!badge) break; - await page.mouse.click(badge.x, badge.y); - await waitForStable(formNum); - } - - // 2. Cancel active search via Ctrl+Q - await page.keyboard.press('Control+q'); - await waitForStable(formNum); - - // 3. Clear simple search field if it has a value - const searchInfo = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); - return el ? { id: el.id, value: el.value || '' } : null; - })()`); - - if (searchInfo?.value) { - await page.click(`[id="${searchInfo.id}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await page.keyboard.press('Delete'); - await page.keyboard.press('Enter'); - await waitForStable(formNum); - } - - const state = await getFormState(); - state.unfiltered = true; - return state; -} // ============================================================ // Recording, captions, narration, highlight — extracted to recording/* diff --git a/.claude/skills/web-test/scripts/core/click.mjs b/.claude/skills/web-test/scripts/core/click.mjs index c1567c76..160a756c 100644 --- a/.claude/skills/web-test/scripts/core/click.mjs +++ b/.claude/skills/web-test/scripts/core/click.mjs @@ -12,10 +12,11 @@ import { waitForStable, startNetworkMonitor } from './wait.mjs'; import { highlight, unhighlight } from '../recording/highlight.mjs'; import { safeClick } from './helpers.mjs'; import { getGridToggleIcon, shouldClickToggle } from '../table/grid-toggle.mjs'; -// Spreadsheet cell handlers and getFormState live in browser.mjs. import { - clickSpreadsheetCell, findSpreadsheetCellByText, getFormState, -} from '../browser.mjs'; + clickSpreadsheetCell, findSpreadsheetCellByText, +} from '../table/spreadsheet.mjs'; +// getFormState still in browser.mjs. +import { getFormState } from '../browser.mjs'; /** 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. */ diff --git a/.claude/skills/web-test/scripts/table/filter.mjs b/.claude/skills/web-test/scripts/table/filter.mjs new file mode 100644 index 00000000..b1a398a0 --- /dev/null +++ b/.claude/skills/web-test/scripts/table/filter.mjs @@ -0,0 +1,389 @@ +// web-test table/filter v1.16 — filterList / unfilterList — simple search + advanced-column filter badges. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs'; +import { detectFormScript, resolveGridScript } 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 { safeClick } from '../core/helpers.mjs'; +import { selectValue, fillReferenceField } from '../forms/select-value.mjs'; +// pasteText + getFormState + clickElement still in browser.mjs. +import { pasteText, getFormState, clickElement } from '../browser.mjs'; + +/** + * Filter the current list by field value, or search via search bar. + * + * Without field: simple search via the search bar (filters by all columns, no badge). + * With field: advanced search — clicks target column cell to auto-populate FieldSelector, + * opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge. + * Handles text, reference (with Tab autocomplete), and date fields automatically. + * Multiple filters can be chained by calling filterList multiple times. + * + * @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016") + * @param {object} [opts] + * @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата") + * @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact) + */ +export async function filterList(text, { field, exact } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('filterList: no form found'); + + if (!field) { + // --- Simple search: fill search input + Enter --- + const searchId = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); + return el ? el.id : null; + })()`); + + if (searchId) { + await page.click(`[id="${searchId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await pasteText(text); + await page.waitForTimeout(300); + await page.keyboard.press('Enter'); + await waitForStable(formNum); + + const state = await getFormState(); + state.filtered = { type: 'search', text }; + return state; + } + + // No search input — Ctrl+F opens advanced search on such forms. + // Click first grid cell then fall through to advanced search path below. + const firstCell = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0); + if (!grid) return null; + const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; + if (!rows.length) return null; + const cells = [...rows[0].querySelectorAll('.gridBox')]; + if (!cells.length) return null; + const r = cells[0].getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (!firstCell) throw new Error('filterList: no search input and no grid found on this form'); + await page.mouse.click(firstCell.x, firstCell.y); + await page.waitForTimeout(300); + field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected) + } + + // --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти --- + // Clicking a cell in the target column makes it active, so when Alt+F opens the + // advanced search dialog, FieldSelector is auto-populated with the correct field name. + // This avoids changing FieldSelector programmatically (which can cause errors). + const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim()); + + // 1. Click a cell in the target column to activate it (auto-populates FieldSelector). + // If the column isn't visible in the grid, click any cell and use DLB fallback later. + let needDlb = false; + const gridEl = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0); + if (!grid) return { error: 'no_grid' }; + const targetField = ${JSON.stringify(field)}; + const headers = [...grid.querySelectorAll('.gridHead .gridBox')]; + let colIndex = -1; + let startsWithIdx = -1; + let includesIdx = -1; + for (let i = 0; i < headers.length; i++) { + const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' '); + if (!t) continue; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase()); + if (tl === fl) { colIndex = i; break; } + if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; } + else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; } + } + if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx; + const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; + if (!rows.length) return { error: 'no_rows' }; + if (colIndex < 0) { + // Column not in grid — click first cell of first row, will use DLB to change field + const cells = [...rows[0].querySelectorAll('.gridBox')]; + if (!cells.length) return { error: 'no_cells' }; + const r = cells[0].getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true }; + } + const cells = [...rows[0].querySelectorAll('.gridBox')]; + if (colIndex >= cells.length) return { error: 'cell_not_found' }; + const r = cells[colIndex].getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`); + needDlb = !!gridEl.needDlb; + await page.mouse.click(gridEl.x, gridEl.y); + await page.waitForTimeout(500); + + // 2. Open advanced search dialog via Alt+F (with fallback to Еще menu) + await page.keyboard.press('Alt+f'); + await page.waitForTimeout(2000); + + let dialogForm = await page.evaluate(detectFormScript()); + if (dialogForm === formNum) { + // Alt+F didn't open dialog — fallback to Еще → Расширенный поиск + await clickElement('Еще'); + await page.waitForTimeout(500); + const menu = await page.evaluate(readSubmenuScript()); + const searchItem = Array.isArray(menu) && menu.find(i => + i.name.replace(/\u00a0/g, ' ').toLowerCase().includes('расширенный поиск')); + if (!searchItem) { + await page.keyboard.press('Escape'); + throw new Error('filterList: advanced search dialog could not be opened'); + } + await page.mouse.click(searchItem.x, searchItem.y); + await page.waitForTimeout(2000); + dialogForm = await page.evaluate(detectFormScript()); + if (dialogForm === formNum) { + throw new Error('filterList: advanced search dialog did not open'); + } + } + + // 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown + // Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field) + if (needDlb && field) { + const fsInfo = await page.evaluate(`(() => { + const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; + const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); + const dlb = document.getElementById(p + 'FieldSelector_DLB'); + return { + current: fsInput?.value?.trim() || '', + dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0, + dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0 + }; + })()`); + + if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) { + await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY); + await page.waitForTimeout(1500); + + const ddResult = await page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' }; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const target = ny(${JSON.stringify(field.toLowerCase())}); + const items = [...edd.querySelectorAll('div')].filter(el => + el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n')); + const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target) + || items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target)); + if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) }; + const r = match.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() }; + })()`); + + if (ddResult.error) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`); + } + await page.mouse.click(ddResult.x, ddResult.y); + await page.waitForTimeout(3000); + } + } + + // 3. Read dialog state and fill Pattern + // Detect field type by Pattern's sibling buttons: + // - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value) + // - iDLB on Pattern → reference field (paste + Tab for autocomplete) + // - neither → plain text field (just paste) + const dialogInfo = await page.evaluate(`(() => { + const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; + const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); + const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); + const ptLabel = ptInput?.closest('label'); + const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : []; + const isDate = btns.some(c => c.includes('iCalendB')); + const isRef = !isDate && btns.some(c => c.includes('iDLB')); + return { + fieldSelector: fsInput?.value?.trim() || '', + patternValue: ptInput?.value?.trim() || '', + patternId: ptInput?.id || '', + isDate, + isRef + }; + })()`); + + if (dialogInfo.isDate) { + // Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste) + if (isDateValue && dialogInfo.patternValue !== text.trim()) { + await page.click(`[id="${dialogInfo.patternId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Home'); + await page.waitForTimeout(100); + await page.keyboard.press('Shift+End'); + await page.waitForTimeout(100); + await pasteText(text); + await page.waitForTimeout(500); + } + } else { + // Text or reference field: fill Pattern via clipboard paste + await page.click(`[id="${dialogInfo.patternId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await pasteText(text); + await page.waitForTimeout(300); + + if (dialogInfo.isRef) { + // Reference field: Tab triggers autocomplete to resolve text → reference value + await page.keyboard.press('Tab'); + await page.waitForTimeout(2000); + } + } + + // 3b. Switch CompareType if exact match requested (text fields only). + // Date/number: always exact, CompareType disabled. Reference: default exact (selects ref). + if (exact && !dialogInfo.isDate && !dialogInfo.isRef) { + const exactRadio = await page.evaluate(`(() => { + const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; + // Check if CompareType group is disabled (dates, numbers) + const group = document.getElementById(p + 'CompareType'); + if (group && group.classList.contains('disabled')) return { already: true }; + const el = document.getElementById(p + 'CompareType#2#radio'); + if (!el || el.offsetWidth === 0) return null; + if (el.classList.contains('select')) return { already: true }; + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (exactRadio && !exactRadio.already) { + await page.mouse.click(exactRadio.x, exactRadio.y); + await page.waitForTimeout(300); + } + } + + // 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked) + const findBtnCoords = await page.evaluate(`(() => { + const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); + const btn = btns.find(el => el.innerText?.trim() === 'Найти'); + if (!btn) return null; + const r = btn.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (findBtnCoords) { + await page.mouse.click(findBtnCoords.x, findBtnCoords.y); + } else { + await clickElement('Найти'); + } + await page.waitForTimeout(2000); + + // 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти). + // Check the specific dialog form — not generic modalSurface — to avoid closing parent modals + // (e.g. a selection form that opened this advanced search). + for (let attempt = 0; attempt < 3; attempt++) { + const dialogVisible = await page.evaluate(`(() => { + const p = 'form${dialogForm}_'; + return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); + })()`); + if (!dialogVisible) break; + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + await waitForStable(formNum); + + const state = await getFormState(); + state.filtered = { type: 'advanced', field, text, exact: !!exact }; + return state; +} + +/** + * Remove active filters/search from the current list. + * + * Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field). + * With field: clicks the × button on the specific filter badge (selective removal). + * + * @param {object} [opts] + * @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×) + */ +export async function unfilterList({ field } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('unfilterList: no form found'); + + if (field) { + // --- Selective: click × on specific filter badge --- + const closeBtn = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || ''; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const target = ny(${JSON.stringify(field.toLowerCase())}); + const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0); + for (const item of items) { + const titleEl = item.querySelector('.trainName'); + const title = ny(norm(titleEl?.innerText).toLowerCase()); + if (title === target || title.includes(target)) { + const close = item.querySelector('.trainClose'); + if (close) { + const r = close.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) }; + } + } + } + const available = items.map(item => norm(item.querySelector('.trainName')?.innerText)); + return { error: 'not_found', available }; + })()`); + + if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`); + await page.mouse.click(closeBtn.x, closeBtn.y); + await waitForStable(formNum); + + const state = await getFormState(); + state.unfiltered = { field: closeBtn.field }; + return state; + } + + // --- Clear ALL filters --- + + // 1. Remove all advanced filter badges (.trainItem × buttons) + for (let attempt = 0; attempt < 20; attempt++) { + const badge = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')] + .find(el => el.offsetWidth > 0); + if (!item) return null; + const close = item.querySelector('.trainClose'); + if (!close) return null; + const r = close.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (!badge) break; + await page.mouse.click(badge.x, badge.y); + await waitForStable(formNum); + } + + // 2. Cancel active search via Ctrl+Q + await page.keyboard.press('Control+q'); + await waitForStable(formNum); + + // 3. Clear simple search field if it has a value + const searchInfo = await page.evaluate(`(() => { + const p = 'form${formNum}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); + return el ? { id: el.id, value: el.value || '' } : null; + })()`); + + if (searchInfo?.value) { + await page.click(`[id="${searchInfo.id}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Delete'); + await page.keyboard.press('Enter'); + await waitForStable(formNum); + } + + const state = await getFormState(); + state.unfiltered = true; + return state; +} diff --git a/.claude/skills/web-test/scripts/table/spreadsheet.mjs b/.claude/skills/web-test/scripts/table/spreadsheet.mjs new file mode 100644 index 00000000..c2693a8e --- /dev/null +++ b/.claude/skills/web-test/scripts/table/spreadsheet.mjs @@ -0,0 +1,582 @@ +// web-test table/spreadsheet v1.16 — readTable, readSpreadsheet, scanSpreadsheetCells, scroll/click helpers for SpreadsheetDocument. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page, ensureConnected, normYo } from '../core/state.mjs'; +import { detectFormScript, readTableScript, resolveGridScript } from '../dom.mjs'; +import { waitForStable } from '../core/wait.mjs'; + +/** Read structured table data with pagination. Returns columns, rows, total count. */ +export async function readTable({ maxRows = 20, offset = 0, table } = {}) { + ensureConnected(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('readTable: no form found'); + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector })); +} + +// --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) --- + +/** + * Scan spreadsheet iframes for the current form and collect all cells. + * Returns { allCells: Map<'r_c', {r,c,t}>, frameMap: Map<'r_c', frameIndex> } + * where frameIndex is the Playwright frames[] index (1-based, 0 = main). + */ +async function scanSpreadsheetCells(formNum) { + const prefix = `form${formNum ?? 0}_`; + const iframeHandles = await page.$$('iframe'); + + const allCells = new Map(); + const frameMap = new Map(); // key 'r_c' → Playwright Frame object + + for (const handle of iframeHandles) { + const ok = await handle.evaluate((f, pfx) => { + if (f.offsetWidth < 100) return false; + let el = f.parentElement; + for (let d = 0; el && d < 30; d++, el = el.parentElement) { + if (el.id && el.id.startsWith(pfx)) return true; + } + return false; + }, prefix); + if (!ok) continue; + + const frame = await handle.contentFrame(); + if (!frame) continue; + + try { + const cells = await frame.evaluate(`(() => { + const cells = []; + document.querySelectorAll('div[x]').forEach(d => { + const span = d.querySelector('span'); + const text = span?.innerText?.replace(/\\n/g, ' ')?.trim() || ''; + if (!text) return; + const rowDiv = d.parentElement; + const row = rowDiv?.getAttribute('y') || rowDiv?.className?.match(/R(\\d+)/)?.[1] || null; + const col = d.getAttribute('x'); + if (row != null && col != null) cells.push({ r: parseInt(row), c: parseInt(col), t: text }); + }); + return cells; + })()`); + for (const cell of cells) { + const key = `${cell.r}_${cell.c}`; + if (!allCells.has(key) || cell.t.length > allCells.get(key).t.length) { + allCells.set(key, cell); + frameMap.set(key, frame); + } + } + } catch { /* skip inaccessible frames */ } + } + return { allCells, frameMap }; +} + +/** + * Build structured mapping from raw cells: headers, column map, data/totals row indices. + * Returns { rows, sortedRows, maxCol, colNames, headerRowIdx, dataStartIdx, totalsRowIdx, rowMap } + * or null if header detection fails. + */ +function buildSpreadsheetMapping(allCells) { + const rowMap = new Map(); + let maxCol = 0; + for (const cell of allCells.values()) { + if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map()); + rowMap.get(cell.r).set(cell.c, cell.t); + if (cell.c > maxCol) maxCol = cell.c; + } + + const sortedRows = [...rowMap.keys()].sort((a, b) => a - b); + const rows = sortedRows.map(r => { + const cm = rowMap.get(r); + const arr = []; + for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || ''); + return arr; + }); + + // Generic numeric check: digits with optional spaces/commas, excludes codes like "68/78" + // Accepts bare integers (e.g. account codes "50", "84") — used for hasNumber / totals classification. + const isNumericVal = (c) => { + if (!c || !/\d/.test(c)) return false; + const s = c.replace(/^[-\s\u00a0]+/, '').replace(/[\s\u00a0]/g, ''); + return /^\d[\d,]*$/.test(s); + }; + // Data-formatted numeric value: requires a formatting signal (grouping space, decimal comma, or leading minus). + // Used as the anchor for first data row — avoids false positives on bare account codes like "50", "51". + const isDataNumericVal = (c) => { + if (!isNumericVal(c)) return false; + return /[\s\u00a0,]/.test(c) || /^-/.test(c); + }; + const hasNumber = (row) => row.some(c => isNumericVal(c)); + const nonEmpty = (row) => row.filter(c => c !== '').length; + + // Build a rich mapping (group/super/DCS) anchored at a known detailIdx + firstDataIdx. + // Shared by Level 1 (DCS-code anchor) and Level 2 (formatted-number anchor). + const buildRichMapping = (detailIdx, firstDataIdx) => { + let groupIdx = -1; + if (detailIdx > 0 && nonEmpty(rows[detailIdx - 1]) >= 2) groupIdx = detailIdx - 1; + + const detailRow = rows[detailIdx]; + const groupRow = groupIdx >= 0 ? rows[groupIdx] : null; + + // Detect optional third header level above group row (bounds carry-forward) + let superRow = null; + if (groupIdx > 0 && nonEmpty(rows[groupIdx - 1]) >= 2) { + superRow = rows[groupIdx - 1]; + } + + // Build column names (group + detail merge) + const groupFilled = new Array(maxCol + 1).fill(''); + if (groupRow) { + let cur = ''; + for (let c = 0; c <= maxCol; c++) { + if (groupRow[c]) { + cur = groupRow[c]; + } else if (superRow && superRow[c]) { + // New top-level header starts here — stop carry-forward + cur = ''; + } + groupFilled[c] = cur; + } + } + + const detailCounts = {}; + for (let c = 0; c <= maxCol; c++) { + const n = detailRow[c]; + if (n) detailCounts[n] = (detailCounts[n] || 0) + 1; + } + + // Detect DCS column codes (К1, К2, ...) — always prefix with group when present + const detailNonEmpty = detailRow.filter(c => c); + const isDcsCodeRow = detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c)); + + const colNames = []; + for (let c = 0; c <= maxCol; c++) { + const detail = detailRow[c]; + const group = groupFilled[c]; + const sup = superRow ? superRow[c] : ''; + if (detail) { + // Prefer group prefix; fall back to superRow for DCS code columns without sub-group + const prefix = group && group !== detail ? group : (isDcsCodeRow && sup ? sup : ''); + const needPrefix = prefix && (isDcsCodeRow || detailCounts[detail] > 1 || (groupRow && groupRow[c] === '')); + colNames.push(needPrefix ? `${prefix} / ${detail}` : detail); + } else if (group) { + colNames.push(group); + } else if (sup) { + colNames.push(sup); + } else { + colNames.push(null); + } + } + + const colMap = new Map(); + for (let c = 0; c < colNames.length; c++) { + if (colNames[c]) colMap.set(colNames[c], c); + } + + // Classify data rows: separate data indices and totals index + const dataRowIndices = []; + let totalsRowIdx = -1; + for (let i = firstDataIdx; i < rows.length; i++) { + if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; + const first = rows[i][0]?.trim().toLowerCase(); + if (first === 'итого' || first === 'всего') { + totalsRowIdx = i; + } else { + dataRowIndices.push(i); + } + } + + const superRowIdx = superRow ? groupIdx - 1 : -1; + + return { + rows, sortedRows, maxCol, colNames, colMap, + headerRowIdx: detailIdx, groupRowIdx: groupIdx, superRowIdx, + dataStartIdx: firstDataIdx, dataRowIndices, totalsRowIdx, + rowMap, hasNumber, nonEmpty, + }; + }; + + // --- Level 1: DCS-code row anchor --- + // ФСД / СКД-отчёты всегда содержат строку "К1, К2, ..." — rock-solid structural marker. + // Якорение через неё — детерминированное, работает даже если все данные — голые целые (отчёт в "тыс.руб"). + for (let i = 0; i < rows.length; i++) { + const detailNonEmpty = rows[i].filter(c => c); + if (detailNonEmpty.length >= 2 && detailNonEmpty.every(c => /^К\d+$/.test(c))) { + // Find first non-empty row after the К-codes row as data start + let firstDataIdx = rows.length; + for (let j = i + 1; j < rows.length; j++) { + if (nonEmpty(rows[j]) > 0) { firstDataIdx = j; break; } + } + return buildRichMapping(i, firstDataIdx); + } + } + + // --- Level 2: formatted-number anchor (heuristic for reports without DCS codes) --- + let firstDataIdx = rows.length; + for (let i = 0; i < rows.length; i++) { + if (rows[i].filter(c => isDataNumericVal(c)).length >= 2) { firstDataIdx = i; break; } + } + if (firstDataIdx === rows.length) { + for (let i = 0; i < rows.length; i++) { + if (rows[i].some(c => isDataNumericVal(c))) { firstDataIdx = i; break; } + } + } + + if (firstDataIdx < rows.length) { + let detailIdx = -1; + for (let i = firstDataIdx - 1; i >= 0; i--) { + if (nonEmpty(rows[i]) >= Math.min(3, maxCol + 1)) { detailIdx = i; break; } + } + if (detailIdx !== -1) return buildRichMapping(detailIdx, firstDataIdx); + } + + // --- Level 3: single-row header fallback (text-only data, query console) --- + // First "wide" row (nonEmpty >= 2) = headers, rest = data. No multi-level composition. + let headerIdx = -1; + for (let i = 0; i < rows.length; i++) { + if (nonEmpty(rows[i]) >= 2) { headerIdx = i; break; } + } + // Single-column tables: accept nonEmpty >= 1 + if (headerIdx === -1 && maxCol === 0) { + for (let i = 0; i < rows.length; i++) { + if (nonEmpty(rows[i]) >= 1) { headerIdx = i; break; } + } + } + if (headerIdx === -1) return null; // truly empty — top-level fallback to { rows, total } + + const detailRow = rows[headerIdx]; + const colNames = []; + for (let c = 0; c <= maxCol; c++) colNames.push(detailRow[c] || null); + const colMap = new Map(); + for (let c = 0; c < colNames.length; c++) { + if (colNames[c]) colMap.set(colNames[c], c); + } + + const dataRowIndices = []; + let totalsRowIdx = -1; + for (let i = headerIdx + 1; i < rows.length; i++) { + if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; + const first = rows[i][0]?.trim().toLowerCase(); + if (first === 'итого' || first === 'всего') { + totalsRowIdx = i; + } else { + dataRowIndices.push(i); + } + } + + return { + rows, sortedRows, maxCol, colNames, colMap, + headerRowIdx: headerIdx, groupRowIdx: -1, superRowIdx: -1, + dataStartIdx: headerIdx + 1, dataRowIndices, totalsRowIdx, + rowMap, hasNumber, nonEmpty, + }; +} + +/** + * Scroll SpreadsheetDocument to make a cell visible using arrow keys. + * Uses native platform scroll — keeps headers, data, and scrollbar synchronized. + * + * How it works: + * 1. Check target cell visibility via Playwright boundingBox (page-level coords). + * 2. Click a fully-visible cell via page.mouse.click through the mxlCurrBody overlay. + * This is the same native click that clickSpreadsheetCell uses — it gives keyboard + * focus to the spreadsheet and keeps headers/data/scrollbar in sync. + * (frame.locator().click() bypasses overlay → desyncs frozen headers; + * page.mouse.click() + frameEl.focus() doesn't transfer keyboard focus.) + * 3. Press ArrowRight/ArrowLeft until the target cell is fully within the viewport. + * + * @param {Frame} frame - Playwright Frame containing the spreadsheet cells + * @param {number} physRow - physical row (y attribute) in the frame + * @param {number} physCol - physical column (x attribute) in the frame + * @param {Locator} cellLoc - Playwright locator for the target cell (from caller) + */ +async function scrollSpreadsheetToCell(frame, physRow, physCol, cellLoc) { + const pageVw = await page.evaluate('window.innerWidth'); + // Get iframe bounds — the actual visible region on page. + // The iframe may extend behind the section panel on the left, so cells with + // x >= 0 but x < iframeBox.x are behind the panel. Clicking them hits the panel. + const frameElm = await frame.frameElement(); + const frameBox = await frameElm.boundingBox(); + const visLeft = frameBox ? frameBox.x : 0; + const visRight = frameBox ? Math.min(frameBox.x + frameBox.width, pageVw) : pageVw; + + const getBox = async () => { + try { return await cellLoc.boundingBox({ timeout: 500 }); } + catch { return null; } + }; + const isFullyVisible = (box) => box && box.x >= visLeft && (box.x + box.width) <= visRight; + + let box = await getBox(); + if (!box) return; // cell not in DOM + if (isFullyVisible(box)) return; + + const direction = (box.x + box.width) > pageVw ? 'ArrowRight' : 'ArrowLeft'; + + // Find a fully-visible cell to click for focus. + // Prefer cells in the target row (scrollable area), fall back to any row. + const targetRowSel = `div[y="${physRow}"] div[x]`; + const anyRowSel = 'div[x]'; + let focusClicked = false; + for (const sel of [targetRowSel, anyRowSel]) { + const locs = frame.locator(sel); + const count = await locs.count(); + const candidates = []; + for (let ci = 0; ci < count; ci++) { + const b = await locs.nth(ci).boundingBox(); + if (b && b.width > 5 && b.x >= visLeft && (b.x + b.width) <= visRight) { + candidates.push({ ci, box: b }); + } + } + if (candidates.length === 0) continue; + candidates.sort((a, b) => a.box.x - b.box.x); + // ArrowRight → rightmost fully-visible (each press scrolls right immediately) + // ArrowLeft → leftmost fully-visible (each press scrolls left immediately) + const pick = direction === 'ArrowRight' + ? candidates[candidates.length - 1] + : candidates[0]; + // Native click through overlay — gives keyboard focus + no header desync. + await page.mouse.click(pick.box.x + pick.box.width / 2, pick.box.y + pick.box.height / 2); + await page.waitForTimeout(100); + focusClicked = true; + break; + } + if (!focusClicked) return; // no visible cells — can't scroll + + // Arrow keys until cell is fully visible or we detect no progress. + const MAX_STALE = 5; // bail out if arrows aren't scrolling (lost focus?) + let prevCx = box.x + box.width / 2; + let staleCount = 0; + for (let i = 0; i < 100; i++) { + await page.keyboard.press(direction); + await page.waitForTimeout(50); + box = await getBox(); + if (!box) break; + if (isFullyVisible(box)) break; + const cx = box.x + box.width / 2; + if (Math.abs(cx - prevCx) >= 1) { + staleCount = 0; + } else { + staleCount++; + if (staleCount >= MAX_STALE) break; + } + prevCx = cx; + } + await page.waitForTimeout(200); +} + +/** + * Click a cell in SpreadsheetDocument by logical coordinates. + * target: { row: number|'totals'|{colName: value}, column: string } + * Internal helper — called from clickElement when first arg is an object. + */ +export async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = {}) { + ensureConnected(); + const formNum = await page.evaluate(detectFormScript()); + const { allCells, frameMap } = await scanSpreadsheetCells(formNum); + if (allCells.size === 0) throw new Error('clickElement: no SpreadsheetDocument found on current form.'); + + const mapping = buildSpreadsheetMapping(allCells); + if (!mapping) throw new Error('clickElement: could not detect spreadsheet headers. Use readSpreadsheet() to check report structure.'); + + const { rows, sortedRows, colNames, colMap, dataRowIndices, totalsRowIdx } = mapping; + + // Resolve column (exact → endsWith " / X" → includes) + let colName = target.column; + if (!colMap.has(colName)) { + const available = colNames.filter(n => n); + const suffix = ' / ' + colName; + const match = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(colName)); + if (!match) throw new Error(`clickElement: column "${colName}" not found. Available: ${available.join(', ')}`); + colName = match; + } + const physCol = colMap.get(colName); + + // Resolve row → index into rows[] array + let rowIdx; + const row = target.row; + if (row === 'totals') { + if (totalsRowIdx === -1) throw new Error('clickElement: no totals row found in spreadsheet.'); + rowIdx = totalsRowIdx; + } else if (typeof row === 'number') { + if (row < 0 || row >= dataRowIndices.length) throw new Error(`clickElement: row index ${row} out of range (0..${dataRowIndices.length - 1}).`); + rowIdx = dataRowIndices[row]; + } else if (typeof row === 'object') { + // Filter: { colName: value } — find first data row where column matches + const filterEntries = Object.entries(row); + const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || ''; + const resolveCol = (name) => { + if (colMap.has(name)) return colMap.get(name); + const suffix = ' / ' + name; + const available = colNames.filter(n => n); + const m = available.find(n => n.endsWith(suffix)) || available.find(n => n.includes(name)); + return m ? colMap.get(m) : null; + }; + rowIdx = dataRowIndices.find(i => { + return filterEntries.every(([fCol, fVal]) => { + const fColIdx = resolveCol(fCol); + if (fColIdx == null) return false; + const cellText = norm(rows[i][fColIdx]); + const search = norm(fVal); + return cellText === search || cellText.includes(search); + }); + }); + if (rowIdx == null) throw new Error(`clickElement: no row matching ${JSON.stringify(row)} found in spreadsheet data.`); + } else { + throw new Error('clickElement: row must be a number, "totals", or { colName: value } filter object.'); + } + + // Map rows[] index → physical row number + const physRow = sortedRows[rowIdx]; + const cellKey = `${physRow}_${physCol}`; + const frame = frameMap.get(cellKey); + if (!frame) { + // Cell exists in mapping but might be empty — try clicking anyway + throw new Error(`clickElement: cell at row=${JSON.stringify(target.row)}, column="${colName}" is empty or not rendered.`); + } + // Use [y]+[x] attributes — CSS class RxCy uses different numbering than y/x attrs. + const cellDiv = frame.locator(`div[y="${physRow}"] div[x="${physCol}"]`).first(); + // Scroll cell into view using arrow keys — the only reliable way to scroll + // 1C SpreadsheetDocument without desynchronizing headers, data, and scrollbar. + await scrollSpreadsheetToCell(frame, physRow, physCol, cellDiv); + const box = await cellDiv.boundingBox(); + if (!box) throw new Error(`clickElement: cell y=${physRow} x=${physCol} not visible (no bounding box).`); + + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; + if (modKey) await page.keyboard.down(modKey); + if (dbl) { + await page.mouse.dblclick(x, y); + } else { + await page.mouse.click(x, y); + } + if (modKey) await page.keyboard.up(modKey); + + await waitForStable(); + const state = await getFormState(); + state.clicked = { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) }; + return state; +} + +/** + * Search spreadsheet iframes for a cell matching text (for text fallback in clickElement). + * Returns { frameIndex, physRow, physCol, box } or null if not found. + */ +export async function findSpreadsheetCellByText(formNum, searchText) { + const { allCells, frameMap } = await scanSpreadsheetCells(formNum); + if (allCells.size === 0) return null; + + const norm = s => s?.replace(/\u00a0/g, ' ').trim().toLowerCase() || ''; + const target = norm(searchText); + + // Exact match first, then includes + let found = null; + for (const [key, cell] of allCells) { + if (norm(cell.t) === target) { found = { key, cell }; break; } + } + if (!found) { + for (const [key, cell] of allCells) { + if (norm(cell.t).includes(target)) { found = { key, cell }; break; } + } + } + if (!found) return null; + + const frame = frameMap.get(found.key); + if (!frame) return null; + + // Scroll cell into view using native arrow-key mechanism + const cellDiv = frame.locator(`div[y="${found.cell.r}"] div[x="${found.cell.c}"]`).first(); + await scrollSpreadsheetToCell(frame, found.cell.r, found.cell.c, cellDiv); + const box = await cellDiv.boundingBox(); + if (!box) return null; + + return { frame, physRow: found.cell.r, physCol: found.cell.c, text: found.cell.t, box }; +} + +/** + * Read report output (SpreadsheetDocumentField) rendered in iframes. + * 1C renders spreadsheet documents as absolutely-positioned div cells inside iframes. + * Each cell is a div[x] inside a row div[y], text content in . + * + * Returns structured data: + * { title, headers, data: [{col: val}], totals: {col: val}, total } + * If header detection fails, falls back to { rows: string[][], total }. + */ +export async function readSpreadsheet() { + ensureConnected(); + const formNum = await page.evaluate(detectFormScript()); + + const { allCells } = await scanSpreadsheetCells(formNum); + + if (allCells.size === 0) { + // Check for state window messages (info bar) that explain why the report is empty + const err = await checkForErrors(); + const hint = err?.stateText?.length ? err.stateText.join('; ') : ''; + throw new Error('readSpreadsheet: no SpreadsheetDocument found.' + (hint ? ' State: ' + hint : ' Report may not be generated yet.')); + } + + const mapping = buildSpreadsheetMapping(allCells); + if (!mapping) { + // Fallback: return raw rows + const rowMap = new Map(); + let maxCol = 0; + for (const cell of allCells.values()) { + if (!rowMap.has(cell.r)) rowMap.set(cell.r, new Map()); + rowMap.get(cell.r).set(cell.c, cell.t); + if (cell.c > maxCol) maxCol = cell.c; + } + const sortedRows = [...rowMap.keys()].sort((a, b) => a - b); + const rows = sortedRows.map(r => { + const cm = rowMap.get(r); + const arr = []; + for (let c = 0; c <= maxCol; c++) arr.push(cm.get(c) || ''); + return arr; + }); + return { rows, total: rows.length }; + } + + const { rows, colNames, dataStartIdx, maxCol, groupRowIdx, headerRowIdx, superRowIdx, hasNumber, nonEmpty } = mapping; + + // Convert data rows to objects + const data = []; + let totals = null; + const toObj = (row) => { + const obj = {}; + for (let c = 0; c < colNames.length; c++) { + if (colNames[c] && row[c]) obj[colNames[c]] = row[c]; + } + return obj; + }; + + for (let i = dataStartIdx; i < rows.length; i++) { + if (!hasNumber(rows[i]) && nonEmpty(rows[i]) === 0) continue; + const first = rows[i][0]?.trim().toLowerCase(); + if (first === 'итого' || first === 'всего') { + totals = toObj(rows[i]); + } else { + data.push(toObj(rows[i])); + } + } + + // Meta: title, params, filters from rows before header (superRow is part of header, not meta) + const metaEnd = superRowIdx >= 0 ? superRowIdx : (groupRowIdx >= 0 ? groupRowIdx : headerRowIdx); + let title = ''; + const meta = []; + for (let i = 0; i < metaEnd; i++) { + const parts = rows[i].filter(c => c); + if (!parts.length) continue; + if (!title) { title = parts.join(' '); continue; } + meta.push(parts.join(' ')); + } + + return { + title: title || undefined, + meta: meta.length ? meta : undefined, + headers: colNames.filter(n => n), + data, + totals: totals || undefined, + total: data.length, + }; +} From 50d40a9dd568175d34720686de2daa9d9b1286c1 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 13:26:48 +0300 Subject: [PATCH 18/47] =?UTF-8?q?fix(web-test):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=82=D1=8C=20getFormState=20=D0=B2=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=D1=8B=20table/spreadsheet.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clickSpreadsheetCell вызывает getFormState в конце (для drill-down формы), но import не был добавлен при экстракции на C.11. ReferenceError в 11-report drill-down. Импортируем из browser.mjs (циклически). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/table/spreadsheet.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.claude/skills/web-test/scripts/table/spreadsheet.mjs b/.claude/skills/web-test/scripts/table/spreadsheet.mjs index c2693a8e..ccf2d2b8 100644 --- a/.claude/skills/web-test/scripts/table/spreadsheet.mjs +++ b/.claude/skills/web-test/scripts/table/spreadsheet.mjs @@ -4,6 +4,8 @@ import { page, ensureConnected, normYo } from '../core/state.mjs'; import { detectFormScript, readTableScript, resolveGridScript } from '../dom.mjs'; import { waitForStable } from '../core/wait.mjs'; +// getFormState still in browser.mjs (cycle resolves at call time). +import { getFormState } from '../browser.mjs'; /** Read structured table data with pagination. Returns columns, rows, total count. */ export async function readTable({ maxRows = 20, offset = 0, table } = {}) { From a5c0be6766da0d36a7a4ebddf44d374285c58736 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 14:59:28 +0300 Subject: [PATCH 19/47] =?UTF-8?q?refactor(web-test):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=E2=80=94=20readTable=20=D0=B2=20table/grid.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readTable читает form-grid (.gridLine/.gridBody — табличные части на форме, списки), а не SpreadsheetDocument. Имя файла table/spreadsheet.mjs было обманчиво. Разделяем домены: table/grid.mjs ← readTable (form-grid операции, готово для fillTableRow + deleteTableRow в D.12) table/spreadsheet.mjs ← readSpreadsheet + cell helpers (только SpreadsheetDocument — отчёты, печатные формы) Поведение 1-в-1. browser.mjs re-export обновлён. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 3 ++- .../skills/web-test/scripts/table/grid.mjs | 23 +++++++++++++++++++ .../web-test/scripts/table/spreadsheet.mjs | 14 +---------- 3 files changed, 26 insertions(+), 14 deletions(-) create mode 100644 .claude/skills/web-test/scripts/table/grid.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 9f32ebb9..5f36b4da 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -184,7 +184,8 @@ export async function getFormState() { // ============================================================ // Table reading + SpreadsheetDocument — extracted to table/spreadsheet.mjs // ============================================================ -export { readTable, readSpreadsheet } from './table/spreadsheet.mjs'; +export { readTable } from './table/grid.mjs'; +export { readSpreadsheet } from './table/spreadsheet.mjs'; // ============================================================ diff --git a/.claude/skills/web-test/scripts/table/grid.mjs b/.claude/skills/web-test/scripts/table/grid.mjs new file mode 100644 index 00000000..1cdb5dd1 --- /dev/null +++ b/.claude/skills/web-test/scripts/table/grid.mjs @@ -0,0 +1,23 @@ +// web-test table/grid v1.16 — Form-grid operations: read table rows, fill rows, delete rows. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// "Grid" в терминах 1С — таблица на форме (.gridLine/.gridBody/.grid в DOM): +// табличные части документов, формы списков, ТЧ настроек и т.п. +// Отдельно от SpreadsheetDocument (table/spreadsheet.mjs). + +import { page, ensureConnected } from '../core/state.mjs'; +import { detectFormScript, readTableScript, resolveGridScript } from '../dom.mjs'; + +/** Read structured table data with pagination. Returns columns, rows, total count. */ +export async function readTable({ maxRows = 20, offset = 0, table } = {}) { + ensureConnected(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('readTable: no form found'); + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector })); +} diff --git a/.claude/skills/web-test/scripts/table/spreadsheet.mjs b/.claude/skills/web-test/scripts/table/spreadsheet.mjs index ccf2d2b8..678b4f1e 100644 --- a/.claude/skills/web-test/scripts/table/spreadsheet.mjs +++ b/.claude/skills/web-test/scripts/table/spreadsheet.mjs @@ -7,19 +7,7 @@ import { waitForStable } from '../core/wait.mjs'; // getFormState still in browser.mjs (cycle resolves at call time). import { getFormState } from '../browser.mjs'; -/** Read structured table data with pagination. Returns columns, rows, total count. */ -export async function readTable({ maxRows = 20, offset = 0, table } = {}) { - ensureConnected(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('readTable: no form found'); - let gridSelector; - if (table) { - const resolved = await page.evaluate(resolveGridScript(formNum, table)); - if (resolved.error) throw new Error(`readTable: ${resolved.message || resolved.error}. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); - gridSelector = resolved.gridSelector; - } - return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector })); -} +// readTable moved to table/grid.mjs (form-grid операции отделены от SpreadsheetDocument). // --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) --- From f31770d79c94e47ee8e66d2b252ef19bb2e80654 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 15:02:47 +0300 Subject: [PATCH 20/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20D.12=20=E2=80=94=20fillTableRow=20=E2=86=92=20row-fill?= =?UTF-8?q?.mjs,=20deleteTableRow=20=E2=86=92=20grid.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit table/row-fill.mjs (~1230 LOC): fillTableRow целиком, как entry-point. table/grid.mjs: добавлен deleteTableRow + расширены импорты (clickElement, dismissPendingErrors, waitForStable, getFormState). Дальнейший распил fillTableRow на под-хелперы (trySelect/readVisibleRows/ pickValueWithOptionalType/enterEditMode/fillCellSequentially per плана §12.1-12.3) отложен — при необходимости создаём table/row-fill/*.mjs subfolder. browser.mjs: 1532 → 249 LOC (-84% от baseline 6293). 05-table регресс зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 1292 +---------------- .../skills/web-test/scripts/table/grid.mjs | 81 ++ .../web-test/scripts/table/row-fill.mjs | 1235 ++++++++++++++++ 3 files changed, 1321 insertions(+), 1287 deletions(-) create mode 100644 .claude/skills/web-test/scripts/table/row-fill.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 5f36b4da..29053f74 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -218,1293 +218,11 @@ export { closeForm } from './forms/close.mjs'; -/** - * Fill cells in the current table row via Tab navigation. - * Grid cells are only accessible sequentially (Tab) — no random access. - * - * After "Добавить", 1C enters inline edit mode on the first cell. - * All inputs in the row are created hidden (offsetWidth=0); only the active one is visible. - * Tab moves through cells in a fixed order determined by the form configuration. - * - * @param {Object} fields - { fieldName: value } map (fuzzy match: "Номенклатура" → "ТоварыНоменклатура") - * @param {Object} [options] - * @param {string} [options.tab] - Switch to this form tab before operating - * @param {boolean} [options.add] - Click "Добавить" to create a new row first - * @returns {{ filled[], notFilled[]?, form }} - */ -export async function fillTableRow(fields, { tab, add, row, table } = {}) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('fillTableRow: no form found'); - - // Pre-resolve grid when table is specified - let gridSelector; - if (table) { - const resolved = await page.evaluate(resolveGridScript(formNum, table)); - if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); - gridSelector = resolved.gridSelector; - } - - try { - // 1. Switch tab if requested - if (tab) { - await clickElement(tab); - } - - // 2. Add new row if requested - let addedRowIdx = -1; - if (add) { - // Count rows before add — new row will be appended at this index - addedRowIdx = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - const body = grid?.querySelector('.gridBody'); - return body ? body.querySelectorAll('.gridLine').length : 0; - })()`); - await clickElement('Добавить', { table }); - // Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait - for (let aw = 0; aw < 6; aw++) { - await page.waitForTimeout(150); - const ready = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; - let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } - return false; - })()`); - if (ready) break; - } - } - - // 2b. Enter edit mode on existing row by dblclick - if (row != null) { - // Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right - const sortedKeys = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return null; - const head = grid.querySelector('.gridHead'); - if (!head) return null; - const headLine = head.querySelector('.gridLine') || head; - const cols = []; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase(); - const ci = parseInt(box.getAttribute('colindex') || '-1'); - if (t) cols.push({ text: t, colindex: ci }); - }); - const keys = ${JSON.stringify(Object.keys(fields).map(k => k.toLowerCase()))}; - const mapped = keys.map(k => { - const exact = cols.find(c => c.text === k); - if (exact) return { key: k, colindex: exact.colindex }; - const inc = cols.find(c => c.text.includes(k) || k.includes(c.text)); - return { key: k, colindex: inc ? inc.colindex : 999 }; - }); - mapped.sort((a, b) => a.colindex - b.colindex); - return mapped.map(m => m.key); - })()`); - if (sortedKeys) { - // Rebuild fields in sorted order - const sortedFields = {}; - for (const kl of sortedKeys) { - const origKey = Object.keys(fields).find(k => k.toLowerCase() === kl); - if (origKey) sortedFields[origKey] = fields[origKey]; - } - // Add any keys not matched in header (preserve original order for those) - for (const k of Object.keys(fields)) { - if (!(k in sortedFields)) sortedFields[k] = fields[k]; - } - fields = sortedFields; - } - - const fieldKeys = JSON.stringify(Object.keys(fields).map(k => k.toLowerCase())); - const cellCoords = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return { error: 'no_grid' }; - const head = grid.querySelector('.gridHead'); - const body = grid.querySelector('.gridBody'); - if (!head || !body) return { error: 'no_grid_body' }; - - // Read column headers to find target colindex - const headLine = head.querySelector('.gridLine') || head; - const cols = []; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = box.querySelector('.gridBoxText'); - const ci = box.getAttribute('colindex'); - cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); - }); - - const keys = ${fieldKeys}; - let targetColindex = null; - for (const key of keys) { - const exact = cols.find(c => c.text === key); - if (exact) { targetColindex = exact.colindex; break; } - const inc = cols.find(c => c.text.includes(key) || key.includes(c.text)); - if (inc) { targetColindex = inc.colindex; break; } - } - - const rows = [...body.querySelectorAll('.gridLine')]; - if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; - const line = rows[${row}]; - - // Find body cell by colindex (reliable across merged headers) - let box = null; - if (targetColindex != null) { - box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); - } - // Fallback: second visible box (skip checkbox/N column) - if (!box) { - const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); - box = boxes.length > 1 ? boxes[1] : boxes[0]; - } - if (!box) return { error: 'no_cell' }; - // Scroll into view if off-screen - box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - const cell = box.querySelector('.gridBoxText') || box; - const r = cell.getBoundingClientRect(); - const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; - })()`); - - if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); - - // Skip if cell already contains the desired value (single-field optimization) - const firstKey0 = Object.keys(fields)[0]; - const rawFirstVal = fields[firstKey0]; - const firstVal0 = rawFirstVal === null || rawFirstVal === undefined || rawFirstVal === '' - ? '' : (typeof rawFirstVal === 'object' ? rawFirstVal.value : String(rawFirstVal)); - let firstFieldSkipped = false; - if (cellCoords.currentText && firstVal0 && - cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) { - firstFieldSkipped = true; - if (Object.keys(fields).length === 1) { - return [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }]; - } - } - - // Click first (tree grids enter edit on single click; dblclick toggles expand/collapse). - // Then escalate: dblclick → F4 if needed. - await page.mouse.click(cellCoords.x, cellCoords.y); - - // Clear cell via Shift+F4 if value is empty - if (firstVal0 === '') { - await page.waitForTimeout(500); - // Check if click opened a selection form — close it first - let openedForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (openedForm !== null) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - } else { - // No form opened — need to enter edit mode first (dblclick), then close any form that opens - await page.mouse.dblclick(cellCoords.x, cellCoords.y); - await page.waitForTimeout(500); - openedForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (openedForm !== null) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - } - } - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(300); - const results = [{ field: firstKey0, ok: true, method: 'clear', value: '' }]; - // If more fields remain, process them on the same row - const remaining = { ...fields }; - delete remaining[firstKey0]; - if (Object.keys(remaining).length > 0) { - const more = await fillTableRow(remaining, { row, table }); - if (Array.isArray(more)) results.push(...more); - else if (more?.filled) results.push(...more.filled); - } - const formData = await getFormState(); - return { filled: results, form: formData }; - } - - // Check if clicked cell is a checkbox (toggle-on-click, no edit mode) - const checkboxInfo = await page.evaluate(`(() => { - const el = document.elementFromPoint(${cellCoords.x}, ${cellCoords.y}); - const cell = el?.closest('.gridBox'); - if (!cell) return null; - const chk = cell.querySelector('.checkbox'); - if (!chk) return null; - const r = chk.getBoundingClientRect(); - return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }; - })()`); - if (checkboxInfo !== null) { - // Checkbox cell found — click directly on the checkbox icon (not cell center) - const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim()); - if (checkboxInfo.checked !== desired) { - await page.mouse.click(checkboxInfo.x, checkboxInfo.y); - await page.waitForTimeout(300); - } - const results = [{ field: firstKey0, ok: true, method: 'toggle', value: desired }]; - await waitForStable(formNum); - // If more fields remain, process them on the same row - const remaining = { ...fields }; - delete remaining[firstKey0]; - if (Object.keys(remaining).length > 0) { - const more = await fillTableRow(remaining, { row, table }); - results.push(...more); - } - return results; - } - - let inEdit = false; - let directEditForm = null; - for (let dw = 0; dw < 4; dw++) { - await page.waitForTimeout(150); - inEdit = await page.evaluate(`(() => { - const f = document.activeElement; - return f && f.tagName === 'INPUT'; - })()`); - if (inEdit) break; - directEditForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (directEditForm !== null) break; - } - // Click didn't enter edit — try dblclick (works for flat grids) - if (!inEdit && directEditForm === null) { - await page.mouse.dblclick(cellCoords.x, cellCoords.y); - for (let dw = 0; dw < 4; dw++) { - await page.waitForTimeout(150); - inEdit = await page.evaluate(`(() => { - const f = document.activeElement; - return f && f.tagName === 'INPUT'; - })()`); - if (inEdit) break; - directEditForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (directEditForm !== null) break; - } - } - // Still nothing — try F4 (opens selection for direct-edit cells) - if (!inEdit && directEditForm === null) { - await page.keyboard.press('F4'); - for (let fw = 0; fw < 8; fw++) { - await page.waitForTimeout(200); - inEdit = await page.evaluate(`(() => { - const f = document.activeElement; - return f && f.tagName === 'INPUT'; - })()`); - if (inEdit) break; - directEditForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (directEditForm !== null) break; - } - } - - // When click entered INPUT mode but no selection form yet — try F4 only for tree grids - // (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop) - if (inEdit && directEditForm === null) { - const isTreeGrid = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - return grid ? !!grid.querySelector('.gridBoxTree') : false; - })()`); - if (isTreeGrid) { - await page.keyboard.press('F4'); - for (let fw = 0; fw < 8; fw++) { - await page.waitForTimeout(200); - directEditForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (directEditForm !== null) break; - } - // If F4 didn't open a selection form, fall through to Tab loop - } - } - - // Direct-edit mode: selection form opened on dblclick/F4 (e.g. tree grid with immediate editing). - // Handle each field by picking from selection form, then dblclick next cell. - if (directEditForm !== null) { - const pending = new Map(); - for (const [key, val] of Object.entries(fields)) { - if (val && typeof val === 'object' && 'value' in val) { - pending.set(key, { value: String(val.value), type: val.type || null, filled: false }); - } else { - pending.set(key, { value: String(val), type: null, filled: false }); - } - } - const results = []; - - // Helper: handle type dialog + pick from selection form - async function directEditPick(openedForm, key, info) { - let selForm = openedForm; - // Check if opened form is a type selection dialog (composite type field) - if (await isTypeDialog(selForm)) { - if (info.type) { - await pickFromTypeDialog(selForm, info.type); - await waitForStable(selForm); - // After type selection, detect the actual selection form - selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (selForm === null) { - return { field: key, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` }; - } - } else { - // No type specified — close type dialog and report error - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - return { field: key, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` }; - } - } - const pr = await pickFromSelectionForm(selForm, key, info.value, formNum); - return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, error: pr.error, message: pr.message }; - } - - // First field: selection form is already open from the dblclick above - const firstKey = Object.keys(fields)[0]; - const firstInfo = pending.get(firstKey); - if (firstFieldSkipped) { - firstInfo.filled = true; - results.push({ field: firstKey, ok: true, method: 'skip', value: cellCoords.currentText }); - // Close the selection form that opened from the click - await page.keyboard.press('Escape'); - await waitForStable(formNum); - } else { - const pickResult = await directEditPick(directEditForm, firstKey, firstInfo); - firstInfo.filled = true; - results.push(pickResult); - } - - // Remaining fields: dblclick on each column cell individually - for (const [key, info] of pending) { - if (info.filled) continue; - // Find column for this key and dblclick on it - const nextCoords = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return null; - const head = grid.querySelector('.gridHead'); - const body = grid.querySelector('.gridBody'); - if (!head || !body) return null; - const headLine = head.querySelector('.gridLine') || head; - const cols = []; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = box.querySelector('.gridBoxText'); - const ci = box.getAttribute('colindex'); - cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); - }); - const kl = ${JSON.stringify(key.toLowerCase())}; - const klNoSpace = kl.replace(/[\\s\\-]+/g, ''); - let targetColindex = null; - const exact = cols.find(c => c.text === kl); - if (exact) targetColindex = exact.colindex; - else { - const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text) - || c.text.includes(klNoSpace) || klNoSpace.includes(c.text)); - if (inc) targetColindex = inc.colindex; - } - if (targetColindex == null) return null; - const rows = [...body.querySelectorAll('.gridLine')]; - if (${row} >= rows.length) return null; - const line = rows[${row}]; - const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); - if (!box) return null; - box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - const cell = box.querySelector('.gridBoxText') || box; - const r = cell.getBoundingClientRect(); - const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; - })()`); - if (!nextCoords) { - info.filled = true; - results.push({ field: key, error: 'column_not_found', message: `Column for "${key}" not found` }); - continue; - } - // Skip if cell already contains the desired value - if (nextCoords.currentText && info.value && - nextCoords.currentText.toLowerCase().includes(info.value.toLowerCase())) { - info.filled = true; - results.push({ field: key, ok: true, method: 'skip', value: nextCoords.currentText }); - continue; - } - await page.mouse.dblclick(nextCoords.x, nextCoords.y); - await page.waitForTimeout(300); - // Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator - const inInputAfterDblclick = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; - let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } - return false; - })()`); - // Also check if a selection form already appeared - let selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (selForm === null && inInputAfterDblclick) { - // Plain text/numeric field — fill via clipboard paste - await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] }); - await page.waitForTimeout(400); - // Dismiss EDD autocomplete if it appeared - const hasEdd = await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - return edd && edd.offsetWidth > 0; - })()`); - if (hasEdd) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(200); - } - info.filled = true; - results.push({ field: key, ok: true, method: 'paste' }); - continue; - } - // Poll for selection form (with F4 fallback if dblclick didn't open it) - if (selForm === null) { - for (let attempt = 0; attempt < 2 && selForm === null; attempt++) { - if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback - for (let sw = 0; sw < 6; sw++) { - await page.waitForTimeout(200); - selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (selForm !== null) break; - } - } - } - if (selForm === null) { - info.filled = true; - results.push({ field: key, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` }); - continue; - } - const pr = await directEditPick(selForm, key, info); - info.filled = true; - results.push(pr); - } - // Commit the edit: click on a different row (Escape cancels in tree grids). - // Find the first visible row that is NOT the edited row and click it. - const commitCoords = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return null; - const body = grid.querySelector('.gridBody'); - if (!body) return null; - const rows = [...body.querySelectorAll('.gridLine')]; - const otherIdx = ${row} === 0 ? 1 : 0; - const other = rows[otherIdx]; - if (!other) return null; - const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); - const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0]; - if (!box) return null; - const r = box.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (commitCoords) { - await page.mouse.click(commitCoords.x, commitCoords.y); - } else { - await page.keyboard.press('Escape'); - } - await waitForStable(formNum); - return results; - } - - if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`); - } else { - // No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent) - const editCheck = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName }; - let node = f; - while (node) { - if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true }; - node = node.parentElement; - } - return { inEdit: false, hint: 'input not inside grid' }; - })()`); - - if (!editCheck.inEdit) { - throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.'); - } - } - - // 4. Prepare pending fields for fuzzy matching - const pending = new Map(); - for (const [key, val] of Object.entries(fields)) { - if (val === null || val === undefined || val === '') { - pending.set(key, { value: '', type: null, filled: false }); - } else if (val && typeof val === 'object' && 'value' in val) { - const innerVal = val.value; - pending.set(key, { - value: innerVal === null || innerVal === undefined || innerVal === '' ? '' : String(innerVal), - type: val.type || null, filled: false - }); - } else { - pending.set(key, { value: String(val), type: null, filled: false }); - } - } - - const results = []; - const MAX_ITER = 40; - let prevCellId = null; - let nonInputCount = 0; - let firstCellId = null; - - for (let iter = 0; iter < MAX_ITER; iter++) { - // Read focused element (INPUT or TEXTAREA inside grid = editable cell) - const cell = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f) return { tag: 'none' }; - if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') { - const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })(); - if (inGrid) { - let headerText = ''; - let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement; - if (grid) { - const fr = f.getBoundingClientRect(); - const head = grid.querySelector('.gridHead'); - const hl = head?.querySelector('.gridLine') || head; - if (hl) for (const h of hl.children) { - if (h.offsetWidth === 0) continue; - const hr = h.getBoundingClientRect(); - if (fr.x >= hr.x && fr.x < hr.x + hr.width) { - const t = h.querySelector('.gridBoxText'); - headerText = (t || h).innerText?.trim() || ''; - break; - } - } - } - return { - tag: 'INPUT', id: f.id, - fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''), - headerText - }; - } - } - return { tag: f.tagName || 'none' }; - })()`); - - if (cell.tag !== 'INPUT' || !cell.fullName) { - // Not in an editable grid cell — Tab past (ERP has DIV focus between cells) - nonInputCount++; - // If only checkbox fields remain unfilled, stop Tab'ing to avoid creating extra rows - const onlyCheckboxLeft = [...pending.values()].every(p => p.filled || - ['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(p.value.toLowerCase().trim())); - if (nonInputCount > 3 || onlyCheckboxLeft) break; - await page.keyboard.press('Tab'); - await page.waitForTimeout(300); - continue; - } - nonInputCount = 0; - - // Track first cell to detect wrap-around (Tab looped back to row start) - if (firstCellId === null) firstCellId = cell.id; - else if (cell.id === firstCellId) break; // wrapped around — all cells visited - - // Stuck detection: same cell twice in a row → force Tab - if (cell.id === prevCellId) { - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - prevCellId = null; - continue; - } - prevCellId = cell.id; - - // Fuzzy match cell name to user field: exact → suffix → includes → no-space includes - const cellLower = cell.fullName.toLowerCase(); - let matchedKey = null; - for (const [key, info] of pending) { - if (info.filled) continue; - const kl = key.toLowerCase(); - if (cellLower === kl || cellLower.endsWith(kl) || cellLower.includes(kl)) { - matchedKey = key; - break; - } - // CamelCase cell names have no spaces/dashes — try matching without spaces and dashes - const klNoSpace = kl.replace(/[\s\-]+/g, ''); - if (klNoSpace && (cellLower.endsWith(klNoSpace) || cellLower.includes(klNoSpace))) { - matchedKey = key; - break; - } - } - - // Fallback: match by column header text (handles metadata typos in cell id) - if (!matchedKey && cell.headerText) { - const htLower = cell.headerText.toLowerCase(); - for (const [key, info] of pending) { - if (info.filled) continue; - const kl = key.toLowerCase(); - if (htLower === kl || htLower.endsWith(kl) || htLower.includes(kl)) { - matchedKey = key; - break; - } - } - } - - if (!matchedKey) { - // Skip this cell - await page.keyboard.press('Tab'); - await page.waitForTimeout(300); - continue; - } - - const info = pending.get(matchedKey); - const text = info.value; - - // Clear cell if value is empty (Shift+F4 = native 1C clear) - if (text === '') { - await page.keyboard.press('Shift+F4'); - await page.waitForTimeout(300); - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'clear', value: '' }); - if ([...pending.values()].every(p => p.filled)) break; - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - continue; - } - - // If user specified a type, always clear and use type selection flow - if (info.type) { - await page.keyboard.press('Shift+F4'); // Clear cell to reset any inherited type - await page.waitForTimeout(300); - await page.keyboard.press('F4'); - // Poll for type dialog form to appear - let typeForm = null; - for (let tw = 0; tw < 6; tw++) { - await page.waitForTimeout(200); - typeForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (typeForm !== null) break; - } - if (typeForm !== null && await isTypeDialog(typeForm)) { - await pickFromTypeDialog(typeForm, info.type); - await waitForStable(typeForm); - // After type selection, check if a selection form opened (ref types) - const selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (selForm === null) { - // Primitive type — poll for calculator/calendar popup or settle on INPUT - let hasPopup = null; - for (let pw = 0; pw < 5; pw++) { - await page.waitForTimeout(200); - hasPopup = await page.evaluate(`(() => { - const calc = document.querySelector('.calculate'); - if (calc && calc.offsetWidth > 0) return 'calculator'; - const cal = document.querySelector('.frameCalendar'); - if (cal && cal.offsetWidth > 0) return 'calendar'; - return null; - })()`); - if (hasPopup) break; - } - if (hasPopup) { - await page.keyboard.press('Escape'); - // Poll for popup to disappear - for (let dw = 0; dw < 4; dw++) { - await page.waitForTimeout(150); - const gone = await page.evaluate(`(() => { - const calc = document.querySelector('.calculate'); - if (calc && calc.offsetWidth > 0) return false; - const cal = document.querySelector('.frameCalendar'); - if (cal && cal.offsetWidth > 0) return false; - return true; - })()`); - if (gone) break; - } - } - // Ensure we are in an editable INPUT for this cell - const inInput = await page.evaluate(`(() => { - const f = document.activeElement; - return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); - })()`); - if (!inInput) { - const cellRect = await page.evaluate(`(() => { - const el = document.getElementById(${JSON.stringify(cell.id)}); - if (!el) return null; - const r = el.getBoundingClientRect(); - return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; - })()`); - if (cellRect) { - await page.mouse.dblclick(cellRect.x, cellRect.y); - // Poll for INPUT focus - for (let fw = 0; fw < 4; fw++) { - await page.waitForTimeout(150); - const ok = await page.evaluate(`(() => { - const f = document.activeElement; - return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); - })()`); - if (ok) break; - } - } - } - await pasteText(text, { confirm: ['Control+a', 'Control+v'] }); - await page.waitForTimeout(400); - await page.keyboard.press('Tab'); - await page.waitForTimeout(300); - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type }); - continue; - } - const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); - info.filled = true; - results.push(pickResult.ok - ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type } - : { field: matchedKey, cell: cell.fullName, - error: pickResult.error, message: pickResult.message }); - continue; - } - // F4 opened something but not a type dialog — close and report - if (typeForm !== null) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - } - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, - error: 'type_dialog_failed', - message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` }); - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - continue; - } - - // === Fill this cell: clipboard paste (trusted event) === - await page.keyboard.press('Control+A'); - await pasteText(text); - await page.waitForTimeout(1500); - - // Check if paste was rejected (composite-type cell blocks text input until type is selected) - const inputAfterPaste = await page.evaluate(`document.activeElement?.value || ''`); - if (!inputAfterPaste && text) { - // No type specified — can't fill this composite-type cell - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, - error: 'type_required', - message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` }); - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - continue; - } - - // Check for EDD autocomplete (indicates reference field) - const eddItems = await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return null; - return [...edd.querySelectorAll('.eddText')] - .filter(el => el.offsetWidth > 0) - .map(el => el.innerText?.trim() || ''); - })()`); - - if (eddItems && eddItems.length > 0) { - // Reference field with autocomplete — click best match - // Filter out reference field "create" actions (Создать элемент, Создать группу, Создать: ...) - // but keep standalone enum values like "Создать" (no space/colon after) - const realItems = eddItems.filter(i => !/^Создать[\s:]/.test(i)); - - if (realItems.length > 0) { - const tgt = normYo(text.toLowerCase()); - let pick = realItems.find(i => - normYo(i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === tgt); - if (!pick) pick = realItems.find(i => normYo(i.toLowerCase()).includes(tgt)); - if (!pick) pick = realItems[0]; - - // Click EDD item via dispatchEvent (bypasses div.surface overlay) - const pickLower = pick.toLowerCase(); - await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd) return; - for (const el of edd.querySelectorAll('.eddText')) { - if (el.offsetWidth === 0) continue; - if (el.innerText.trim().toLowerCase().includes(${JSON.stringify(pickLower)})) { - const r = el.getBoundingClientRect(); - const opts = { bubbles:true, cancelable:true, - clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - el.dispatchEvent(new MouseEvent('mousedown', opts)); - el.dispatchEvent(new MouseEvent('mouseup', opts)); - el.dispatchEvent(new MouseEvent('click', opts)); - return; - } - } - })()`); - await waitForStable(); - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, ok: true, - method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') }); - } else { - // Only "Создать:" items — value not found in autocomplete - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, - error: 'not_found', message: `No match for "${text}"` }); - } - - // Done? If so, don't Tab (avoids creating a new row after last cell) - if ([...pending.values()].every(p => p.filled)) break; - // Tab to move to next cell - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - continue; - } - - // No EDD — press Tab to commit the value - await page.keyboard.press('Tab'); - await page.waitForTimeout(1000); - - // Check for "нет в списке" cloud popup (reference field, value not found) - const notInList = await page.evaluate(`(() => { - for (const el of document.querySelectorAll('div')) { - if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; - const s = getComputedStyle(el); - if (s.position !== 'absolute' && s.position !== 'fixed') continue; - if ((parseInt(s.zIndex) || 0) < 100) continue; - if ((el.innerText || '').includes('нет в списке')) return true; - } - return false; - })()`); - - if (notInList) { - // Cloud has "Показать все" link — try to open selection form via it - const clickedShowAll = await page.evaluate(`(() => { - for (const el of document.querySelectorAll('div')) { - if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; - const s = getComputedStyle(el); - if (s.position !== 'absolute' && s.position !== 'fixed') continue; - if ((parseInt(s.zIndex) || 0) < 100) continue; - if (!(el.innerText || '').includes('нет в списке')) continue; - // Found the cloud — look for "Показать все" hyperlink inside - const links = [...el.querySelectorAll('a, span, div')] - .filter(e => e.offsetWidth > 0 && e.children.length === 0); - const showAll = links.find(e => { - const t = (e.innerText?.trim() || '').toLowerCase(); - return t === 'показать все' || t === 'show all'; - }); - if (showAll) { - const r = showAll.getBoundingClientRect(); - const opts = { bubbles:true, cancelable:true, - clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - showAll.dispatchEvent(new MouseEvent('mousedown', opts)); - showAll.dispatchEvent(new MouseEvent('mouseup', opts)); - showAll.dispatchEvent(new MouseEvent('click', opts)); - return true; - } - return false; - } - return false; - })()`); - - if (clickedShowAll) { - await waitForStable(formNum); - // Check if selection form opened - const selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { - if (el.offsetWidth === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - - if (selForm !== null) { - const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); - info.filled = true; - if (pickResult.ok) { - results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }); - continue; - } - // Not found in selection form — fall through to clear + skip - results.push({ field: matchedKey, cell: cell.fullName, - error: pickResult.error, message: pickResult.message }); - } else { - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, - error: 'not_found', message: `Value "${text}" not in list` }); - } - } else { - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, - error: 'not_found', message: `Value "${text}" not in list` }); - } - - // 1C won't let us Tab away from an invalid ref value. - // Must clear the field first, then Tab to move on. - // Escape dismisses the cloud; Ctrl+A + Delete clears the text. - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await page.keyboard.press('Control+A'); - await page.keyboard.press('Delete'); - await page.waitForTimeout(300); - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - continue; - } - - // Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs) - const newForm = await helperDetectNewForm(formNum); - - if (newForm !== null) { - if (await isTypeDialog(newForm)) { - // Composite-type cell — need type to proceed - if (info.type) { - await pickFromTypeDialog(newForm, info.type); - await waitForStable(newForm); - // After type selection, the actual selection form should open - const selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); - if (selForm === null) { - // Primitive type — poll for calculator/calendar popup or settle on INPUT - let hasPopup = null; - for (let pw = 0; pw < 5; pw++) { - await page.waitForTimeout(200); - hasPopup = await page.evaluate(`(() => { - const calc = document.querySelector('.calculate'); - if (calc && calc.offsetWidth > 0) return 'calculator'; - const cal = document.querySelector('.frameCalendar'); - if (cal && cal.offsetWidth > 0) return 'calendar'; - return null; - })()`); - if (hasPopup) break; - } - if (hasPopup) { - await page.keyboard.press('Escape'); - for (let dw = 0; dw < 4; dw++) { - await page.waitForTimeout(150); - const gone = await page.evaluate(`(() => { - const calc = document.querySelector('.calculate'); - if (calc && calc.offsetWidth > 0) return false; - const cal = document.querySelector('.frameCalendar'); - if (cal && cal.offsetWidth > 0) return false; - return true; - })()`); - if (gone) break; - } - } - const inInput = await page.evaluate(`(() => { - const f = document.activeElement; - return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); - })()`); - if (!inInput) { - const cellRect = await page.evaluate(`(() => { - const el = document.getElementById(${JSON.stringify(cell.id)}); - if (!el) return null; - const r = el.getBoundingClientRect(); - return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; - })()`); - if (cellRect) { - await page.mouse.dblclick(cellRect.x, cellRect.y); - for (let fw = 0; fw < 4; fw++) { - await page.waitForTimeout(150); - const ok = await page.evaluate(`(() => { - const f = document.activeElement; - return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); - })()`); - if (ok) break; - } - } - } - await pasteText(text, { confirm: ['Control+a', 'Control+v'] }); - await page.waitForTimeout(400); - await page.keyboard.press('Tab'); - await page.waitForTimeout(300); - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type }); - continue; - } - const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); - info.filled = true; - results.push(pickResult.ok - ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type } - : { field: matchedKey, cell: cell.fullName, - error: pickResult.error, message: pickResult.message }); - continue; - } else { - // No type specified — close dialog, clear cell, report error - await page.keyboard.press('Escape'); - await page.waitForTimeout(300); - await page.keyboard.press('Control+A'); - await page.keyboard.press('Delete'); - await page.waitForTimeout(300); - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, - error: 'type_required', - message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` }); - continue; - } - } - // Not a type dialog — normal selection form - const pickResult = await pickFromSelectionForm(newForm, matchedKey, text, formNum); - info.filled = true; - results.push(pickResult.ok - ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' } - : { field: matchedKey, cell: cell.fullName, - error: pickResult.error, message: pickResult.message }); - continue; - } - - // Plain field — value committed via Tab - info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'direct' }); - - // All done? - if ([...pending.values()].every(p => p.filled)) break; - // Tab already pressed — we're on next cell - } - - // Commit the new row: click on the grid header to exit edit mode. - // Clicking a different data row would re-enter edit mode on that row. - // Without this commit click, the row stays in "uncommitted add" state - // and a subsequent Escape (e.g. from closeForm) would cancel the entire row. - const commitTarget = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return null; - const head = grid.querySelector('.gridHead'); - if (head) { - const r = head.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - } - return null; - })()`); - if (commitTarget) { - await page.mouse.click(commitTarget.x, commitTarget.y); - await page.waitForTimeout(500); - } else { - // Fallback: Tab out of the last cell to commit the row - await page.keyboard.press('Tab'); - await page.waitForTimeout(500); - } - - // Dismiss any leftover error modals - const err = await checkForErrors(); - if (err?.modal) { - try { - const btn = await page.$('a.press.pressDefault'); - if (btn) { await btn.click(); await page.waitForTimeout(500); } - } catch { /* OK */ } - } - - const notFilled = [...pending].filter(([_, info]) => !info.filled).map(([key]) => key); - - // Retry unfilled checkbox fields via direct click (Tab skips checkbox cells) - if (notFilled.length > 0) { - const checkboxFields = {}; - for (const key of notFilled) { - const val = String(pending.get(key).value).toLowerCase().trim(); - if (['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(val)) { - checkboxFields[key] = pending.get(key).value; - } - } - if (Object.keys(checkboxFields).length > 0) { - // Use row index: addedRowIdx (from add mode) or fallback to selected row - const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return -1; - const body = grid.querySelector('.gridBody'); - if (!body) return -1; - const lines = [...body.querySelectorAll('.gridLine')]; - const sel = lines.findIndex(l => l.classList.contains('selected')); - return sel >= 0 ? sel : lines.length - 1; - })()`) - ); - if (currentRow >= 0) { - const more = await fillTableRow(checkboxFields, { row: currentRow, table }); - if (Array.isArray(more)) { - results.push(...more); - } else if (more?.filled) { - results.push(...more.filled); - } - for (const key of Object.keys(checkboxFields)) { - const idx = notFilled.indexOf(key); - if (idx >= 0) notFilled.splice(idx, 1); - } - } - } - } - - const formData = await getFormState(); - const result = { filled: results }; - if (notFilled.length > 0) result.notFilled = notFilled; - result.form = formData; - return result; - - } catch (e) { - if (e.message.startsWith('fillTableRow:')) throw e; - throw new Error(`fillTableRow: ${e.message}`); - } -} - -/** - * Delete a row from the current table part. - * Single click to select the row, then Delete key to remove it. - * - * @param {number} row - 0-based row index to delete - * @param {Object} [options] - * @param {string} [options.tab] - Switch to this form tab before operating - * @returns {{ deleted, rowsBefore, rowsAfter, form }} - */ -export async function deleteTableRow(row, { tab, table } = {}) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('deleteTableRow: no form found'); - - // Pre-resolve grid when table is specified - let gridSelector; - if (table) { - const resolved = await page.evaluate(resolveGridScript(formNum, table)); - if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); - gridSelector = resolved.gridSelector; - } - - // 1. Switch tab if requested - if (tab) { - await clickElement(tab); - await page.waitForTimeout(500); - } - - // 2. Find the target row and click to select it - const cellCoords = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return { error: 'no_grid' }; - const body = grid.querySelector('.gridBody'); - if (!body) return { error: 'no_grid_body' }; - const rows = [...body.querySelectorAll('.gridLine')]; - if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; - const line = rows[${row}]; - // Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes - const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); - // Skip first column (row number / checkbox) — pick second visible box - const box = boxes.length > 1 ? boxes[1] : boxes[0]; - if (!box) return { error: 'no_cell' }; - const cell = box.querySelector('.gridBoxText') || box; - const r = cell.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length }; - })()`); - - if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); - - const rowsBefore = cellCoords.total; - - // Single click to select the row - await page.mouse.click(cellCoords.x, cellCoords.y); - await page.waitForTimeout(300); - - // 3. Press Delete to remove the row - await page.keyboard.press('Delete'); - await waitForStable(); - - // 4. Count rows after deletion - const rowsAfter = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return 0; - const body = grid.querySelector('.gridBody'); - return body ? body.querySelectorAll('.gridLine').length : 0; - })()`); - - const formData = await getFormState(); - return { deleted: row, rowsBefore, rowsAfter, form: formData }; -} +// ============================================================ +// fillTableRow / deleteTableRow — extracted to table/{row-fill,grid}.mjs +// ============================================================ +export { fillTableRow } from './table/row-fill.mjs'; +export { deleteTableRow } from './table/grid.mjs'; // ============================================================ // List filters — extracted to table/filter.mjs diff --git a/.claude/skills/web-test/scripts/table/grid.mjs b/.claude/skills/web-test/scripts/table/grid.mjs index 1cdb5dd1..5b77e68f 100644 --- a/.claude/skills/web-test/scripts/table/grid.mjs +++ b/.claude/skills/web-test/scripts/table/grid.mjs @@ -7,6 +7,11 @@ import { page, ensureConnected } from '../core/state.mjs'; import { detectFormScript, readTableScript, resolveGridScript } from '../dom.mjs'; +import { dismissPendingErrors } from '../core/errors.mjs'; +import { waitForStable } from '../core/wait.mjs'; +import { clickElement } from '../core/click.mjs'; +// getFormState lives in browser.mjs. +import { getFormState } from '../browser.mjs'; /** Read structured table data with pagination. Returns columns, rows, total count. */ export async function readTable({ maxRows = 20, offset = 0, table } = {}) { @@ -21,3 +26,79 @@ export async function readTable({ maxRows = 20, offset = 0, table } = {}) { } return await page.evaluate(readTableScript(formNum, { maxRows, offset, gridSelector })); } + +/** + * Delete a row from the current table part. + * Single click to select the row, then Delete key to remove it. + * + * @param {number} row - 0-based row index to delete + * @param {Object} [options] + * @param {string} [options.tab] - Switch to this form tab before operating + * @returns {{ deleted, rowsBefore, rowsAfter, form }} + */ +export async function deleteTableRow(row, { tab, table } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('deleteTableRow: no form found'); + + // Pre-resolve grid when table is specified + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`deleteTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + + // 1. Switch tab if requested + if (tab) { + await clickElement(tab); + await page.waitForTimeout(500); + } + + // 2. Find the target row and click to select it + const cellCoords = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return { error: 'no_grid' }; + const body = grid.querySelector('.gridBody'); + if (!body) return { error: 'no_grid_body' }; + const rows = [...body.querySelectorAll('.gridLine')]; + if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; + const line = rows[${row}]; + // Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes + const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + // Skip first column (row number / checkbox) — pick second visible box + const box = boxes.length > 1 ? boxes[1] : boxes[0]; + if (!box) return { error: 'no_cell' }; + const cell = box.querySelector('.gridBoxText') || box; + const r = cell.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length }; + })()`); + + if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); + + const rowsBefore = cellCoords.total; + + // Single click to select the row + await page.mouse.click(cellCoords.x, cellCoords.y); + await page.waitForTimeout(300); + + // 3. Press Delete to remove the row + await page.keyboard.press('Delete'); + await waitForStable(); + + // 4. Count rows after deletion + const rowsAfter = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return 0; + const body = grid.querySelector('.gridBody'); + return body ? body.querySelectorAll('.gridLine').length : 0; + })()`); + + const formData = await getFormState(); + return { deleted: row, rowsBefore, rowsAfter, form: formData }; +} diff --git a/.claude/skills/web-test/scripts/table/row-fill.mjs b/.claude/skills/web-test/scripts/table/row-fill.mjs new file mode 100644 index 00000000..57361a28 --- /dev/null +++ b/.claude/skills/web-test/scripts/table/row-fill.mjs @@ -0,0 +1,1235 @@ +// web-test table/row-fill v1.16 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { + page, ensureConnected, normYo, highlightMode, ACTION_WAIT, +} from '../core/state.mjs'; +import { + detectFormScript, resolveGridScript, readTableScript, +} from '../dom.mjs'; +import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; +import { waitForStable, waitForCondition, startNetworkMonitor } from '../core/wait.mjs'; +import { highlight, unhighlight } from '../recording/highlight.mjs'; +import { + safeClick, findFieldInputId, + detectNewForm as helperDetectNewForm, +} from '../core/helpers.mjs'; +import { clickElement } from '../core/click.mjs'; +import { + pickFromSelectionForm, isTypeDialog, pickFromTypeDialog, + fillReferenceField, selectValue, +} from '../forms/select-value.mjs'; +// pasteText + getFormState still in browser.mjs (cycle). +import { pasteText, getFormState } from '../browser.mjs'; + +/** + * Fill cells in the current table row via Tab navigation. + * Grid cells are only accessible sequentially (Tab) — no random access. + * + * After "Добавить", 1C enters inline edit mode on the first cell. + * All inputs in the row are created hidden (offsetWidth=0); only the active one is visible. + * Tab moves through cells in a fixed order determined by the form configuration. + * + * @param {Object} fields - { fieldName: value } map (fuzzy match: "Номенклатура" → "ТоварыНоменклатура") + * @param {Object} [options] + * @param {string} [options.tab] - Switch to this form tab before operating + * @param {boolean} [options.add] - Click "Добавить" to create a new row first + * @returns {{ filled[], notFilled[]?, form }} + */ +export async function fillTableRow(fields, { tab, add, row, table } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('fillTableRow: no form found'); + + // Pre-resolve grid when table is specified + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`fillTableRow: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + + try { + // 1. Switch tab if requested + if (tab) { + await clickElement(tab); + } + + // 2. Add new row if requested + let addedRowIdx = -1; + if (add) { + // Count rows before add — new row will be appended at this index + addedRowIdx = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + const body = grid?.querySelector('.gridBody'); + return body ? body.querySelectorAll('.gridLine').length : 0; + })()`); + await clickElement('Добавить', { table }); + // Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait + for (let aw = 0; aw < 6; aw++) { + await page.waitForTimeout(150); + const ready = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; + let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } + return false; + })()`); + if (ready) break; + } + } + + // 2b. Enter edit mode on existing row by dblclick + if (row != null) { + // Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right + const sortedKeys = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return null; + const head = grid.querySelector('.gridHead'); + if (!head) return null; + const headLine = head.querySelector('.gridLine') || head; + const cols = []; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase(); + const ci = parseInt(box.getAttribute('colindex') || '-1'); + if (t) cols.push({ text: t, colindex: ci }); + }); + const keys = ${JSON.stringify(Object.keys(fields).map(k => k.toLowerCase()))}; + const mapped = keys.map(k => { + const exact = cols.find(c => c.text === k); + if (exact) return { key: k, colindex: exact.colindex }; + const inc = cols.find(c => c.text.includes(k) || k.includes(c.text)); + return { key: k, colindex: inc ? inc.colindex : 999 }; + }); + mapped.sort((a, b) => a.colindex - b.colindex); + return mapped.map(m => m.key); + })()`); + if (sortedKeys) { + // Rebuild fields in sorted order + const sortedFields = {}; + for (const kl of sortedKeys) { + const origKey = Object.keys(fields).find(k => k.toLowerCase() === kl); + if (origKey) sortedFields[origKey] = fields[origKey]; + } + // Add any keys not matched in header (preserve original order for those) + for (const k of Object.keys(fields)) { + if (!(k in sortedFields)) sortedFields[k] = fields[k]; + } + fields = sortedFields; + } + + const fieldKeys = JSON.stringify(Object.keys(fields).map(k => k.toLowerCase())); + const cellCoords = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return { error: 'no_grid' }; + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + if (!head || !body) return { error: 'no_grid_body' }; + + // Read column headers to find target colindex + const headLine = head.querySelector('.gridLine') || head; + const cols = []; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const t = box.querySelector('.gridBoxText'); + const ci = box.getAttribute('colindex'); + cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); + }); + + const keys = ${fieldKeys}; + let targetColindex = null; + for (const key of keys) { + const exact = cols.find(c => c.text === key); + if (exact) { targetColindex = exact.colindex; break; } + const inc = cols.find(c => c.text.includes(key) || key.includes(c.text)); + if (inc) { targetColindex = inc.colindex; break; } + } + + const rows = [...body.querySelectorAll('.gridLine')]; + if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; + const line = rows[${row}]; + + // Find body cell by colindex (reliable across merged headers) + let box = null; + if (targetColindex != null) { + box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); + } + // Fallback: second visible box (skip checkbox/N column) + if (!box) { + const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + box = boxes.length > 1 ? boxes[1] : boxes[0]; + } + if (!box) return { error: 'no_cell' }; + // Scroll into view if off-screen + box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + const cell = box.querySelector('.gridBoxText') || box; + const r = cell.getBoundingClientRect(); + const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; + })()`); + + if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); + + // Skip if cell already contains the desired value (single-field optimization) + const firstKey0 = Object.keys(fields)[0]; + const rawFirstVal = fields[firstKey0]; + const firstVal0 = rawFirstVal === null || rawFirstVal === undefined || rawFirstVal === '' + ? '' : (typeof rawFirstVal === 'object' ? rawFirstVal.value : String(rawFirstVal)); + let firstFieldSkipped = false; + if (cellCoords.currentText && firstVal0 && + cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) { + firstFieldSkipped = true; + if (Object.keys(fields).length === 1) { + return [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }]; + } + } + + // Click first (tree grids enter edit on single click; dblclick toggles expand/collapse). + // Then escalate: dblclick → F4 if needed. + await page.mouse.click(cellCoords.x, cellCoords.y); + + // Clear cell via Shift+F4 if value is empty + if (firstVal0 === '') { + await page.waitForTimeout(500); + // Check if click opened a selection form — close it first + let openedForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (openedForm !== null) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } else { + // No form opened — need to enter edit mode first (dblclick), then close any form that opens + await page.mouse.dblclick(cellCoords.x, cellCoords.y); + await page.waitForTimeout(500); + openedForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (openedForm !== null) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + } + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(300); + const results = [{ field: firstKey0, ok: true, method: 'clear', value: '' }]; + // If more fields remain, process them on the same row + const remaining = { ...fields }; + delete remaining[firstKey0]; + if (Object.keys(remaining).length > 0) { + const more = await fillTableRow(remaining, { row, table }); + if (Array.isArray(more)) results.push(...more); + else if (more?.filled) results.push(...more.filled); + } + const formData = await getFormState(); + return { filled: results, form: formData }; + } + + // Check if clicked cell is a checkbox (toggle-on-click, no edit mode) + const checkboxInfo = await page.evaluate(`(() => { + const el = document.elementFromPoint(${cellCoords.x}, ${cellCoords.y}); + const cell = el?.closest('.gridBox'); + if (!cell) return null; + const chk = cell.querySelector('.checkbox'); + if (!chk) return null; + const r = chk.getBoundingClientRect(); + return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }; + })()`); + if (checkboxInfo !== null) { + // Checkbox cell found — click directly on the checkbox icon (not cell center) + const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim()); + if (checkboxInfo.checked !== desired) { + await page.mouse.click(checkboxInfo.x, checkboxInfo.y); + await page.waitForTimeout(300); + } + const results = [{ field: firstKey0, ok: true, method: 'toggle', value: desired }]; + await waitForStable(formNum); + // If more fields remain, process them on the same row + const remaining = { ...fields }; + delete remaining[firstKey0]; + if (Object.keys(remaining).length > 0) { + const more = await fillTableRow(remaining, { row, table }); + results.push(...more); + } + return results; + } + + let inEdit = false; + let directEditForm = null; + for (let dw = 0; dw < 4; dw++) { + await page.waitForTimeout(150); + inEdit = await page.evaluate(`(() => { + const f = document.activeElement; + return f && f.tagName === 'INPUT'; + })()`); + if (inEdit) break; + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } + // Click didn't enter edit — try dblclick (works for flat grids) + if (!inEdit && directEditForm === null) { + await page.mouse.dblclick(cellCoords.x, cellCoords.y); + for (let dw = 0; dw < 4; dw++) { + await page.waitForTimeout(150); + inEdit = await page.evaluate(`(() => { + const f = document.activeElement; + return f && f.tagName === 'INPUT'; + })()`); + if (inEdit) break; + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } + } + // Still nothing — try F4 (opens selection for direct-edit cells) + if (!inEdit && directEditForm === null) { + await page.keyboard.press('F4'); + for (let fw = 0; fw < 8; fw++) { + await page.waitForTimeout(200); + inEdit = await page.evaluate(`(() => { + const f = document.activeElement; + return f && f.tagName === 'INPUT'; + })()`); + if (inEdit) break; + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } + } + + // When click entered INPUT mode but no selection form yet — try F4 only for tree grids + // (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop) + if (inEdit && directEditForm === null) { + const isTreeGrid = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + return grid ? !!grid.querySelector('.gridBoxTree') : false; + })()`); + if (isTreeGrid) { + await page.keyboard.press('F4'); + for (let fw = 0; fw < 8; fw++) { + await page.waitForTimeout(200); + directEditForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (directEditForm !== null) break; + } + // If F4 didn't open a selection form, fall through to Tab loop + } + } + + // Direct-edit mode: selection form opened on dblclick/F4 (e.g. tree grid with immediate editing). + // Handle each field by picking from selection form, then dblclick next cell. + if (directEditForm !== null) { + const pending = new Map(); + for (const [key, val] of Object.entries(fields)) { + if (val && typeof val === 'object' && 'value' in val) { + pending.set(key, { value: String(val.value), type: val.type || null, filled: false }); + } else { + pending.set(key, { value: String(val), type: null, filled: false }); + } + } + const results = []; + + // Helper: handle type dialog + pick from selection form + async function directEditPick(openedForm, key, info) { + let selForm = openedForm; + // Check if opened form is a type selection dialog (composite type field) + if (await isTypeDialog(selForm)) { + if (info.type) { + await pickFromTypeDialog(selForm, info.type); + await waitForStable(selForm); + // After type selection, detect the actual selection form + selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm === null) { + return { field: key, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` }; + } + } else { + // No type specified — close type dialog and report error + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + return { field: key, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` }; + } + } + const pr = await pickFromSelectionForm(selForm, key, info.value, formNum); + return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, error: pr.error, message: pr.message }; + } + + // First field: selection form is already open from the dblclick above + const firstKey = Object.keys(fields)[0]; + const firstInfo = pending.get(firstKey); + if (firstFieldSkipped) { + firstInfo.filled = true; + results.push({ field: firstKey, ok: true, method: 'skip', value: cellCoords.currentText }); + // Close the selection form that opened from the click + await page.keyboard.press('Escape'); + await waitForStable(formNum); + } else { + const pickResult = await directEditPick(directEditForm, firstKey, firstInfo); + firstInfo.filled = true; + results.push(pickResult); + } + + // Remaining fields: dblclick on each column cell individually + for (const [key, info] of pending) { + if (info.filled) continue; + // Find column for this key and dblclick on it + const nextCoords = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return null; + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + if (!head || !body) return null; + const headLine = head.querySelector('.gridLine') || head; + const cols = []; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const t = box.querySelector('.gridBoxText'); + const ci = box.getAttribute('colindex'); + cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); + }); + const kl = ${JSON.stringify(key.toLowerCase())}; + const klNoSpace = kl.replace(/[\\s\\-]+/g, ''); + let targetColindex = null; + const exact = cols.find(c => c.text === kl); + if (exact) targetColindex = exact.colindex; + else { + const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text) + || c.text.includes(klNoSpace) || klNoSpace.includes(c.text)); + if (inc) targetColindex = inc.colindex; + } + if (targetColindex == null) return null; + const rows = [...body.querySelectorAll('.gridLine')]; + if (${row} >= rows.length) return null; + const line = rows[${row}]; + const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); + if (!box) return null; + box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + const cell = box.querySelector('.gridBoxText') || box; + const r = cell.getBoundingClientRect(); + const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; + })()`); + if (!nextCoords) { + info.filled = true; + results.push({ field: key, error: 'column_not_found', message: `Column for "${key}" not found` }); + continue; + } + // Skip if cell already contains the desired value + if (nextCoords.currentText && info.value && + nextCoords.currentText.toLowerCase().includes(info.value.toLowerCase())) { + info.filled = true; + results.push({ field: key, ok: true, method: 'skip', value: nextCoords.currentText }); + continue; + } + await page.mouse.dblclick(nextCoords.x, nextCoords.y); + await page.waitForTimeout(300); + // Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator + const inInputAfterDblclick = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; + let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } + return false; + })()`); + // Also check if a selection form already appeared + let selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm === null && inInputAfterDblclick) { + // Plain text/numeric field — fill via clipboard paste + await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] }); + await page.waitForTimeout(400); + // Dismiss EDD autocomplete if it appeared + const hasEdd = await page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + return edd && edd.offsetWidth > 0; + })()`); + if (hasEdd) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + } + info.filled = true; + results.push({ field: key, ok: true, method: 'paste' }); + continue; + } + // Poll for selection form (with F4 fallback if dblclick didn't open it) + if (selForm === null) { + for (let attempt = 0; attempt < 2 && selForm === null; attempt++) { + if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback + for (let sw = 0; sw < 6; sw++) { + await page.waitForTimeout(200); + selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm !== null) break; + } + } + } + if (selForm === null) { + info.filled = true; + results.push({ field: key, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` }); + continue; + } + const pr = await directEditPick(selForm, key, info); + info.filled = true; + results.push(pr); + } + // Commit the edit: click on a different row (Escape cancels in tree grids). + // Find the first visible row that is NOT the edited row and click it. + const commitCoords = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const rows = [...body.querySelectorAll('.gridLine')]; + const otherIdx = ${row} === 0 ? 1 : 0; + const other = rows[otherIdx]; + if (!other) return null; + const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0]; + if (!box) return null; + const r = box.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`); + if (commitCoords) { + await page.mouse.click(commitCoords.x, commitCoords.y); + } else { + await page.keyboard.press('Escape'); + } + await waitForStable(formNum); + return results; + } + + if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`); + } else { + // No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent) + const editCheck = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName }; + let node = f; + while (node) { + if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true }; + node = node.parentElement; + } + return { inEdit: false, hint: 'input not inside grid' }; + })()`); + + if (!editCheck.inEdit) { + throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.'); + } + } + + // 4. Prepare pending fields for fuzzy matching + const pending = new Map(); + for (const [key, val] of Object.entries(fields)) { + if (val === null || val === undefined || val === '') { + pending.set(key, { value: '', type: null, filled: false }); + } else if (val && typeof val === 'object' && 'value' in val) { + const innerVal = val.value; + pending.set(key, { + value: innerVal === null || innerVal === undefined || innerVal === '' ? '' : String(innerVal), + type: val.type || null, filled: false + }); + } else { + pending.set(key, { value: String(val), type: null, filled: false }); + } + } + + const results = []; + const MAX_ITER = 40; + let prevCellId = null; + let nonInputCount = 0; + let firstCellId = null; + + for (let iter = 0; iter < MAX_ITER; iter++) { + // Read focused element (INPUT or TEXTAREA inside grid = editable cell) + const cell = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f) return { tag: 'none' }; + if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') { + const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })(); + if (inGrid) { + let headerText = ''; + let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement; + if (grid) { + const fr = f.getBoundingClientRect(); + const head = grid.querySelector('.gridHead'); + const hl = head?.querySelector('.gridLine') || head; + if (hl) for (const h of hl.children) { + if (h.offsetWidth === 0) continue; + const hr = h.getBoundingClientRect(); + if (fr.x >= hr.x && fr.x < hr.x + hr.width) { + const t = h.querySelector('.gridBoxText'); + headerText = (t || h).innerText?.trim() || ''; + break; + } + } + } + return { + tag: 'INPUT', id: f.id, + fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''), + headerText + }; + } + } + return { tag: f.tagName || 'none' }; + })()`); + + if (cell.tag !== 'INPUT' || !cell.fullName) { + // Not in an editable grid cell — Tab past (ERP has DIV focus between cells) + nonInputCount++; + // If only checkbox fields remain unfilled, stop Tab'ing to avoid creating extra rows + const onlyCheckboxLeft = [...pending.values()].every(p => p.filled || + ['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(p.value.toLowerCase().trim())); + if (nonInputCount > 3 || onlyCheckboxLeft) break; + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + continue; + } + nonInputCount = 0; + + // Track first cell to detect wrap-around (Tab looped back to row start) + if (firstCellId === null) firstCellId = cell.id; + else if (cell.id === firstCellId) break; // wrapped around — all cells visited + + // Stuck detection: same cell twice in a row → force Tab + if (cell.id === prevCellId) { + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + prevCellId = null; + continue; + } + prevCellId = cell.id; + + // Fuzzy match cell name to user field: exact → suffix → includes → no-space includes + const cellLower = cell.fullName.toLowerCase(); + let matchedKey = null; + for (const [key, info] of pending) { + if (info.filled) continue; + const kl = key.toLowerCase(); + if (cellLower === kl || cellLower.endsWith(kl) || cellLower.includes(kl)) { + matchedKey = key; + break; + } + // CamelCase cell names have no spaces/dashes — try matching without spaces and dashes + const klNoSpace = kl.replace(/[\s\-]+/g, ''); + if (klNoSpace && (cellLower.endsWith(klNoSpace) || cellLower.includes(klNoSpace))) { + matchedKey = key; + break; + } + } + + // Fallback: match by column header text (handles metadata typos in cell id) + if (!matchedKey && cell.headerText) { + const htLower = cell.headerText.toLowerCase(); + for (const [key, info] of pending) { + if (info.filled) continue; + const kl = key.toLowerCase(); + if (htLower === kl || htLower.endsWith(kl) || htLower.includes(kl)) { + matchedKey = key; + break; + } + } + } + + if (!matchedKey) { + // Skip this cell + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + continue; + } + + const info = pending.get(matchedKey); + const text = info.value; + + // Clear cell if value is empty (Shift+F4 = native 1C clear) + if (text === '') { + await page.keyboard.press('Shift+F4'); + await page.waitForTimeout(300); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'clear', value: '' }); + if ([...pending.values()].every(p => p.filled)) break; + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + continue; + } + + // If user specified a type, always clear and use type selection flow + if (info.type) { + await page.keyboard.press('Shift+F4'); // Clear cell to reset any inherited type + await page.waitForTimeout(300); + await page.keyboard.press('F4'); + // Poll for type dialog form to appear + let typeForm = null; + for (let tw = 0; tw < 6; tw++) { + await page.waitForTimeout(200); + typeForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (typeForm !== null) break; + } + if (typeForm !== null && await isTypeDialog(typeForm)) { + await pickFromTypeDialog(typeForm, info.type); + await waitForStable(typeForm); + // After type selection, check if a selection form opened (ref types) + const selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm === null) { + // Primitive type — poll for calculator/calendar popup or settle on INPUT + let hasPopup = null; + for (let pw = 0; pw < 5; pw++) { + await page.waitForTimeout(200); + hasPopup = await page.evaluate(`(() => { + const calc = document.querySelector('.calculate'); + if (calc && calc.offsetWidth > 0) return 'calculator'; + const cal = document.querySelector('.frameCalendar'); + if (cal && cal.offsetWidth > 0) return 'calendar'; + return null; + })()`); + if (hasPopup) break; + } + if (hasPopup) { + await page.keyboard.press('Escape'); + // Poll for popup to disappear + for (let dw = 0; dw < 4; dw++) { + await page.waitForTimeout(150); + const gone = await page.evaluate(`(() => { + const calc = document.querySelector('.calculate'); + if (calc && calc.offsetWidth > 0) return false; + const cal = document.querySelector('.frameCalendar'); + if (cal && cal.offsetWidth > 0) return false; + return true; + })()`); + if (gone) break; + } + } + // Ensure we are in an editable INPUT for this cell + const inInput = await page.evaluate(`(() => { + const f = document.activeElement; + return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); + })()`); + if (!inInput) { + const cellRect = await page.evaluate(`(() => { + const el = document.getElementById(${JSON.stringify(cell.id)}); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + })()`); + if (cellRect) { + await page.mouse.dblclick(cellRect.x, cellRect.y); + // Poll for INPUT focus + for (let fw = 0; fw < 4; fw++) { + await page.waitForTimeout(150); + const ok = await page.evaluate(`(() => { + const f = document.activeElement; + return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); + })()`); + if (ok) break; + } + } + } + await pasteText(text, { confirm: ['Control+a', 'Control+v'] }); + await page.waitForTimeout(400); + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type }); + continue; + } + const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); + info.filled = true; + results.push(pickResult.ok + ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type } + : { field: matchedKey, cell: cell.fullName, + error: pickResult.error, message: pickResult.message }); + continue; + } + // F4 opened something but not a type dialog — close and report + if (typeForm !== null) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + } + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'type_dialog_failed', + message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` }); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + continue; + } + + // === Fill this cell: clipboard paste (trusted event) === + await page.keyboard.press('Control+A'); + await pasteText(text); + await page.waitForTimeout(1500); + + // Check if paste was rejected (composite-type cell blocks text input until type is selected) + const inputAfterPaste = await page.evaluate(`document.activeElement?.value || ''`); + if (!inputAfterPaste && text) { + // No type specified — can't fill this composite-type cell + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'type_required', + message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` }); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + continue; + } + + // Check for EDD autocomplete (indicates reference field) + const eddItems = await page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return null; + return [...edd.querySelectorAll('.eddText')] + .filter(el => el.offsetWidth > 0) + .map(el => el.innerText?.trim() || ''); + })()`); + + if (eddItems && eddItems.length > 0) { + // Reference field with autocomplete — click best match + // Filter out reference field "create" actions (Создать элемент, Создать группу, Создать: ...) + // but keep standalone enum values like "Создать" (no space/colon after) + const realItems = eddItems.filter(i => !/^Создать[\s:]/.test(i)); + + if (realItems.length > 0) { + const tgt = normYo(text.toLowerCase()); + let pick = realItems.find(i => + normYo(i.replace(/\s*\([^)]*\)\s*$/, '').toLowerCase()) === tgt); + if (!pick) pick = realItems.find(i => normYo(i.toLowerCase()).includes(tgt)); + if (!pick) pick = realItems[0]; + + // Click EDD item via dispatchEvent (bypasses div.surface overlay) + const pickLower = pick.toLowerCase(); + await page.evaluate(`(() => { + const edd = document.getElementById('editDropDown'); + if (!edd) return; + for (const el of edd.querySelectorAll('.eddText')) { + if (el.offsetWidth === 0) continue; + if (el.innerText.trim().toLowerCase().includes(${JSON.stringify(pickLower)})) { + const r = el.getBoundingClientRect(); + const opts = { bubbles:true, cancelable:true, + clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + el.dispatchEvent(new MouseEvent('mousedown', opts)); + el.dispatchEvent(new MouseEvent('mouseup', opts)); + el.dispatchEvent(new MouseEvent('click', opts)); + return; + } + } + })()`); + await waitForStable(); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: true, + method: 'dropdown', value: pick.replace(/\s*\([^)]*\)\s*$/, '') }); + } else { + // Only "Создать:" items — value not found in autocomplete + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'not_found', message: `No match for "${text}"` }); + } + + // Done? If so, don't Tab (avoids creating a new row after last cell) + if ([...pending.values()].every(p => p.filled)) break; + // Tab to move to next cell + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + continue; + } + + // No EDD — press Tab to commit the value + await page.keyboard.press('Tab'); + await page.waitForTimeout(1000); + + // Check for "нет в списке" cloud popup (reference field, value not found) + const notInList = await page.evaluate(`(() => { + for (const el of document.querySelectorAll('div')) { + if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; + const s = getComputedStyle(el); + if (s.position !== 'absolute' && s.position !== 'fixed') continue; + if ((parseInt(s.zIndex) || 0) < 100) continue; + if ((el.innerText || '').includes('нет в списке')) return true; + } + return false; + })()`); + + if (notInList) { + // Cloud has "Показать все" link — try to open selection form via it + const clickedShowAll = await page.evaluate(`(() => { + for (const el of document.querySelectorAll('div')) { + if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; + const s = getComputedStyle(el); + if (s.position !== 'absolute' && s.position !== 'fixed') continue; + if ((parseInt(s.zIndex) || 0) < 100) continue; + if (!(el.innerText || '').includes('нет в списке')) continue; + // Found the cloud — look for "Показать все" hyperlink inside + const links = [...el.querySelectorAll('a, span, div')] + .filter(e => e.offsetWidth > 0 && e.children.length === 0); + const showAll = links.find(e => { + const t = (e.innerText?.trim() || '').toLowerCase(); + return t === 'показать все' || t === 'show all'; + }); + if (showAll) { + const r = showAll.getBoundingClientRect(); + const opts = { bubbles:true, cancelable:true, + clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + showAll.dispatchEvent(new MouseEvent('mousedown', opts)); + showAll.dispatchEvent(new MouseEvent('mouseup', opts)); + showAll.dispatchEvent(new MouseEvent('click', opts)); + return true; + } + return false; + } + return false; + })()`); + + if (clickedShowAll) { + await waitForStable(formNum); + // Check if selection form opened + const selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { + if (el.offsetWidth === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + + if (selForm !== null) { + const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); + info.filled = true; + if (pickResult.ok) { + results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'form' }); + continue; + } + // Not found in selection form — fall through to clear + skip + results.push({ field: matchedKey, cell: cell.fullName, + error: pickResult.error, message: pickResult.message }); + } else { + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'not_found', message: `Value "${text}" not in list` }); + } + } else { + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'not_found', message: `Value "${text}" not in list` }); + } + + // 1C won't let us Tab away from an invalid ref value. + // Must clear the field first, then Tab to move on. + // Escape dismisses the cloud; Ctrl+A + Delete clears the text. + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Delete'); + await page.waitForTimeout(300); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + continue; + } + + // Check for a new form (broad detection — also catches type dialogs whose buttons lack IDs) + const newForm = await helperDetectNewForm(formNum); + + if (newForm !== null) { + if (await isTypeDialog(newForm)) { + // Composite-type cell — need type to proceed + if (info.type) { + await pickFromTypeDialog(newForm, info.type); + await waitForStable(newForm); + // After type selection, the actual selection form should open + const selForm = await page.evaluate(`(() => { + const forms = {}; + document.querySelectorAll('[id]').forEach(el => { + if (el.offsetWidth === 0 && el.offsetHeight === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`); + if (selForm === null) { + // Primitive type — poll for calculator/calendar popup or settle on INPUT + let hasPopup = null; + for (let pw = 0; pw < 5; pw++) { + await page.waitForTimeout(200); + hasPopup = await page.evaluate(`(() => { + const calc = document.querySelector('.calculate'); + if (calc && calc.offsetWidth > 0) return 'calculator'; + const cal = document.querySelector('.frameCalendar'); + if (cal && cal.offsetWidth > 0) return 'calendar'; + return null; + })()`); + if (hasPopup) break; + } + if (hasPopup) { + await page.keyboard.press('Escape'); + for (let dw = 0; dw < 4; dw++) { + await page.waitForTimeout(150); + const gone = await page.evaluate(`(() => { + const calc = document.querySelector('.calculate'); + if (calc && calc.offsetWidth > 0) return false; + const cal = document.querySelector('.frameCalendar'); + if (cal && cal.offsetWidth > 0) return false; + return true; + })()`); + if (gone) break; + } + } + const inInput = await page.evaluate(`(() => { + const f = document.activeElement; + return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); + })()`); + if (!inInput) { + const cellRect = await page.evaluate(`(() => { + const el = document.getElementById(${JSON.stringify(cell.id)}); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + })()`); + if (cellRect) { + await page.mouse.dblclick(cellRect.x, cellRect.y); + for (let fw = 0; fw < 4; fw++) { + await page.waitForTimeout(150); + const ok = await page.evaluate(`(() => { + const f = document.activeElement; + return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); + })()`); + if (ok) break; + } + } + } + await pasteText(text, { confirm: ['Control+a', 'Control+v'] }); + await page.waitForTimeout(400); + await page.keyboard.press('Tab'); + await page.waitForTimeout(300); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'type-direct', type: info.type }); + continue; + } + const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); + info.filled = true; + results.push(pickResult.ok + ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type } + : { field: matchedKey, cell: cell.fullName, + error: pickResult.error, message: pickResult.message }); + continue; + } else { + // No type specified — close dialog, clear cell, report error + await page.keyboard.press('Escape'); + await page.waitForTimeout(300); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Delete'); + await page.waitForTimeout(300); + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, + error: 'type_required', + message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` }); + continue; + } + } + // Not a type dialog — normal selection form + const pickResult = await pickFromSelectionForm(newForm, matchedKey, text, formNum); + info.filled = true; + results.push(pickResult.ok + ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' } + : { field: matchedKey, cell: cell.fullName, + error: pickResult.error, message: pickResult.message }); + continue; + } + + // Plain field — value committed via Tab + info.filled = true; + results.push({ field: matchedKey, cell: cell.fullName, ok: true, method: 'direct' }); + + // All done? + if ([...pending.values()].every(p => p.filled)) break; + // Tab already pressed — we're on next cell + } + + // Commit the new row: click on the grid header to exit edit mode. + // Clicking a different data row would re-enter edit mode on that row. + // Without this commit click, the row stays in "uncommitted add" state + // and a subsequent Escape (e.g. from closeForm) would cancel the entire row. + const commitTarget = await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return null; + const head = grid.querySelector('.gridHead'); + if (head) { + const r = head.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + } + return null; + })()`); + if (commitTarget) { + await page.mouse.click(commitTarget.x, commitTarget.y); + await page.waitForTimeout(500); + } else { + // Fallback: Tab out of the last cell to commit the row + await page.keyboard.press('Tab'); + await page.waitForTimeout(500); + } + + // Dismiss any leftover error modals + const err = await checkForErrors(); + if (err?.modal) { + try { + const btn = await page.$('a.press.pressDefault'); + if (btn) { await btn.click(); await page.waitForTimeout(500); } + } catch { /* OK */ } + } + + const notFilled = [...pending].filter(([_, info]) => !info.filled).map(([key]) => key); + + // Retry unfilled checkbox fields via direct click (Tab skips checkbox cells) + if (notFilled.length > 0) { + const checkboxFields = {}; + for (const key of notFilled) { + const val = String(pending.get(key).value).toLowerCase().trim(); + if (['true', 'false', 'да', 'нет', '1', '0', 'yes', 'no'].includes(val)) { + checkboxFields[key] = pending.get(key).value; + } + } + if (Object.keys(checkboxFields).length > 0) { + // Use row index: addedRowIdx (from add mode) or fallback to selected row + const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(`(() => { + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; + if (!grid) return -1; + const body = grid.querySelector('.gridBody'); + if (!body) return -1; + const lines = [...body.querySelectorAll('.gridLine')]; + const sel = lines.findIndex(l => l.classList.contains('selected')); + return sel >= 0 ? sel : lines.length - 1; + })()`) + ); + if (currentRow >= 0) { + const more = await fillTableRow(checkboxFields, { row: currentRow, table }); + if (Array.isArray(more)) { + results.push(...more); + } else if (more?.filled) { + results.push(...more.filled); + } + for (const key of Object.keys(checkboxFields)) { + const idx = notFilled.indexOf(key); + if (idx >= 0) notFilled.splice(idx, 1); + } + } + } + } + + const formData = await getFormState(); + const result = { filled: results }; + if (notFilled.length > 0) result.notFilled = notFilled; + result.form = formData; + return result; + + } catch (e) { + if (e.message.startsWith('fillTableRow:')) throw e; + throw new Error(`fillTableRow: ${e.message}`); + } +} From 8739d1d15c3278de4f8f96a45d9336f5eedbea7d Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 16:03:20 +0300 Subject: [PATCH 21/47] =?UTF-8?q?refactor(web-test):=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B0=20=E2=80=94=20engine/=20wr?= =?UTF-8?q?apper=20=D0=B4=D0=BB=D1=8F=20=D0=B2=D0=BD=D1=83=D1=82=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=B8=D1=85=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Перенос всей внутрянки движка под scripts/engine/: - core/, forms/, nav/, table/, recording/ → engine// Публичные entry-точки остаются в scripts/ корне без изменений: - browser.mjs, dom.mjs, run.mjs — компат не ломаем. Симметричный layout, легко читать с первого взгляда: scripts/ browser.mjs, dom.mjs, run.mjs ← публичные entries engine/ ← внутренности движка (dom/, cli/ — место под будущий распил dom.mjs / run.mjs) Технические правки после переезда: - browser.mjs: ./core/... → ./engine/core/... (23 импорта) - engine/*/* модули: ../browser.mjs → ../../browser.mjs (11 импортов) - engine/*/* модули: ../dom.mjs → ../../dom.mjs (12 импортов) - engine/recording/capture.mjs: dynamic import('../browser.mjs') → import('../../browser.mjs') - engine/core/state.mjs: projectRoot пересчитан (5 → 6 уровней вверх) - Git rename detection срабатывает — история файлов сохраняется Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 46 +++++++++---------- .../scripts/{ => engine}/core/click.mjs | 4 +- .../scripts/{ => engine}/core/errors.mjs | 2 +- .../scripts/{ => engine}/core/helpers.mjs | 2 +- .../scripts/{ => engine}/core/session.mjs | 2 +- .../scripts/{ => engine}/core/state.mjs | 4 +- .../scripts/{ => engine}/core/wait.mjs | 2 +- .../scripts/{ => engine}/forms/close.mjs | 4 +- .../scripts/{ => engine}/forms/fill.mjs | 4 +- .../{ => engine}/forms/select-value.mjs | 4 +- .../scripts/{ => engine}/nav/navigation.mjs | 4 +- .../{ => engine}/recording/captions.mjs | 0 .../{ => engine}/recording/capture.mjs | 2 +- .../{ => engine}/recording/highlight.mjs | 2 +- .../{ => engine}/recording/narration.mjs | 0 .../scripts/{ => engine}/recording/tts.mjs | 0 .../scripts/{ => engine}/table/filter.mjs | 4 +- .../{ => engine}/table/grid-toggle.mjs | 0 .../scripts/{ => engine}/table/grid.mjs | 4 +- .../scripts/{ => engine}/table/row-fill.mjs | 4 +- .../{ => engine}/table/spreadsheet.mjs | 4 +- 21 files changed, 49 insertions(+), 49 deletions(-) rename .claude/skills/web-test/scripts/{ => engine}/core/click.mjs (97%) rename .claude/skills/web-test/scripts/{ => engine}/core/errors.mjs (97%) rename .claude/skills/web-test/scripts/{ => engine}/core/helpers.mjs (96%) rename .claude/skills/web-test/scripts/{ => engine}/core/session.mjs (97%) rename .claude/skills/web-test/scripts/{ => engine}/core/state.mjs (96%) rename .claude/skills/web-test/scripts/{ => engine}/core/wait.mjs (95%) rename .claude/skills/web-test/scripts/{ => engine}/forms/close.mjs (93%) rename .claude/skills/web-test/scripts/{ => engine}/forms/fill.mjs (96%) rename .claude/skills/web-test/scripts/{ => engine}/forms/select-value.mjs (97%) rename .claude/skills/web-test/scripts/{ => engine}/nav/navigation.mjs (96%) rename .claude/skills/web-test/scripts/{ => engine}/recording/captions.mjs (100%) rename .claude/skills/web-test/scripts/{ => engine}/recording/capture.mjs (96%) rename .claude/skills/web-test/scripts/{ => engine}/recording/highlight.mjs (97%) rename .claude/skills/web-test/scripts/{ => engine}/recording/narration.mjs (100%) rename .claude/skills/web-test/scripts/{ => engine}/recording/tts.mjs (100%) rename .claude/skills/web-test/scripts/{ => engine}/table/filter.mjs (97%) rename .claude/skills/web-test/scripts/{ => engine}/table/grid-toggle.mjs (100%) rename .claude/skills/web-test/scripts/{ => engine}/table/grid.mjs (96%) rename .claude/skills/web-test/scripts/{ => engine}/table/row-fill.mjs (97%) rename .claude/skills/web-test/scripts/{ => engine}/table/spreadsheet.mjs (97%) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 29053f74..84ca517d 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -35,7 +35,7 @@ import { LOAD_TIMEOUT, INIT_TIMEOUT, ACTION_WAIT, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES, EXT_ID, projectRoot, resolveProjectPath, normYo, isConnected, ensureConnected, getPage, setPreserveClipboard, -} from './core/state.mjs'; +} from './engine/core/state.mjs'; export { isConnected, getPage, setPreserveClipboard, ensureConnected }; export async function saveClipboard() { @@ -130,27 +130,27 @@ export { connect, disconnect, attach, detach, getSession, createContext, setActiveContext, listContexts, getActiveContext, hasContext, closeContext, -} from './core/session.mjs'; +} from './engine/core/session.mjs'; // ============================================================ // Wait + error/modal handling — extracted to core/{wait,errors}.mjs // ============================================================ import { waitForStable, waitForCondition, startNetworkMonitor, -} from './core/wait.mjs'; +} from './engine/core/wait.mjs'; import { closeModals, checkForErrors, dismissPendingErrors, fetchErrorStack, _detectPlatformDialogs, _closePlatformDialogs, -} from './core/errors.mjs'; +} from './engine/core/errors.mjs'; import { safeClick, findFieldInputId, readEdd, returnFormState, detectNewForm as helperDetectNewForm, -} from './core/helpers.mjs'; -import { getGridToggleIcon, shouldClickToggle } from './table/grid-toggle.mjs'; +} from './engine/core/helpers.mjs'; +import { getGridToggleIcon, shouldClickToggle } from './engine/table/grid-toggle.mjs'; // Re-export only what was publicly exported before the refactor. // waitForStable/waitForCondition/startNetworkMonitor/closeModals/checkForErrors/ // dismissPendingErrors are internal helpers — imported above for local use only. -export { fetchErrorStack } from './core/errors.mjs'; +export { fetchErrorStack } from './engine/core/errors.mjs'; /* getPage moved to core/state.mjs */ @@ -160,7 +160,7 @@ export { fetchErrorStack } from './core/errors.mjs'; export { getPageState, getSections, navigateSection, getCommands, openCommand, switchTab, openFile, navigateLink, -} from './nav/navigation.mjs'; +} from './engine/nav/navigation.mjs'; /** Read current form state. Single evaluate call via combined script. */ export async function getFormState() { @@ -184,50 +184,50 @@ export async function getFormState() { // ============================================================ // Table reading + SpreadsheetDocument — extracted to table/spreadsheet.mjs // ============================================================ -export { readTable } from './table/grid.mjs'; -export { readSpreadsheet } from './table/spreadsheet.mjs'; +export { readTable } from './engine/table/grid.mjs'; +export { readSpreadsheet } from './engine/table/spreadsheet.mjs'; // ============================================================ // Value selection (DLB/CB) — extracted to forms/select-value.mjs // ============================================================ -export { selectValue } from './forms/select-value.mjs'; +export { selectValue } from './engine/forms/select-value.mjs'; import { selectValue, pickFromSelectionForm, isTypeDialog, pickFromTypeDialog, fillReferenceField, -} from './forms/select-value.mjs'; +} from './engine/forms/select-value.mjs'; // ============================================================ // Fill fields — extracted to forms/fill.mjs // ============================================================ -export { fillFields, fillField } from './forms/fill.mjs'; +export { fillFields, fillField } from './engine/forms/fill.mjs'; // ============================================================ // clickElement dispatcher — extracted to core/click.mjs // ============================================================ -export { clickElement } from './core/click.mjs'; -import { clickElement } from './core/click.mjs'; +export { clickElement } from './engine/core/click.mjs'; +import { clickElement } from './engine/core/click.mjs'; // ============================================================ // Close form — extracted to forms/close.mjs // ============================================================ -export { closeForm } from './forms/close.mjs'; +export { closeForm } from './engine/forms/close.mjs'; // ============================================================ // fillTableRow / deleteTableRow — extracted to table/{row-fill,grid}.mjs // ============================================================ -export { fillTableRow } from './table/row-fill.mjs'; -export { deleteTableRow } from './table/grid.mjs'; +export { fillTableRow } from './engine/table/row-fill.mjs'; +export { deleteTableRow } from './engine/table/grid.mjs'; // ============================================================ // List filters — extracted to table/filter.mjs // ============================================================ -export { filterList, unfilterList } from './table/filter.mjs'; +export { filterList, unfilterList } from './engine/table/filter.mjs'; // ============================================================ @@ -235,15 +235,15 @@ export { filterList, unfilterList } from './table/filter.mjs'; // ============================================================ export { screenshot, wait, isRecording, startRecording, stopRecording, -} from './recording/capture.mjs'; +} from './engine/recording/capture.mjs'; export { showCaption, hideCaption, getCaptions, showTitleSlide, hideTitleSlide, showImage, hideImage, -} from './recording/captions.mjs'; +} from './engine/recording/captions.mjs'; export { highlight, unhighlight, setHighlight, isHighlightMode, -} from './recording/highlight.mjs'; -export { addNarration } from './recording/narration.mjs'; +} from './engine/recording/highlight.mjs'; +export { addNarration } from './engine/recording/narration.mjs'; /* ensureConnected moved to core/state.mjs */ diff --git a/.claude/skills/web-test/scripts/core/click.mjs b/.claude/skills/web-test/scripts/engine/core/click.mjs similarity index 97% rename from .claude/skills/web-test/scripts/core/click.mjs rename to .claude/skills/web-test/scripts/engine/core/click.mjs index 160a756c..e33cc575 100644 --- a/.claude/skills/web-test/scripts/core/click.mjs +++ b/.claude/skills/web-test/scripts/engine/core/click.mjs @@ -6,7 +6,7 @@ import { } from './state.mjs'; import { detectFormScript, findClickTargetScript, resolveGridScript, readSubmenuScript, -} from '../dom.mjs'; +} from '../../dom.mjs'; import { dismissPendingErrors, checkForErrors, fetchErrorStack } from './errors.mjs'; import { waitForStable, startNetworkMonitor } from './wait.mjs'; import { highlight, unhighlight } from '../recording/highlight.mjs'; @@ -16,7 +16,7 @@ import { clickSpreadsheetCell, findSpreadsheetCellByText, } from '../table/spreadsheet.mjs'; // getFormState still in browser.mjs. -import { getFormState } from '../browser.mjs'; +import { getFormState } from '../../browser.mjs'; /** 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. */ diff --git a/.claude/skills/web-test/scripts/core/errors.mjs b/.claude/skills/web-test/scripts/engine/core/errors.mjs similarity index 97% rename from .claude/skills/web-test/scripts/core/errors.mjs rename to .claude/skills/web-test/scripts/engine/core/errors.mjs index 4e72381f..1d37a048 100644 --- a/.claude/skills/web-test/scripts/core/errors.mjs +++ b/.claude/skills/web-test/scripts/engine/core/errors.mjs @@ -2,7 +2,7 @@ // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page } from './state.mjs'; -import { checkErrorsScript } from '../dom.mjs'; +import { checkErrorsScript } from '../../dom.mjs'; import { waitForStable } from './wait.mjs'; /** diff --git a/.claude/skills/web-test/scripts/core/helpers.mjs b/.claude/skills/web-test/scripts/engine/core/helpers.mjs similarity index 96% rename from .claude/skills/web-test/scripts/core/helpers.mjs rename to .claude/skills/web-test/scripts/engine/core/helpers.mjs index 810cf2a9..c6d1af26 100644 --- a/.claude/skills/web-test/scripts/core/helpers.mjs +++ b/.claude/skills/web-test/scripts/engine/core/helpers.mjs @@ -4,7 +4,7 @@ import { page } from './state.mjs'; import { dismissPendingErrors, checkForErrors } from './errors.mjs'; -import { getFormState } from '../browser.mjs'; +import { getFormState } from '../../browser.mjs'; /** * page.click with the standard "intercepts pointer events" retry ladder: diff --git a/.claude/skills/web-test/scripts/core/session.mjs b/.claude/skills/web-test/scripts/engine/core/session.mjs similarity index 97% rename from .claude/skills/web-test/scripts/core/session.mjs rename to .claude/skills/web-test/scripts/engine/core/session.mjs index 78905977..d2b7dfbf 100644 --- a/.claude/skills/web-test/scripts/core/session.mjs +++ b/.claude/skills/web-test/scripts/engine/core/session.mjs @@ -17,7 +17,7 @@ import { stopRecording } from '../recording/capture.mjs'; // getPageState lives in browser.mjs (moves to nav/navigation.mjs in a later stage). // Static import is a deliberate ESM cycle — fine because the binding is used at // call time (inside async connect/createContext), not at module evaluation time. -import { getPageState } from '../browser.mjs'; +import { getPageState } from '../../browser.mjs'; /** * Find the 1C browser extension in Chrome/Edge user profiles. diff --git a/.claude/skills/web-test/scripts/core/state.mjs b/.claude/skills/web-test/scripts/engine/core/state.mjs similarity index 96% rename from .claude/skills/web-test/scripts/core/state.mjs rename to .claude/skills/web-test/scripts/engine/core/state.mjs index 395a58ad..6c193769 100644 --- a/.claude/skills/web-test/scripts/core/state.mjs +++ b/.claude/skills/web-test/scripts/engine/core/state.mjs @@ -10,9 +10,9 @@ import { dirname, resolve as pathResolve } from 'path'; import { fileURLToPath } from 'url'; -// Project root: 4 levels up from .claude/skills/web-test/scripts/core/state.mjs +// Project root: 6 levels up from .claude/skills/web-test/scripts/engine/core/state.mjs const __fn_state = fileURLToPath(import.meta.url); -export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..'); +export const projectRoot = pathResolve(dirname(__fn_state), '..', '..', '..', '..', '..', '..'); /** Resolve a user-provided path relative to the project root (not cwd). */ export const resolveProjectPath = (p) => pathResolve(projectRoot, p); diff --git a/.claude/skills/web-test/scripts/core/wait.mjs b/.claude/skills/web-test/scripts/engine/core/wait.mjs similarity index 95% rename from .claude/skills/web-test/scripts/core/wait.mjs rename to .claude/skills/web-test/scripts/engine/core/wait.mjs index 8e58f41a..20c84ed2 100644 --- a/.claude/skills/web-test/scripts/core/wait.mjs +++ b/.claude/skills/web-test/scripts/engine/core/wait.mjs @@ -2,7 +2,7 @@ // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES } from './state.mjs'; -import { detectFormScript } from '../dom.mjs'; +import { detectFormScript } from '../../dom.mjs'; /** * Smart wait: poll until DOM is stable and no loading indicators are visible. diff --git a/.claude/skills/web-test/scripts/forms/close.mjs b/.claude/skills/web-test/scripts/engine/forms/close.mjs similarity index 93% rename from .claude/skills/web-test/scripts/forms/close.mjs rename to .claude/skills/web-test/scripts/engine/forms/close.mjs index 6ff2b65d..c4faae14 100644 --- a/.claude/skills/web-test/scripts/forms/close.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/close.mjs @@ -2,10 +2,10 @@ // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, recorder, ensureConnected } from '../core/state.mjs'; -import { detectFormScript } from '../dom.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'; +import { getFormState } from '../../browser.mjs'; /** * Close the current form/dialog via Escape. diff --git a/.claude/skills/web-test/scripts/forms/fill.mjs b/.claude/skills/web-test/scripts/engine/forms/fill.mjs similarity index 96% rename from .claude/skills/web-test/scripts/forms/fill.mjs rename to .claude/skills/web-test/scripts/engine/forms/fill.mjs index c98573ea..dcd669e7 100644 --- a/.claude/skills/web-test/scripts/forms/fill.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/fill.mjs @@ -6,7 +6,7 @@ import { } from '../core/state.mjs'; import { detectFormScript, resolveFieldsScript, readFormScript, -} from '../dom.mjs'; +} from '../../dom.mjs'; import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; import { waitForStable, startNetworkMonitor } from '../core/wait.mjs'; import { highlight, unhighlight } from '../recording/highlight.mjs'; @@ -15,7 +15,7 @@ import { isTypeDialog, pickFromTypeDialog, } from './select-value.mjs'; // pasteText + getFormState live in browser.mjs. -import { pasteText, getFormState } from '../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) { diff --git a/.claude/skills/web-test/scripts/forms/select-value.mjs b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs similarity index 97% rename from .claude/skills/web-test/scripts/forms/select-value.mjs rename to .claude/skills/web-test/scripts/engine/forms/select-value.mjs index 527dafaa..bfe0892d 100644 --- a/.claude/skills/web-test/scripts/forms/select-value.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs @@ -7,7 +7,7 @@ import { import { detectFormScript, findFieldButtonScript, resolveFieldsScript, readSubmenuScript, checkErrorsScript, -} from '../dom.mjs'; +} from '../../dom.mjs'; import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; import { waitForStable, waitForCondition } from '../core/wait.mjs'; import { highlight, unhighlight } from '../recording/highlight.mjs'; @@ -16,7 +16,7 @@ import { detectNewForm as helperDetectNewForm, } from '../core/helpers.mjs'; // pasteText + getFormState live in browser.mjs. -import { pasteText, getFormState } from '../browser.mjs'; +import { pasteText, getFormState } from '../../browser.mjs'; /** * Scan visible grid rows for a text match (exact → startsWith → includes). diff --git a/.claude/skills/web-test/scripts/nav/navigation.mjs b/.claude/skills/web-test/scripts/engine/nav/navigation.mjs similarity index 96% rename from .claude/skills/web-test/scripts/nav/navigation.mjs rename to .claude/skills/web-test/scripts/engine/nav/navigation.mjs index ecce3152..060396e6 100644 --- a/.claude/skills/web-test/scripts/nav/navigation.mjs +++ b/.claude/skills/web-test/scripts/engine/nav/navigation.mjs @@ -8,14 +8,14 @@ import { readSectionsScript, readTabsScript, readCommandsScript, navigateSectionScript, openCommandScript, switchTabScript, detectFormScript, -} from '../dom.mjs'; +} 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'; +import { pasteText, getFormState } from '../../browser.mjs'; /** * Get current page state: active section, tabs. diff --git a/.claude/skills/web-test/scripts/recording/captions.mjs b/.claude/skills/web-test/scripts/engine/recording/captions.mjs similarity index 100% rename from .claude/skills/web-test/scripts/recording/captions.mjs rename to .claude/skills/web-test/scripts/engine/recording/captions.mjs diff --git a/.claude/skills/web-test/scripts/recording/capture.mjs b/.claude/skills/web-test/scripts/engine/recording/capture.mjs similarity index 96% rename from .claude/skills/web-test/scripts/recording/capture.mjs rename to .claude/skills/web-test/scripts/engine/recording/capture.mjs index ad94a4ee..d89f5ab1 100644 --- a/.claude/skills/web-test/scripts/recording/capture.mjs +++ b/.claude/skills/web-test/scripts/engine/recording/capture.mjs @@ -45,7 +45,7 @@ export async function wait(seconds) { await page.waitForTimeout(ms); } } - const { getFormState } = await import('../browser.mjs'); + const { getFormState } = await import('../../browser.mjs'); return await getFormState(); } diff --git a/.claude/skills/web-test/scripts/recording/highlight.mjs b/.claude/skills/web-test/scripts/engine/recording/highlight.mjs similarity index 97% rename from .claude/skills/web-test/scripts/recording/highlight.mjs rename to .claude/skills/web-test/scripts/engine/recording/highlight.mjs index bdf5eee1..a8fa3e6c 100644 --- a/.claude/skills/web-test/scripts/recording/highlight.mjs +++ b/.claude/skills/web-test/scripts/engine/recording/highlight.mjs @@ -8,7 +8,7 @@ import { import { readSubmenuScript, detectFormScript, resolveGridScript, findClickTargetScript, resolveFieldsScript, -} from '../dom.mjs'; +} from '../../dom.mjs'; /** * Highlight an element on the page (visual accent for video recordings). diff --git a/.claude/skills/web-test/scripts/recording/narration.mjs b/.claude/skills/web-test/scripts/engine/recording/narration.mjs similarity index 100% rename from .claude/skills/web-test/scripts/recording/narration.mjs rename to .claude/skills/web-test/scripts/engine/recording/narration.mjs diff --git a/.claude/skills/web-test/scripts/recording/tts.mjs b/.claude/skills/web-test/scripts/engine/recording/tts.mjs similarity index 100% rename from .claude/skills/web-test/scripts/recording/tts.mjs rename to .claude/skills/web-test/scripts/engine/recording/tts.mjs diff --git a/.claude/skills/web-test/scripts/table/filter.mjs b/.claude/skills/web-test/scripts/engine/table/filter.mjs similarity index 97% rename from .claude/skills/web-test/scripts/table/filter.mjs rename to .claude/skills/web-test/scripts/engine/table/filter.mjs index b1a398a0..db3ba8d1 100644 --- a/.claude/skills/web-test/scripts/table/filter.mjs +++ b/.claude/skills/web-test/scripts/engine/table/filter.mjs @@ -2,14 +2,14 @@ // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs'; -import { detectFormScript, resolveGridScript } from '../dom.mjs'; +import { detectFormScript, resolveGridScript } 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 { safeClick } from '../core/helpers.mjs'; import { selectValue, fillReferenceField } from '../forms/select-value.mjs'; // pasteText + getFormState + clickElement still in browser.mjs. -import { pasteText, getFormState, clickElement } from '../browser.mjs'; +import { pasteText, getFormState, clickElement } from '../../browser.mjs'; /** * Filter the current list by field value, or search via search bar. diff --git a/.claude/skills/web-test/scripts/table/grid-toggle.mjs b/.claude/skills/web-test/scripts/engine/table/grid-toggle.mjs similarity index 100% rename from .claude/skills/web-test/scripts/table/grid-toggle.mjs rename to .claude/skills/web-test/scripts/engine/table/grid-toggle.mjs diff --git a/.claude/skills/web-test/scripts/table/grid.mjs b/.claude/skills/web-test/scripts/engine/table/grid.mjs similarity index 96% rename from .claude/skills/web-test/scripts/table/grid.mjs rename to .claude/skills/web-test/scripts/engine/table/grid.mjs index 5b77e68f..b97b4d6b 100644 --- a/.claude/skills/web-test/scripts/table/grid.mjs +++ b/.claude/skills/web-test/scripts/engine/table/grid.mjs @@ -6,12 +6,12 @@ // Отдельно от SpreadsheetDocument (table/spreadsheet.mjs). import { page, ensureConnected } from '../core/state.mjs'; -import { detectFormScript, readTableScript, resolveGridScript } from '../dom.mjs'; +import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs'; import { dismissPendingErrors } from '../core/errors.mjs'; import { waitForStable } from '../core/wait.mjs'; import { clickElement } from '../core/click.mjs'; // getFormState lives in browser.mjs. -import { getFormState } from '../browser.mjs'; +import { getFormState } from '../../browser.mjs'; /** Read structured table data with pagination. Returns columns, rows, total count. */ export async function readTable({ maxRows = 20, offset = 0, table } = {}) { diff --git a/.claude/skills/web-test/scripts/table/row-fill.mjs b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs similarity index 97% rename from .claude/skills/web-test/scripts/table/row-fill.mjs rename to .claude/skills/web-test/scripts/engine/table/row-fill.mjs index 57361a28..0f7ec083 100644 --- a/.claude/skills/web-test/scripts/table/row-fill.mjs +++ b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs @@ -6,7 +6,7 @@ import { } from '../core/state.mjs'; import { detectFormScript, resolveGridScript, readTableScript, -} from '../dom.mjs'; +} from '../../dom.mjs'; import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; import { waitForStable, waitForCondition, startNetworkMonitor } from '../core/wait.mjs'; import { highlight, unhighlight } from '../recording/highlight.mjs'; @@ -20,7 +20,7 @@ import { fillReferenceField, selectValue, } from '../forms/select-value.mjs'; // pasteText + getFormState still in browser.mjs (cycle). -import { pasteText, getFormState } from '../browser.mjs'; +import { pasteText, getFormState } from '../../browser.mjs'; /** * Fill cells in the current table row via Tab navigation. diff --git a/.claude/skills/web-test/scripts/table/spreadsheet.mjs b/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs similarity index 97% rename from .claude/skills/web-test/scripts/table/spreadsheet.mjs rename to .claude/skills/web-test/scripts/engine/table/spreadsheet.mjs index 678b4f1e..6f5c7522 100644 --- a/.claude/skills/web-test/scripts/table/spreadsheet.mjs +++ b/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs @@ -2,10 +2,10 @@ // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, ensureConnected, normYo } from '../core/state.mjs'; -import { detectFormScript, readTableScript, resolveGridScript } from '../dom.mjs'; +import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs'; import { waitForStable } from '../core/wait.mjs'; // getFormState still in browser.mjs (cycle resolves at call time). -import { getFormState } from '../browser.mjs'; +import { getFormState } from '../../browser.mjs'; // readTable moved to table/grid.mjs (form-grid операции отделены от SpreadsheetDocument). From a24c39b6de39aa2399b34ffaabeae022c01911d0 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 16:25:15 +0300 Subject: [PATCH 22/47] =?UTF-8?q?refactor(web-test):=20=D1=8D=D1=82=D0=B0?= =?UTF-8?q?=D0=BF=20E.13=20=E2=80=94=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20(v1.17=20+=20=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=8B=D0=B9=20facade=20+=20=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BA=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Версия v1.16 → v1.17 во всех заголовках движка. 2. browser.mjs стал чистым facade — только re-exports, 0 функций определено. Было: 249 LOC с 4 настоящими функциями (saveClipboard, restoreClipboard, pasteText, getFormState) — теперь 57 LOC чистых re-export'ов. 3. engine/core/clipboard.mjs — новый модуль: pasteText + saveClipboard + restoreClipboard (~85 LOC, был в browser.mjs). 4. engine/core/form-state.mjs — новый модуль: getFormState — центральный читатель состояния формы (~30 LOC). 5. Убрано 12 циклических импортов из engine/* → ../../browser.mjs: - Все читатели pasteText теперь импортят из engine/core/clipboard.mjs - Все читатели getFormState — из engine/core/form-state.mjs - session.mjs → nav/navigation.mjs (getPageState напрямую) - filter.mjs → core/click.mjs (clickElement напрямую) Граф зависимостей стал деревом (без обратных рёбер). 6. Убраны _-префиксы у 9 функций, которые стали приватными внутри своих модулей (раньше _ означало "приватная для browser.mjs"): _detectPlatformDialogs → detectPlatformDialogs _closePlatformDialogs → closePlatformDialogs _parseErrorStack → parseErrorStack _fetchStackViaReport → fetchStackViaReport _fetchStackViaHamburger → fetchStackViaHamburger _logoutSlot → logoutSlot _saveActiveSlot → saveActiveSlot _activateSlot → activateSlot _attachSessionListeners → attachSessionListeners Публичный API: 56 экспортов, идентичный исходному. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 233 ++---------------- .../web-test/scripts/engine/core/click.mjs | 4 +- .../scripts/engine/core/clipboard.mjs | 97 ++++++++ .../web-test/scripts/engine/core/errors.mjs | 28 +-- .../scripts/engine/core/form-state.mjs | 32 +++ .../web-test/scripts/engine/core/helpers.mjs | 4 +- .../web-test/scripts/engine/core/session.mjs | 33 ++- .../web-test/scripts/engine/core/state.mjs | 2 +- .../web-test/scripts/engine/core/wait.mjs | 2 +- .../web-test/scripts/engine/forms/close.mjs | 10 +- .../web-test/scripts/engine/forms/fill.mjs | 5 +- .../scripts/engine/forms/select-value.mjs | 3 +- .../scripts/engine/nav/navigation.mjs | 3 +- .../scripts/engine/recording/captions.mjs | 2 +- .../scripts/engine/recording/capture.mjs | 4 +- .../scripts/engine/recording/highlight.mjs | 2 +- .../scripts/engine/recording/narration.mjs | 2 +- .../web-test/scripts/engine/recording/tts.mjs | 2 +- .../web-test/scripts/engine/table/filter.mjs | 7 +- .../scripts/engine/table/grid-toggle.mjs | 2 +- .../web-test/scripts/engine/table/grid.mjs | 4 +- .../scripts/engine/table/row-fill.mjs | 5 +- .../scripts/engine/table/spreadsheet.mjs | 4 +- 23 files changed, 214 insertions(+), 276 deletions(-) create mode 100644 .claude/skills/web-test/scripts/engine/core/clipboard.mjs create mode 100644 .claude/skills/web-test/scripts/engine/core/form-state.mjs diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 84ca517d..20895767 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -1,238 +1,47 @@ -// web-test browser v1.16 — Playwright browser management for 1C web client +// web-test browser v1.17 — engine facade: re-exports the public API from engine/* // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** - * Playwright browser management for 1C web client. - * - * Maintains a single browser instance across MCP tool calls. - * Handles connection, navigation, waiting, screenshots. + * Public API of the web-test engine. Pure re-export facade — no logic here. + * Implementation lives in `./engine/*`. External callers (run.mjs, exec scripts, + * tests) import from this file; engine internals import each other directly. */ -import { chromium } from 'playwright'; -import { spawn, execFileSync } from 'child_process'; -import { statSync, mkdirSync, existsSync as fsExistsSync, writeFileSync, readFileSync, rmSync, readdirSync } from 'fs'; -import { dirname, resolve as pathResolve, join as pathJoin, basename, extname } from 'path'; -import { tmpdir } from 'os'; -import { fileURLToPath, pathToFileURL } from 'url'; -import { - readSectionsScript, readTabsScript, readCommandsScript, - readFormScript, navigateSectionScript, openCommandScript, - findClickTargetScript, findFieldButtonScript, readSubmenuScript, - resolveFieldsScript, getFormStateScript, - detectFormScript, readTableScript, checkErrorsScript, - switchTabScript, resolveGridScript -} from './dom.mjs'; -// Module-level state, constants, normYo and resolveProjectPath live in core/state.mjs. -// Imported as live bindings — reads stay current; writes go through setters. -import { - browser, page, sessionPrefix, seanceId, recorder, - lastCaptions, lastRecordingDuration, highlightMode, - persistentUserDataDir, preserveClipboard, clipboardWarnLogged, - contexts, activeContextName, activeMode, - setBrowser, setPage, setSessionPrefix, setSeanceId, setRecorder, - setLastCaptions, setLastRecordingDuration, setHighlightMode, - setPersistentUserDataDir, setActiveContextName, setActiveMode, - setClipboardWarnLogged, - LOAD_TIMEOUT, INIT_TIMEOUT, ACTION_WAIT, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES, - EXT_ID, projectRoot, resolveProjectPath, normYo, - isConnected, ensureConnected, getPage, setPreserveClipboard, +// ── core ────────────────────────────────────────────────────────────────── +export { + isConnected, getPage, ensureConnected, setPreserveClipboard, } from './engine/core/state.mjs'; +export { + pasteText, saveClipboard, restoreClipboard, +} from './engine/core/clipboard.mjs'; +export { getFormState } from './engine/core/form-state.mjs'; +export { fetchErrorStack } from './engine/core/errors.mjs'; +export { clickElement } from './engine/core/click.mjs'; -export { isConnected, getPage, setPreserveClipboard, ensureConnected }; -export async function saveClipboard() { - if (!page) return; - try { - await page.evaluate(async () => { - try { - const items = await navigator.clipboard.read(); - const saved = []; - for (const item of items) { - const types = {}; - for (const t of item.types) types[t] = await item.getType(t); - saved.push(types); - } - window.__webTestSavedClipboard = saved; - delete window.__webTestClipboardError; - } catch (e) { - window.__webTestSavedClipboard = null; - window.__webTestClipboardError = e?.name || String(e); - } - }); - } catch { - // page.evaluate itself failed (closed page, navigation in flight) — skip. - } -} -export async function restoreClipboard() { - if (!page) return; - let err = null; - try { - err = await page.evaluate(async () => { - const saved = window.__webTestSavedClipboard; - const captured = window.__webTestClipboardError || null; - delete window.__webTestSavedClipboard; - delete window.__webTestClipboardError; - try { - if (!saved || saved.length === 0) { - // Save failed (e.g. CF_HDROP from Explorer not readable via Clipboard API) - // or buffer was empty. Either way, the test's writeText already destroyed - // any prior native formats in the OS clipboard, so explicitly clear here - // to avoid leaking the test value into the user's clipboard. - await navigator.clipboard.writeText(''); - return captured; - } - const items = saved.map(types => new ClipboardItem(types)); - await navigator.clipboard.write(items); - return null; - } catch (e) { - return e?.name || String(e); - } - }); - } catch { - return; - } - if (err && !clipboardWarnLogged) { - setClipboardWarnLogged(true); - console.error(`[web-test] clipboard preserve skipped: ${err} (logged once per session)`); - } -} - -/** - * Paste `text` via OS clipboard (the only trusted-paste path that 1C respects - * for autocomplete and Cyrillic). Wraps the writeText+confirm-key pair in a - * narrow save/restore so a user's clipboard survives the test run — the window - * between save and restore is microseconds. - * - * - `confirm` — key (string) or sequence (array) to press after writeText. - * Defaults to 'Control+V'. Use ['Control+a', 'Control+v'] for select-all-then-paste, - * or 'Shift+F11' for the goto-link dialog. - * - `postDelay` — ms to wait between confirm-press and restore, for dialogs - * that read clipboard asynchronously (e.g. Shift+F11). Default 0. - */ -export async function pasteText(text, { confirm = 'Control+V', postDelay = 0 } = {}) { - if (!page) return; - if (preserveClipboard) await saveClipboard(); - try { - await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); - if (Array.isArray(confirm)) { - for (const key of confirm) await page.keyboard.press(key); - } else if (confirm) { - await page.keyboard.press(confirm); - } - if (postDelay) await page.waitForTimeout(postDelay); - } finally { - if (preserveClipboard) await restoreClipboard(); - } -} - -// ============================================================ -// Session lifecycle + multi-context — extracted to core/session.mjs -// ============================================================ +// ── session ─────────────────────────────────────────────────────────────── export { connect, disconnect, attach, detach, getSession, createContext, setActiveContext, listContexts, getActiveContext, hasContext, closeContext, } from './engine/core/session.mjs'; -// ============================================================ -// Wait + error/modal handling — extracted to core/{wait,errors}.mjs -// ============================================================ -import { - waitForStable, waitForCondition, startNetworkMonitor, -} from './engine/core/wait.mjs'; -import { - closeModals, checkForErrors, dismissPendingErrors, fetchErrorStack, - _detectPlatformDialogs, _closePlatformDialogs, -} from './engine/core/errors.mjs'; -import { - safeClick, findFieldInputId, readEdd, returnFormState, - detectNewForm as helperDetectNewForm, -} from './engine/core/helpers.mjs'; -import { getGridToggleIcon, shouldClickToggle } from './engine/table/grid-toggle.mjs'; -// Re-export only what was publicly exported before the refactor. -// waitForStable/waitForCondition/startNetworkMonitor/closeModals/checkForErrors/ -// dismissPendingErrors are internal helpers — imported above for local use only. -export { fetchErrorStack } from './engine/core/errors.mjs'; - -/* getPage moved to core/state.mjs */ - -// ============================================================ -// Navigation — extracted to nav/navigation.mjs -// ============================================================ +// ── navigation ──────────────────────────────────────────────────────────── export { getPageState, getSections, navigateSection, getCommands, openCommand, switchTab, openFile, navigateLink, } from './engine/nav/navigation.mjs'; -/** Read current form state. Single evaluate call via combined script. */ -export async function getFormState() { - ensureConnected(); - const state = await page.evaluate(getFormStateScript()); - const err = await checkForErrors(); - if (err) { - state.errors = err; - if (err.confirmation) { - state.confirmation = err.confirmation; - state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond'; - } - } - // Detect platform-level dialogs (About, Support Info, Error Report) - // These are NOT 1C forms — invisible to detectForms() and not closeable via Escape. - const pd = await _detectPlatformDialogs(); - if (pd.length) state.platformDialogs = pd; - return state; -} - -// ============================================================ -// Table reading + SpreadsheetDocument — extracted to table/spreadsheet.mjs -// ============================================================ -export { readTable } from './engine/table/grid.mjs'; -export { readSpreadsheet } from './engine/table/spreadsheet.mjs'; - - -// ============================================================ -// Value selection (DLB/CB) — extracted to forms/select-value.mjs -// ============================================================ +// ── forms ───────────────────────────────────────────────────────────────── export { selectValue } from './engine/forms/select-value.mjs'; -import { - selectValue, pickFromSelectionForm, isTypeDialog, pickFromTypeDialog, - fillReferenceField, -} from './engine/forms/select-value.mjs'; - - - -// ============================================================ -// Fill fields — extracted to forms/fill.mjs -// ============================================================ export { fillFields, fillField } from './engine/forms/fill.mjs'; - - -// ============================================================ -// clickElement dispatcher — extracted to core/click.mjs -// ============================================================ -export { clickElement } from './engine/core/click.mjs'; -import { clickElement } from './engine/core/click.mjs'; - -// ============================================================ -// Close form — extracted to forms/close.mjs -// ============================================================ export { closeForm } from './engine/forms/close.mjs'; - - -// ============================================================ -// fillTableRow / deleteTableRow — extracted to table/{row-fill,grid}.mjs -// ============================================================ +// ── tables ──────────────────────────────────────────────────────────────── +export { readTable, deleteTableRow } from './engine/table/grid.mjs'; +export { readSpreadsheet } from './engine/table/spreadsheet.mjs'; export { fillTableRow } from './engine/table/row-fill.mjs'; -export { deleteTableRow } from './engine/table/grid.mjs'; - -// ============================================================ -// List filters — extracted to table/filter.mjs -// ============================================================ export { filterList, unfilterList } from './engine/table/filter.mjs'; - -// ============================================================ -// Recording, captions, narration, highlight — extracted to recording/* -// ============================================================ +// ── recording / overlays ────────────────────────────────────────────────── export { screenshot, wait, isRecording, startRecording, stopRecording, } from './engine/recording/capture.mjs'; @@ -245,5 +54,3 @@ export { highlight, unhighlight, setHighlight, isHighlightMode, } from './engine/recording/highlight.mjs'; export { addNarration } from './engine/recording/narration.mjs'; - -/* ensureConnected moved to core/state.mjs */ diff --git a/.claude/skills/web-test/scripts/engine/core/click.mjs b/.claude/skills/web-test/scripts/engine/core/click.mjs index e33cc575..c8e46141 100644 --- a/.claude/skills/web-test/scripts/engine/core/click.mjs +++ b/.claude/skills/web-test/scripts/engine/core/click.mjs @@ -1,4 +1,4 @@ -// web-test core/click v1.16 — clickElement dispatcher: spreadsheet cells, submenus, grid groups/trees, buttons/links, tabs. +// web-test core/click v1.17 — clickElement dispatcher: spreadsheet cells, submenus, grid groups/trees, buttons/links, tabs. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -16,7 +16,7 @@ import { clickSpreadsheetCell, findSpreadsheetCellByText, } from '../table/spreadsheet.mjs'; // getFormState still in browser.mjs. -import { getFormState } from '../../browser.mjs'; +import { getFormState } from './form-state.mjs'; /** 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. */ diff --git a/.claude/skills/web-test/scripts/engine/core/clipboard.mjs b/.claude/skills/web-test/scripts/engine/core/clipboard.mjs new file mode 100644 index 00000000..2e51c96e --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/core/clipboard.mjs @@ -0,0 +1,97 @@ +// web-test engine/core/clipboard v1.17 — OS-clipboard preservation around trusted paste. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// pasteText() — the only path 1C respects for autocomplete and Cyrillic input. +// saveClipboard/restoreClipboard preserve full clipboard contents (all MIME +// types) around the writeText+Ctrl+V pair so a user's concurrent Ctrl+C isn't +// clobbered. Blobs are stashed on `window` to avoid CDP serialization. + +import { + page, preserveClipboard, clipboardWarnLogged, setClipboardWarnLogged, +} from './state.mjs'; + +export async function saveClipboard() { + if (!page) return; + try { + await page.evaluate(async () => { + try { + const items = await navigator.clipboard.read(); + const saved = []; + for (const item of items) { + const types = {}; + for (const t of item.types) types[t] = await item.getType(t); + saved.push(types); + } + window.__webTestSavedClipboard = saved; + delete window.__webTestClipboardError; + } catch (e) { + window.__webTestSavedClipboard = null; + window.__webTestClipboardError = e?.name || String(e); + } + }); + } catch { + // page.evaluate itself failed (closed page, navigation in flight) — skip. + } +} + +export async function restoreClipboard() { + if (!page) return; + let err = null; + try { + err = await page.evaluate(async () => { + const saved = window.__webTestSavedClipboard; + const captured = window.__webTestClipboardError || null; + delete window.__webTestSavedClipboard; + delete window.__webTestClipboardError; + try { + if (!saved || saved.length === 0) { + // Save failed (e.g. CF_HDROP from Explorer not readable via Clipboard API) + // or buffer was empty. Either way, the test's writeText already destroyed + // any prior native formats in the OS clipboard, so explicitly clear here + // to avoid leaking the test value into the user's clipboard. + await navigator.clipboard.writeText(''); + return captured; + } + const items = saved.map(types => new ClipboardItem(types)); + await navigator.clipboard.write(items); + return null; + } catch (e) { + return e?.name || String(e); + } + }); + } catch { + return; + } + if (err && !clipboardWarnLogged) { + setClipboardWarnLogged(true); + console.error(`[web-test] clipboard preserve skipped: ${err} (logged once per session)`); + } +} + +/** + * Paste `text` via OS clipboard (the only trusted-paste path that 1C respects + * for autocomplete and Cyrillic). Wraps the writeText+confirm-key pair in a + * narrow save/restore so a user's clipboard survives the test run — the window + * between save and restore is microseconds. + * + * - `confirm` — key (string) or sequence (array) to press after writeText. + * Defaults to 'Control+V'. Use ['Control+a', 'Control+v'] for select-all-then-paste, + * or 'Shift+F11' for the goto-link dialog. + * - `postDelay` — ms to wait between confirm-press and restore, for dialogs + * that read clipboard asynchronously (e.g. Shift+F11). Default 0. + */ +export async function pasteText(text, { confirm = 'Control+V', postDelay = 0 } = {}) { + if (!page) return; + if (preserveClipboard) await saveClipboard(); + try { + await page.evaluate(`navigator.clipboard.writeText(${JSON.stringify(String(text))})`); + if (Array.isArray(confirm)) { + for (const key of confirm) await page.keyboard.press(key); + } else if (confirm) { + await page.keyboard.press(confirm); + } + if (postDelay) await page.waitForTimeout(postDelay); + } finally { + if (preserveClipboard) await restoreClipboard(); + } +} diff --git a/.claude/skills/web-test/scripts/engine/core/errors.mjs b/.claude/skills/web-test/scripts/engine/core/errors.mjs index 1d37a048..d3945da9 100644 --- a/.claude/skills/web-test/scripts/engine/core/errors.mjs +++ b/.claude/skills/web-test/scripts/engine/core/errors.mjs @@ -1,4 +1,4 @@ -// web-test core/errors v1.16 — Error/modal/platform-dialog handling: dismiss, detect, fetch stack from 1C UI. +// web-test core/errors v1.17 — Error/modal/platform-dialog handling: dismiss, detect, fetch stack from 1C UI. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page } from './state.mjs'; @@ -62,8 +62,8 @@ export async function dismissPendingErrors() { // Close leftover platform dialogs first (About, Support Info, Error Report) // These block all interaction via modalSurface and are invisible to 1C form detection try { - const pd = await _detectPlatformDialogs(); - if (pd.length) await _closePlatformDialogs(); + const pd = await detectPlatformDialogs(); + if (pd.length) await closePlatformDialogs(); } catch { /* OK */ } const err = await checkForErrors(); if (!err?.modal) return null; @@ -84,7 +84,7 @@ export async function dismissPendingErrors() { * Detect open platform-level dialogs (About, Support Info, Error Report). * Returns array of { type, title? } for each detected dialog, or empty array. */ -export async function _detectPlatformDialogs() { +export async function detectPlatformDialogs() { return await page.evaluate(() => { const result = []; // "О программе" dialog @@ -114,7 +114,7 @@ export async function _detectPlatformDialogs() { * These are NOT 1C forms — they are platform UI overlays invisible to getFormState(). * Each close is wrapped in try/catch to avoid cascading failures. */ -export async function _closePlatformDialogs() { +export async function closePlatformDialogs() { await page.evaluate(() => { // "Подробный текст ошибки" OK button (inside error report detail view) // It's a cloud window with its own OK button — look for visible pressDefault in small ps*win @@ -142,7 +142,7 @@ export async function _closePlatformDialogs() { * Input: raw text from errJournalInput (first block) or "Подробный текст ошибки" textarea. * Returns { raw, timestamp?, entries: [{location, code}] } */ -function _parseErrorStack(raw) { +function parseErrorStack(raw) { if (!raw) return null; const result = { raw, entries: [] }; // Extract timestamp if present (format: DD.MM.YYYY HH:MM:SS) @@ -180,13 +180,13 @@ export async function fetchErrorStack(formNum, hasReport) { return !!(el && el.offsetWidth > 2 && el.textContent.trim()); }, formNum); } - if (hasReport) return await _fetchStackViaReport(formNum); - return await _fetchStackViaHamburger(formNum); + if (hasReport) return await fetchStackViaReport(formNum); + return await fetchStackViaHamburger(formNum); } catch { return null; } finally { // Ensure all platform dialogs are closed - try { await _closePlatformDialogs(); } catch {} + try { await closePlatformDialogs(); } catch {} // Ensure the error modal itself is closed try { const sel = formNum != null @@ -203,7 +203,7 @@ export async function fetchErrorStack(formNum, hasReport) { * Path 1: Fetch stack via OpenReport link (for platform exceptions). * The error modal must still be open with a visible "Сформировать отчет об ошибке" link. */ -async function _fetchStackViaReport(formNum) { +async function fetchStackViaReport(formNum) { // 1. Get coordinates of the OpenReport link and click via mouse (modalSurface blocks JS clicks) const coords = await page.evaluate((fn) => { const el = document.getElementById('form' + fn + '_OpenReport#text'); @@ -274,7 +274,7 @@ async function _fetchStackViaReport(formNum) { await page.waitForTimeout(300); } catch {} - return _parseErrorStack(raw); + return parseErrorStack(raw); } /** @@ -282,7 +282,7 @@ async function _fetchStackViaReport(formNum) { * Works for all error types including simple ВызватьИсключение. * The error modal is closed first to allow access to the hamburger menu. */ -async function _fetchStackViaHamburger(formNum) { +async function fetchStackViaHamburger(formNum) { // 1. Close the error modal first try { const sel = formNum != null @@ -336,6 +336,6 @@ async function _fetchStackViaHamburger(formNum) { } const firstBlock = firstBlockLines.join('\n').trim(); - // 7. Close support info and about dialogs (done in finally via _closePlatformDialogs) - return _parseErrorStack(firstBlock || errorText); + // 7. Close support info and about dialogs (done in finally via closePlatformDialogs) + return parseErrorStack(firstBlock || errorText); } diff --git a/.claude/skills/web-test/scripts/engine/core/form-state.mjs b/.claude/skills/web-test/scripts/engine/core/form-state.mjs new file mode 100644 index 00000000..12391936 --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/core/form-state.mjs @@ -0,0 +1,32 @@ +// web-test engine/core/form-state v1.17 — central form-state reader. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// getFormState — the canonical "what's on the screen right now" call. Combines: +// 1. DOM script (getFormStateScript) → form structure (fields, buttons, tables, openForms, ...) +// 2. checkForErrors → state.errors + state.confirmation hint +// 3. detectPlatformDialogs → state.platformDialogs (About / Support Info / Error Report) +// +// Returned by virtually every action-function as the "after" snapshot. + +import { page, ensureConnected } from './state.mjs'; +import { getFormStateScript } from '../../dom.mjs'; +import { checkForErrors, detectPlatformDialogs } from './errors.mjs'; + +/** Read current form state. Single evaluate call via combined script. */ +export async function getFormState() { + ensureConnected(); + const state = await page.evaluate(getFormStateScript()); + const err = await checkForErrors(); + if (err) { + state.errors = err; + if (err.confirmation) { + state.confirmation = err.confirmation; + state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond'; + } + } + // Detect platform-level dialogs (About, Support Info, Error Report) + // These are NOT 1C forms — invisible to detectForms() and not closeable via Escape. + const pd = await detectPlatformDialogs(); + if (pd.length) state.platformDialogs = pd; + return state; +} diff --git a/.claude/skills/web-test/scripts/engine/core/helpers.mjs b/.claude/skills/web-test/scripts/engine/core/helpers.mjs index c6d1af26..595f1e9f 100644 --- a/.claude/skills/web-test/scripts/engine/core/helpers.mjs +++ b/.claude/skills/web-test/scripts/engine/core/helpers.mjs @@ -1,10 +1,10 @@ -// web-test core/helpers v1.16 — private, cross-cutting helpers used by the +// web-test core/helpers v1.17 — private, cross-cutting helpers used by the // public action functions (clickElement/fillFields/selectValue/etc). // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page } from './state.mjs'; import { dismissPendingErrors, checkForErrors } from './errors.mjs'; -import { getFormState } from '../../browser.mjs'; +import { getFormState } from './form-state.mjs'; /** * page.click with the standard "intercepts pointer events" retry ladder: diff --git a/.claude/skills/web-test/scripts/engine/core/session.mjs b/.claude/skills/web-test/scripts/engine/core/session.mjs index d2b7dfbf..c33be755 100644 --- a/.claude/skills/web-test/scripts/engine/core/session.mjs +++ b/.claude/skills/web-test/scripts/engine/core/session.mjs @@ -1,4 +1,4 @@ -// web-test core/session v1.16 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry. +// web-test core/session v1.17 — Browser session lifecycle: connect/disconnect/attach/detach, multi-context registry. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { chromium } from 'playwright'; @@ -14,10 +14,7 @@ import { } from './state.mjs'; import { closeModals } from './errors.mjs'; import { stopRecording } from '../recording/capture.mjs'; -// getPageState lives in browser.mjs (moves to nav/navigation.mjs in a later stage). -// Static import is a deliberate ESM cycle — fine because the binding is used at -// call time (inside async connect/createContext), not at module evaluation time. -import { getPageState } from '../../browser.mjs'; +import { getPageState } from '../nav/navigation.mjs'; /** * Find the 1C browser extension in Chrome/Edge user profiles. @@ -125,7 +122,7 @@ export async function connect(url, { extensionPath } = {}) { * @param {object} slot { page, sessionPrefix, seanceId } from contexts Map * @param {number} [waitMs=500] pause after logout fetch (gives 1C time to process) */ -async function _logoutSlot(slot, waitMs = 500) { +async function logoutSlot(slot, waitMs = 500) { if (!slot?.page || slot.page.isClosed() || !slot.seanceId || !slot.sessionPrefix) return; try { const logoutUrl = `${slot.sessionPrefix}/e1cib/logout?seanceId=${slot.seanceId}`; @@ -143,13 +140,13 @@ async function _logoutSlot(slot, waitMs = 500) { export async function disconnect() { // Multi-context path: stop recording + logout each slot before closing browser if (contexts.size > 0) { - _saveActiveSlot(); + saveActiveSlot(); // Recorder is global — one stop covers all contexts if (recorder) { try { await stopRecording(); } catch {} } for (const [, slot] of contexts.entries()) { - await _logoutSlot(slot); + await logoutSlot(slot); } contexts.clear(); setActiveContextName(null); @@ -163,7 +160,7 @@ export async function disconnect() { if (browser) { // Graceful logout — release the 1C license (single-session connect path) - await _logoutSlot({ page, sessionPrefix, seanceId }, 1000); + await logoutSlot({ page, sessionPrefix, seanceId }, 1000); await browser.close().catch(() => {}); setBrowser(null); setPage(null); @@ -217,7 +214,7 @@ export function getSession() { * Save current module-level state into the active slot before switching. * No-op if no active slot. */ -function _saveActiveSlot() { +function saveActiveSlot() { if (!activeContextName) return; const slot = contexts.get(activeContextName); if (!slot) return; @@ -231,7 +228,7 @@ function _saveActiveSlot() { } /** Load a slot's state into module-level vars and mark it active. */ -function _activateSlot(name) { +function activateSlot(name) { const slot = contexts.get(name); if (!slot) throw new Error(`Context "${name}" not found. Create it via createContext() first.`); setPage(slot.page); @@ -242,7 +239,7 @@ function _activateSlot(name) { } /** Attach 1C session listeners to a page, writing into the given slot. */ -function _attachSessionListeners(pg, slot, name) { +function attachSessionListeners(pg, slot, name) { pg.on('dialog', dialog => dialog.accept().catch(() => {})); pg.on('request', req => { if (slot.seanceId) return; @@ -311,7 +308,7 @@ export async function createContext(name, url, { extensionPath, isolation = 'tab } // Save current active before switching - _saveActiveSlot(); + saveActiveSlot(); // Create slot — page differs by mode let newCtx, newPage; @@ -341,8 +338,8 @@ export async function createContext(name, url, { extensionPath, isolation = 'tab }; contexts.set(name, slot); - _attachSessionListeners(newPage, slot, name); - _activateSlot(name); + attachSessionListeners(newPage, slot, name); + activateSlot(name); await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); try { await page.waitForSelector('#themesCell_theme_0', { timeout: INIT_TIMEOUT }); } @@ -359,8 +356,8 @@ export async function setActiveContext(name) { // If a recording is active, flush the outgoing page's last frame so the gap is filled // up to the moment of the switch (avoids a "jump" in video time). if (recorder && recorder._flushFrames) recorder._flushFrames(); - _saveActiveSlot(); - _activateSlot(name); + saveActiveSlot(); + activateSlot(name); // If the recording is still alive (it lives across slots — we keep the same ffmpeg/output), // re-attach its screencast to the newly active page. if (recorder && recorder._attachPage) { @@ -397,7 +394,7 @@ export async function closeContext(name) { throw new Error(`closeContext: cannot close the active context "${name}". setActiveContext to another context first.`); } const slot = contexts.get(name); - await _logoutSlot(slot); + await logoutSlot(slot); if (activeMode === 'tab') { try { await slot.page.close(); } catch {} } else { diff --git a/.claude/skills/web-test/scripts/engine/core/state.mjs b/.claude/skills/web-test/scripts/engine/core/state.mjs index 6c193769..985c065b 100644 --- a/.claude/skills/web-test/scripts/engine/core/state.mjs +++ b/.claude/skills/web-test/scripts/engine/core/state.mjs @@ -1,4 +1,4 @@ -// web-test core/state v1.16 — module-level state for the web-test engine. +// web-test core/state v1.17 — module-level state for the web-test engine. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills // // Holds the single browser/page/recorder slot plus the multi-context registry, diff --git a/.claude/skills/web-test/scripts/engine/core/wait.mjs b/.claude/skills/web-test/scripts/engine/core/wait.mjs index 20c84ed2..c584bc52 100644 --- a/.claude/skills/web-test/scripts/engine/core/wait.mjs +++ b/.claude/skills/web-test/scripts/engine/core/wait.mjs @@ -1,4 +1,4 @@ -// web-test core/wait v1.16 — Smart wait helpers: DOM stability polling, JS-expression polling, CDP network monitor. +// web-test core/wait v1.17 — Smart wait helpers: DOM stability polling, JS-expression polling, CDP network monitor. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, MAX_WAIT, POLL_INTERVAL, STABLE_CYCLES } from './state.mjs'; diff --git a/.claude/skills/web-test/scripts/engine/forms/close.mjs b/.claude/skills/web-test/scripts/engine/forms/close.mjs index c4faae14..6c5be6dd 100644 --- a/.claude/skills/web-test/scripts/engine/forms/close.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/close.mjs @@ -1,11 +1,11 @@ -// web-test forms/close v1.16 — Close current form via Escape, handle save-changes confirmation. +// web-test forms/close v1.17 — 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 { dismissPendingErrors, checkForErrors, detectPlatformDialogs, closePlatformDialogs } from '../core/errors.mjs'; import { waitForStable } from '../core/wait.mjs'; -import { getFormState } from '../../browser.mjs'; +import { getFormState } from '../core/form-state.mjs'; /** * Close the current form/dialog via Escape. @@ -19,9 +19,9 @@ export async function closeForm({ save } = {}) { ensureConnected(); await dismissPendingErrors(); // If platform dialogs are open, close them instead of pressing Escape - const pd = await _detectPlatformDialogs(); + const pd = await detectPlatformDialogs(); if (pd.length) { - await _closePlatformDialogs(); + await closePlatformDialogs(); await page.waitForTimeout(300); const state = await getFormState(); state.closed = true; diff --git a/.claude/skills/web-test/scripts/engine/forms/fill.mjs b/.claude/skills/web-test/scripts/engine/forms/fill.mjs index dcd669e7..8783fa9a 100644 --- a/.claude/skills/web-test/scripts/engine/forms/fill.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/fill.mjs @@ -1,4 +1,4 @@ -// web-test forms/fill v1.16 — Fill form fields by name (text/checkbox/date/dropdown/reference). Delegates references to selectValue / fillReferenceField. +// web-test forms/fill v1.17 — 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 { @@ -15,7 +15,8 @@ import { isTypeDialog, pickFromTypeDialog, } from './select-value.mjs'; // pasteText + getFormState live in browser.mjs. -import { pasteText, getFormState } from '../../browser.mjs'; +import { pasteText } from '../core/clipboard.mjs'; +import { getFormState } from '../core/form-state.mjs'; /** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */ export async function fillFields(fields) { diff --git a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs index bfe0892d..dc6bbdf6 100644 --- a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs @@ -16,7 +16,8 @@ import { detectNewForm as helperDetectNewForm, } from '../core/helpers.mjs'; // pasteText + getFormState live in browser.mjs. -import { pasteText, getFormState } from '../../browser.mjs'; +import { pasteText } from '../core/clipboard.mjs'; +import { getFormState } from '../core/form-state.mjs'; /** * Scan visible grid rows for a text match (exact → startsWith → includes). diff --git a/.claude/skills/web-test/scripts/engine/nav/navigation.mjs b/.claude/skills/web-test/scripts/engine/nav/navigation.mjs index 060396e6..0c002aab 100644 --- a/.claude/skills/web-test/scripts/engine/nav/navigation.mjs +++ b/.claude/skills/web-test/scripts/engine/nav/navigation.mjs @@ -15,7 +15,8 @@ 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'; +import { pasteText } from '../core/clipboard.mjs'; +import { getFormState } from '../core/form-state.mjs'; /** * Get current page state: active section, tabs. diff --git a/.claude/skills/web-test/scripts/engine/recording/captions.mjs b/.claude/skills/web-test/scripts/engine/recording/captions.mjs index c70cd987..1043bc5b 100644 --- a/.claude/skills/web-test/scripts/engine/recording/captions.mjs +++ b/.claude/skills/web-test/scripts/engine/recording/captions.mjs @@ -1,4 +1,4 @@ -// web-test recording/captions v1.16 — Overlay primitives: captions, title slides, image overlays. +// web-test recording/captions v1.17 — Overlay primitives: captions, title slides, image overlays. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { existsSync as fsExistsSync, readFileSync } from 'fs'; diff --git a/.claude/skills/web-test/scripts/engine/recording/capture.mjs b/.claude/skills/web-test/scripts/engine/recording/capture.mjs index d89f5ab1..dcbe1c02 100644 --- a/.claude/skills/web-test/scripts/engine/recording/capture.mjs +++ b/.claude/skills/web-test/scripts/engine/recording/capture.mjs @@ -1,4 +1,4 @@ -// web-test recording/capture v1.16 — Recording lifecycle (CDP screencast + ffmpeg pipe), screenshot, wait helpers. +// web-test recording/capture v1.17 — Recording lifecycle (CDP screencast + ffmpeg pipe), screenshot, wait helpers. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { spawn } from 'child_process'; @@ -45,7 +45,7 @@ export async function wait(seconds) { await page.waitForTimeout(ms); } } - const { getFormState } = await import('../../browser.mjs'); + const { getFormState } = await import('../core/form-state.mjs'); return await getFormState(); } diff --git a/.claude/skills/web-test/scripts/engine/recording/highlight.mjs b/.claude/skills/web-test/scripts/engine/recording/highlight.mjs index a8fa3e6c..ea61de2c 100644 --- a/.claude/skills/web-test/scripts/engine/recording/highlight.mjs +++ b/.claude/skills/web-test/scripts/engine/recording/highlight.mjs @@ -1,4 +1,4 @@ -// web-test recording/highlight v1.16 — Visual highlight overlay (single + auto-mode for clickElement/fillFields/selectValue). +// web-test recording/highlight v1.17 — Visual highlight overlay (single + auto-mode for clickElement/fillFields/selectValue). // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { diff --git a/.claude/skills/web-test/scripts/engine/recording/narration.mjs b/.claude/skills/web-test/scripts/engine/recording/narration.mjs index dff34e0a..6891fddf 100644 --- a/.claude/skills/web-test/scripts/engine/recording/narration.mjs +++ b/.claude/skills/web-test/scripts/engine/recording/narration.mjs @@ -1,4 +1,4 @@ -// web-test recording/narration v1.16 — Post-process: generate TTS audio for captions and merge with recorded video. +// web-test recording/narration v1.17 — Post-process: generate TTS audio for captions and merge with recorded video. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { execFileSync } from 'child_process'; diff --git a/.claude/skills/web-test/scripts/engine/recording/tts.mjs b/.claude/skills/web-test/scripts/engine/recording/tts.mjs index 0a965fb0..fd218540 100644 --- a/.claude/skills/web-test/scripts/engine/recording/tts.mjs +++ b/.claude/skills/web-test/scripts/engine/recording/tts.mjs @@ -1,4 +1,4 @@ -// web-test recording/tts v1.16 — TTS providers (edge/openai/elevenlabs) and ffmpeg/ffprobe helpers. +// web-test recording/tts v1.17 — TTS providers (edge/openai/elevenlabs) and ffmpeg/ffprobe helpers. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { execFileSync, spawn } from 'child_process'; diff --git a/.claude/skills/web-test/scripts/engine/table/filter.mjs b/.claude/skills/web-test/scripts/engine/table/filter.mjs index db3ba8d1..03df38c1 100644 --- a/.claude/skills/web-test/scripts/engine/table/filter.mjs +++ b/.claude/skills/web-test/scripts/engine/table/filter.mjs @@ -1,4 +1,4 @@ -// web-test table/filter v1.16 — filterList / unfilterList — simple search + advanced-column filter badges. +// web-test table/filter v1.17 — filterList / unfilterList — simple search + advanced-column filter badges. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs'; @@ -8,8 +8,9 @@ import { waitForStable, waitForCondition } from '../core/wait.mjs'; import { highlight, unhighlight } from '../recording/highlight.mjs'; import { safeClick } from '../core/helpers.mjs'; import { selectValue, fillReferenceField } from '../forms/select-value.mjs'; -// pasteText + getFormState + clickElement still in browser.mjs. -import { pasteText, getFormState, clickElement } from '../../browser.mjs'; +import { pasteText } from '../core/clipboard.mjs'; +import { getFormState } from '../core/form-state.mjs'; +import { clickElement } from '../core/click.mjs'; /** * Filter the current list by field value, or search via search bar. diff --git a/.claude/skills/web-test/scripts/engine/table/grid-toggle.mjs b/.claude/skills/web-test/scripts/engine/table/grid-toggle.mjs index cf5e7a2d..5fe96d3f 100644 --- a/.claude/skills/web-test/scripts/engine/table/grid-toggle.mjs +++ b/.claude/skills/web-test/scripts/engine/table/grid-toggle.mjs @@ -1,4 +1,4 @@ -// web-test table/grid-toggle v1.16 — shared icon-detection for grid expand/ +// web-test table/grid-toggle v1.17 — shared icon-detection for grid expand/ // collapse toggles. Used by clickElement's gridGroup/gridParent and // gridTreeNode branches; the actual mouse click stays in the caller because // it depends on the caller-local modifier-key handling. diff --git a/.claude/skills/web-test/scripts/engine/table/grid.mjs b/.claude/skills/web-test/scripts/engine/table/grid.mjs index b97b4d6b..9e0be5a9 100644 --- a/.claude/skills/web-test/scripts/engine/table/grid.mjs +++ b/.claude/skills/web-test/scripts/engine/table/grid.mjs @@ -1,4 +1,4 @@ -// web-test table/grid v1.16 — Form-grid operations: read table rows, fill rows, delete rows. +// web-test table/grid v1.17 — Form-grid operations: read table rows, fill rows, delete rows. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills // // "Grid" в терминах 1С — таблица на форме (.gridLine/.gridBody/.grid в DOM): @@ -11,7 +11,7 @@ import { dismissPendingErrors } from '../core/errors.mjs'; import { waitForStable } from '../core/wait.mjs'; import { clickElement } from '../core/click.mjs'; // getFormState lives in browser.mjs. -import { getFormState } from '../../browser.mjs'; +import { getFormState } from '../core/form-state.mjs'; /** Read structured table data with pagination. Returns columns, rows, total count. */ export async function readTable({ maxRows = 20, offset = 0, table } = {}) { diff --git a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs index 0f7ec083..d8ad78c8 100644 --- a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs +++ b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs @@ -1,4 +1,4 @@ -// web-test table/row-fill v1.16 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. +// web-test table/row-fill v1.17 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -20,7 +20,8 @@ import { fillReferenceField, selectValue, } from '../forms/select-value.mjs'; // pasteText + getFormState still in browser.mjs (cycle). -import { pasteText, getFormState } from '../../browser.mjs'; +import { pasteText } from '../core/clipboard.mjs'; +import { getFormState } from '../core/form-state.mjs'; /** * Fill cells in the current table row via Tab navigation. diff --git a/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs b/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs index 6f5c7522..99bad349 100644 --- a/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs +++ b/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs @@ -1,11 +1,11 @@ -// web-test table/spreadsheet v1.16 — readTable, readSpreadsheet, scanSpreadsheetCells, scroll/click helpers for SpreadsheetDocument. +// web-test table/spreadsheet v1.17 — readTable, readSpreadsheet, scanSpreadsheetCells, scroll/click helpers for SpreadsheetDocument. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, ensureConnected, normYo } from '../core/state.mjs'; import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs'; import { waitForStable } from '../core/wait.mjs'; // getFormState still in browser.mjs (cycle resolves at call time). -import { getFormState } from '../../browser.mjs'; +import { getFormState } from '../core/form-state.mjs'; // readTable moved to table/grid.mjs (form-grid операции отделены от SpreadsheetDocument). From ab107616671e082f2d28459d7546cb79998f12f4 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 16:42:17 +0300 Subject: [PATCH 23/47] =?UTF-8?q?chore(web-test):=20=D0=BF=D0=BE=D1=87?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B8=D1=82=D1=8C=20=D1=83=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=88=D0=B8=D0=B5=20=D0=BA=D0=BE=D0=BC=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B8=20=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B5=D0=B8=D1=81=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D1=83=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B=D0=B5=20=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit После полной чистки cycle-импортов в E.13 остались комментарии типа "getFormState still in browser.mjs", которые больше не верны (он переехал в engine/core/form-state.mjs). Сметаем устаревшие "moved to / lives in browser.mjs" комментарии в 8 файлах. Дополнительно в engine/table/spreadsheet.mjs: - убраны неиспользуемые импорты readTableScript, resolveGridScript, normYo (остались с тех пор, как readTable жил в этом файле — до этапа D.12 rename'а в grid.mjs) - заголовочный комментарий обновлён (без упоминания readTable) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../skills/web-test/scripts/engine/core/click.mjs | 3 +-- .../skills/web-test/scripts/engine/forms/fill.mjs | 3 +-- .../web-test/scripts/engine/forms/select-value.mjs | 3 +-- .../web-test/scripts/engine/nav/navigation.mjs | 3 +-- .../web-test/scripts/engine/recording/capture.mjs | 3 +-- .../skills/web-test/scripts/engine/table/grid.mjs | 3 +-- .../web-test/scripts/engine/table/row-fill.mjs | 3 +-- .../web-test/scripts/engine/table/spreadsheet.mjs | 13 +++++-------- 8 files changed, 12 insertions(+), 22 deletions(-) diff --git a/.claude/skills/web-test/scripts/engine/core/click.mjs b/.claude/skills/web-test/scripts/engine/core/click.mjs index c8e46141..e796b6c8 100644 --- a/.claude/skills/web-test/scripts/engine/core/click.mjs +++ b/.claude/skills/web-test/scripts/engine/core/click.mjs @@ -14,8 +14,7 @@ import { safeClick } from './helpers.mjs'; import { getGridToggleIcon, shouldClickToggle } from '../table/grid-toggle.mjs'; import { clickSpreadsheetCell, findSpreadsheetCellByText, -} from '../table/spreadsheet.mjs'; -// getFormState still in browser.mjs. +} from '../table/spreadsheet.mjs'; import { getFormState } from './form-state.mjs'; /** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists). diff --git a/.claude/skills/web-test/scripts/engine/forms/fill.mjs b/.claude/skills/web-test/scripts/engine/forms/fill.mjs index 8783fa9a..8da09047 100644 --- a/.claude/skills/web-test/scripts/engine/forms/fill.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/fill.mjs @@ -13,8 +13,7 @@ import { highlight, unhighlight } from '../recording/highlight.mjs'; import { fillReferenceField, selectValue, pickFromSelectionForm, isTypeDialog, pickFromTypeDialog, -} from './select-value.mjs'; -// pasteText + getFormState live in browser.mjs. +} from './select-value.mjs'; import { pasteText } from '../core/clipboard.mjs'; import { getFormState } from '../core/form-state.mjs'; diff --git a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs index dc6bbdf6..ec22c9a6 100644 --- a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs @@ -14,8 +14,7 @@ import { highlight, unhighlight } from '../recording/highlight.mjs'; import { safeClick, findFieldInputId, readEdd, detectNewForm as helperDetectNewForm, -} from '../core/helpers.mjs'; -// pasteText + getFormState live in browser.mjs. +} from '../core/helpers.mjs'; import { pasteText } from '../core/clipboard.mjs'; import { getFormState } from '../core/form-state.mjs'; diff --git a/.claude/skills/web-test/scripts/engine/nav/navigation.mjs b/.claude/skills/web-test/scripts/engine/nav/navigation.mjs index 0c002aab..621ef72e 100644 --- a/.claude/skills/web-test/scripts/engine/nav/navigation.mjs +++ b/.claude/skills/web-test/scripts/engine/nav/navigation.mjs @@ -12,8 +12,7 @@ import { 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). +import { returnFormState } from '../core/helpers.mjs'; // Static import — ESM cycle that resolves at call time. import { pasteText } from '../core/clipboard.mjs'; import { getFormState } from '../core/form-state.mjs'; diff --git a/.claude/skills/web-test/scripts/engine/recording/capture.mjs b/.claude/skills/web-test/scripts/engine/recording/capture.mjs index dcbe1c02..b6a5a86c 100644 --- a/.claude/skills/web-test/scripts/engine/recording/capture.mjs +++ b/.claude/skills/web-test/scripts/engine/recording/capture.mjs @@ -9,8 +9,7 @@ import { setRecorder, setLastCaptions, setLastRecordingDuration, resolveProjectPath, ensureConnected, } from '../core/state.mjs'; -import { resolveFfmpeg } from './tts.mjs'; -// getFormState lives in browser.mjs for now (moves to forms/ in a later stage). +import { resolveFfmpeg } from './tts.mjs'; // Imported lazily inside wait() to avoid initialization-time circular deps. /** Take a screenshot. Returns PNG buffer. */ diff --git a/.claude/skills/web-test/scripts/engine/table/grid.mjs b/.claude/skills/web-test/scripts/engine/table/grid.mjs index 9e0be5a9..3760279c 100644 --- a/.claude/skills/web-test/scripts/engine/table/grid.mjs +++ b/.claude/skills/web-test/scripts/engine/table/grid.mjs @@ -9,8 +9,7 @@ import { page, ensureConnected } from '../core/state.mjs'; import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs'; import { dismissPendingErrors } from '../core/errors.mjs'; import { waitForStable } from '../core/wait.mjs'; -import { clickElement } from '../core/click.mjs'; -// getFormState lives in browser.mjs. +import { clickElement } from '../core/click.mjs'; import { getFormState } from '../core/form-state.mjs'; /** Read structured table data with pagination. Returns columns, rows, total count. */ diff --git a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs index d8ad78c8..c1e62e38 100644 --- a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs +++ b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs @@ -18,8 +18,7 @@ import { clickElement } from '../core/click.mjs'; import { pickFromSelectionForm, isTypeDialog, pickFromTypeDialog, fillReferenceField, selectValue, -} from '../forms/select-value.mjs'; -// pasteText + getFormState still in browser.mjs (cycle). +} from '../forms/select-value.mjs'; import { pasteText } from '../core/clipboard.mjs'; import { getFormState } from '../core/form-state.mjs'; diff --git a/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs b/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs index 99bad349..1d5d7a14 100644 --- a/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs +++ b/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs @@ -1,13 +1,10 @@ -// web-test table/spreadsheet v1.17 — readTable, readSpreadsheet, scanSpreadsheetCells, scroll/click helpers for SpreadsheetDocument. +// web-test table/spreadsheet v1.17 — readSpreadsheet + helpers for SpreadsheetDocument (отчёты, печатные формы). // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills -import { page, ensureConnected, normYo } from '../core/state.mjs'; -import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs'; -import { waitForStable } from '../core/wait.mjs'; -// getFormState still in browser.mjs (cycle resolves at call time). -import { getFormState } from '../core/form-state.mjs'; - -// readTable moved to table/grid.mjs (form-grid операции отделены от SpreadsheetDocument). +import { page, ensureConnected } from '../core/state.mjs'; +import { detectFormScript } from '../../dom.mjs'; +import { waitForStable } from '../core/wait.mjs'; +import { getFormState } from '../core/form-state.mjs'; // --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) --- From 8bdcb9e6643012f344b070ed910d980fed30930f Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 16:48:08 +0300 Subject: [PATCH 24/47] =?UTF-8?q?refactor(web-test):=20form-state=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B5=D1=85=D0=B0=D0=BB=20=D0=B8=D0=B7=20cor?= =?UTF-8?q?e/=20=D0=B2=20forms/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getFormState — высокоуровневая операция «прочитать состояние формы», семантически в forms/ ближе чем в core/ (foundational плумбинг движка). engine/core/form-state.mjs → engine/forms/state.mjs Все 11 importer'ов обновлены. Внутри state.mjs пути исправлены: './state.mjs' → '../core/state.mjs', './errors.mjs' → '../core/errors.mjs'. 03-fillfields регресс зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 2 +- .claude/skills/web-test/scripts/engine/core/click.mjs | 2 +- .claude/skills/web-test/scripts/engine/core/helpers.mjs | 2 +- .claude/skills/web-test/scripts/engine/forms/close.mjs | 2 +- .claude/skills/web-test/scripts/engine/forms/fill.mjs | 2 +- .../skills/web-test/scripts/engine/forms/select-value.mjs | 2 +- .../scripts/engine/{core/form-state.mjs => forms/state.mjs} | 6 +++--- .claude/skills/web-test/scripts/engine/nav/navigation.mjs | 2 +- .../skills/web-test/scripts/engine/recording/capture.mjs | 2 +- .claude/skills/web-test/scripts/engine/table/filter.mjs | 2 +- .claude/skills/web-test/scripts/engine/table/grid.mjs | 2 +- .claude/skills/web-test/scripts/engine/table/row-fill.mjs | 2 +- .../skills/web-test/scripts/engine/table/spreadsheet.mjs | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) rename .claude/skills/web-test/scripts/engine/{core/form-state.mjs => forms/state.mjs} (84%) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 20895767..7e7a2461 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -13,7 +13,7 @@ export { export { pasteText, saveClipboard, restoreClipboard, } from './engine/core/clipboard.mjs'; -export { getFormState } from './engine/core/form-state.mjs'; +export { getFormState } from './engine/forms/state.mjs'; export { fetchErrorStack } from './engine/core/errors.mjs'; export { clickElement } from './engine/core/click.mjs'; diff --git a/.claude/skills/web-test/scripts/engine/core/click.mjs b/.claude/skills/web-test/scripts/engine/core/click.mjs index e796b6c8..ef872ea4 100644 --- a/.claude/skills/web-test/scripts/engine/core/click.mjs +++ b/.claude/skills/web-test/scripts/engine/core/click.mjs @@ -15,7 +15,7 @@ import { getGridToggleIcon, shouldClickToggle } from '../table/grid-toggle.mjs'; import { clickSpreadsheetCell, findSpreadsheetCellByText, } from '../table/spreadsheet.mjs'; -import { getFormState } from './form-state.mjs'; +import { getFormState } from '../forms/state.mjs'; /** 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. */ diff --git a/.claude/skills/web-test/scripts/engine/core/helpers.mjs b/.claude/skills/web-test/scripts/engine/core/helpers.mjs index 595f1e9f..e7d626f8 100644 --- a/.claude/skills/web-test/scripts/engine/core/helpers.mjs +++ b/.claude/skills/web-test/scripts/engine/core/helpers.mjs @@ -4,7 +4,7 @@ import { page } from './state.mjs'; import { dismissPendingErrors, checkForErrors } from './errors.mjs'; -import { getFormState } from './form-state.mjs'; +import { getFormState } from '../forms/state.mjs'; /** * page.click with the standard "intercepts pointer events" retry ladder: diff --git a/.claude/skills/web-test/scripts/engine/forms/close.mjs b/.claude/skills/web-test/scripts/engine/forms/close.mjs index 6c5be6dd..cd9990e3 100644 --- a/.claude/skills/web-test/scripts/engine/forms/close.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/close.mjs @@ -5,7 +5,7 @@ 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 '../core/form-state.mjs'; +import { getFormState } from './state.mjs'; /** * Close the current form/dialog via Escape. diff --git a/.claude/skills/web-test/scripts/engine/forms/fill.mjs b/.claude/skills/web-test/scripts/engine/forms/fill.mjs index 8da09047..1bb1deaf 100644 --- a/.claude/skills/web-test/scripts/engine/forms/fill.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/fill.mjs @@ -15,7 +15,7 @@ import { isTypeDialog, pickFromTypeDialog, } from './select-value.mjs'; import { pasteText } from '../core/clipboard.mjs'; -import { getFormState } from '../core/form-state.mjs'; +import { getFormState } from './state.mjs'; /** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */ export async function fillFields(fields) { diff --git a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs index ec22c9a6..fa3c8ff4 100644 --- a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs @@ -16,7 +16,7 @@ import { detectNewForm as helperDetectNewForm, } from '../core/helpers.mjs'; import { pasteText } from '../core/clipboard.mjs'; -import { getFormState } from '../core/form-state.mjs'; +import { getFormState } from './state.mjs'; /** * Scan visible grid rows for a text match (exact → startsWith → includes). diff --git a/.claude/skills/web-test/scripts/engine/core/form-state.mjs b/.claude/skills/web-test/scripts/engine/forms/state.mjs similarity index 84% rename from .claude/skills/web-test/scripts/engine/core/form-state.mjs rename to .claude/skills/web-test/scripts/engine/forms/state.mjs index 12391936..64495e66 100644 --- a/.claude/skills/web-test/scripts/engine/core/form-state.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/state.mjs @@ -1,4 +1,4 @@ -// web-test engine/core/form-state v1.17 — central form-state reader. +// web-test engine/forms/state v1.17 — central form-state reader. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills // // getFormState — the canonical "what's on the screen right now" call. Combines: @@ -8,9 +8,9 @@ // // Returned by virtually every action-function as the "after" snapshot. -import { page, ensureConnected } from './state.mjs'; +import { page, ensureConnected } from '../core/state.mjs'; import { getFormStateScript } from '../../dom.mjs'; -import { checkForErrors, detectPlatformDialogs } from './errors.mjs'; +import { checkForErrors, detectPlatformDialogs } from '../core/errors.mjs'; /** Read current form state. Single evaluate call via combined script. */ export async function getFormState() { diff --git a/.claude/skills/web-test/scripts/engine/nav/navigation.mjs b/.claude/skills/web-test/scripts/engine/nav/navigation.mjs index 621ef72e..08790f8c 100644 --- a/.claude/skills/web-test/scripts/engine/nav/navigation.mjs +++ b/.claude/skills/web-test/scripts/engine/nav/navigation.mjs @@ -15,7 +15,7 @@ import { highlight, unhighlight } from '../recording/highlight.mjs'; import { returnFormState } from '../core/helpers.mjs'; // Static import — ESM cycle that resolves at call time. import { pasteText } from '../core/clipboard.mjs'; -import { getFormState } from '../core/form-state.mjs'; +import { getFormState } from '../forms/state.mjs'; /** * Get current page state: active section, tabs. diff --git a/.claude/skills/web-test/scripts/engine/recording/capture.mjs b/.claude/skills/web-test/scripts/engine/recording/capture.mjs index b6a5a86c..caca99ec 100644 --- a/.claude/skills/web-test/scripts/engine/recording/capture.mjs +++ b/.claude/skills/web-test/scripts/engine/recording/capture.mjs @@ -44,7 +44,7 @@ export async function wait(seconds) { await page.waitForTimeout(ms); } } - const { getFormState } = await import('../core/form-state.mjs'); + const { getFormState } = await import('../forms/state.mjs'); return await getFormState(); } diff --git a/.claude/skills/web-test/scripts/engine/table/filter.mjs b/.claude/skills/web-test/scripts/engine/table/filter.mjs index 03df38c1..f73d4910 100644 --- a/.claude/skills/web-test/scripts/engine/table/filter.mjs +++ b/.claude/skills/web-test/scripts/engine/table/filter.mjs @@ -9,7 +9,7 @@ import { highlight, unhighlight } from '../recording/highlight.mjs'; import { safeClick } from '../core/helpers.mjs'; import { selectValue, fillReferenceField } from '../forms/select-value.mjs'; import { pasteText } from '../core/clipboard.mjs'; -import { getFormState } from '../core/form-state.mjs'; +import { getFormState } from '../forms/state.mjs'; import { clickElement } from '../core/click.mjs'; /** diff --git a/.claude/skills/web-test/scripts/engine/table/grid.mjs b/.claude/skills/web-test/scripts/engine/table/grid.mjs index 3760279c..19b4becd 100644 --- a/.claude/skills/web-test/scripts/engine/table/grid.mjs +++ b/.claude/skills/web-test/scripts/engine/table/grid.mjs @@ -10,7 +10,7 @@ import { detectFormScript, readTableScript, resolveGridScript } from '../../dom. import { dismissPendingErrors } from '../core/errors.mjs'; import { waitForStable } from '../core/wait.mjs'; import { clickElement } from '../core/click.mjs'; -import { getFormState } from '../core/form-state.mjs'; +import { getFormState } from '../forms/state.mjs'; /** Read structured table data with pagination. Returns columns, rows, total count. */ export async function readTable({ maxRows = 20, offset = 0, table } = {}) { diff --git a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs index c1e62e38..30c7c18f 100644 --- a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs +++ b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs @@ -20,7 +20,7 @@ import { fillReferenceField, selectValue, } from '../forms/select-value.mjs'; import { pasteText } from '../core/clipboard.mjs'; -import { getFormState } from '../core/form-state.mjs'; +import { getFormState } from '../forms/state.mjs'; /** * Fill cells in the current table row via Tab navigation. diff --git a/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs b/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs index 1d5d7a14..6fce743d 100644 --- a/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs +++ b/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs @@ -4,7 +4,7 @@ import { page, ensureConnected } from '../core/state.mjs'; import { detectFormScript } from '../../dom.mjs'; import { waitForStable } from '../core/wait.mjs'; -import { getFormState } from '../core/form-state.mjs'; +import { getFormState } from '../forms/state.mjs'; // --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) --- From c930b4b04da07236b0c411f9bc88c8535ddc58d7 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 16:52:15 +0300 Subject: [PATCH 25/47] =?UTF-8?q?refactor(web-test):=20spreadsheet=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=BE=D0=B1=D1=81=D1=82=D0=B2=D0=B5=D0=BD=D0=BD=D1=83=D1=8E=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BF=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SpreadsheetDocument (отчёты, печатные формы) — другой домен, чем form-grid (табличные части документов, списки). Раньше лежал внутри table/, что было обманчиво. engine/table/spreadsheet.mjs → engine/spreadsheet/spreadsheet.mjs Структура engine/: core/ плумбинг движка (state, wait, errors, session, click, ...) forms/ работа с формами (fill, close, select-value, state) nav/ навигация table/ form-grid (grid, row-fill, filter, grid-toggle) spreadsheet/ SpreadsheetDocument recording/ запись + overlays В будущем при росте spreadsheet можно распилить — engine/spreadsheet/cells.mjs, engine/spreadsheet/scroll.mjs и т.д. без переименований. 11-report регресс зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 2 +- .claude/skills/web-test/scripts/engine/core/click.mjs | 2 +- .../scripts/engine/{table => spreadsheet}/spreadsheet.mjs | 6 +++--- .claude/skills/web-test/scripts/engine/table/grid.mjs | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) rename .claude/skills/web-test/scripts/engine/{table => spreadsheet}/spreadsheet.mjs (96%) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index 7e7a2461..a96841cd 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -37,7 +37,7 @@ export { closeForm } from './engine/forms/close.mjs'; // ── tables ──────────────────────────────────────────────────────────────── export { readTable, deleteTableRow } from './engine/table/grid.mjs'; -export { readSpreadsheet } from './engine/table/spreadsheet.mjs'; +export { readSpreadsheet } from './engine/spreadsheet/spreadsheet.mjs'; export { fillTableRow } from './engine/table/row-fill.mjs'; export { filterList, unfilterList } from './engine/table/filter.mjs'; diff --git a/.claude/skills/web-test/scripts/engine/core/click.mjs b/.claude/skills/web-test/scripts/engine/core/click.mjs index ef872ea4..7370d1f2 100644 --- a/.claude/skills/web-test/scripts/engine/core/click.mjs +++ b/.claude/skills/web-test/scripts/engine/core/click.mjs @@ -14,7 +14,7 @@ import { safeClick } from './helpers.mjs'; import { getGridToggleIcon, shouldClickToggle } from '../table/grid-toggle.mjs'; import { clickSpreadsheetCell, findSpreadsheetCellByText, -} from '../table/spreadsheet.mjs'; +} from '../spreadsheet/spreadsheet.mjs'; import { getFormState } from '../forms/state.mjs'; /** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists). diff --git a/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs b/.claude/skills/web-test/scripts/engine/spreadsheet/spreadsheet.mjs similarity index 96% rename from .claude/skills/web-test/scripts/engine/table/spreadsheet.mjs rename to .claude/skills/web-test/scripts/engine/spreadsheet/spreadsheet.mjs index 6fce743d..9743170d 100644 --- a/.claude/skills/web-test/scripts/engine/table/spreadsheet.mjs +++ b/.claude/skills/web-test/scripts/engine/spreadsheet/spreadsheet.mjs @@ -1,10 +1,10 @@ -// web-test table/spreadsheet v1.17 — readSpreadsheet + helpers for SpreadsheetDocument (отчёты, печатные формы). +// web-test spreadsheet v1.17 — readSpreadsheet + helpers for SpreadsheetDocument (отчёты, печатные формы). // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, ensureConnected } from '../core/state.mjs'; import { detectFormScript } from '../../dom.mjs'; -import { waitForStable } from '../core/wait.mjs'; -import { getFormState } from '../forms/state.mjs'; +import { waitForStable } from '../core/wait.mjs'; +import { getFormState } from '../forms/state.mjs'; // --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) --- diff --git a/.claude/skills/web-test/scripts/engine/table/grid.mjs b/.claude/skills/web-test/scripts/engine/table/grid.mjs index 19b4becd..8d7788f5 100644 --- a/.claude/skills/web-test/scripts/engine/table/grid.mjs +++ b/.claude/skills/web-test/scripts/engine/table/grid.mjs @@ -3,13 +3,13 @@ // // "Grid" в терминах 1С — таблица на форме (.gridLine/.gridBody/.grid в DOM): // табличные части документов, формы списков, ТЧ настроек и т.п. -// Отдельно от SpreadsheetDocument (table/spreadsheet.mjs). +// Отдельно от SpreadsheetDocument (engine/spreadsheet/spreadsheet.mjs). import { page, ensureConnected } from '../core/state.mjs'; import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs'; import { dismissPendingErrors } from '../core/errors.mjs'; import { waitForStable } from '../core/wait.mjs'; -import { clickElement } from '../core/click.mjs'; +import { clickElement } from '../core/click.mjs'; import { getFormState } from '../forms/state.mjs'; /** Read structured table data with pagination. Returns columns, rows, total count. */ From 71607bef99e9c040f941ff5f42f0de58a9f5cf5c Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 17:47:13 +0300 Subject: [PATCH 26/47] =?UTF-8?q?refactor(web-test):=20dom.mjs=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D0=BF=D0=B8=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BE=20dom?= =?UTF-8?q?/=20(1434=20=E2=86=92=2041=20LOC=20facade)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Внутренности movе в dom/: - _shared.mjs — HAS_VISIBLE_MODAL_FN, DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN - forms.mjs — detectFormScript, readFormScript, findClickTargetScript, findFieldButtonScript, resolveFieldsScript - form-state.mjs — getFormStateScript - grid.mjs — resolveGridScript, readTableScript - nav.mjs — readSectionsScript, readTabsScript, switchTabScript, readCommandsScript, navigateSectionScript, openCommandScript - submenu.mjs — readSubmenuScript, clickPopupItemScript - errors.mjs — checkErrorsScript dom.mjs остался публичным entry-point с теми же 17 экспортами. Регресс tests/web-test/ зелёный (19/19, 9m 22s). --- .claude/skills/web-test/scripts/dom.mjs | 1475 +---------------- .../skills/web-test/scripts/dom/_shared.mjs | 391 +++++ .../skills/web-test/scripts/dom/errors.mjs | 127 ++ .../web-test/scripts/dom/form-state.mjs | 34 + .claude/skills/web-test/scripts/dom/forms.mjs | 398 +++++ .claude/skills/web-test/scripts/dom/grid.mjs | 249 +++ .claude/skills/web-test/scripts/dom/nav.mjs | 93 ++ .../skills/web-test/scripts/dom/submenu.mjs | 149 ++ 8 files changed, 1482 insertions(+), 1434 deletions(-) create mode 100644 .claude/skills/web-test/scripts/dom/_shared.mjs create mode 100644 .claude/skills/web-test/scripts/dom/errors.mjs create mode 100644 .claude/skills/web-test/scripts/dom/form-state.mjs create mode 100644 .claude/skills/web-test/scripts/dom/forms.mjs create mode 100644 .claude/skills/web-test/scripts/dom/grid.mjs create mode 100644 .claude/skills/web-test/scripts/dom/nav.mjs create mode 100644 .claude/skills/web-test/scripts/dom/submenu.mjs diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index fde01963..f0238cf1 100644 --- a/.claude/skills/web-test/scripts/dom.mjs +++ b/.claude/skills/web-test/scripts/dom.mjs @@ -1,1434 +1,41 @@ -// web-test dom v1.7 — 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. - * - * All functions return JavaScript strings for page.evaluate(). - * They produce clean semantic structures — no DOM IDs or CSS classes leak out. - * Only non-default property values are included to minimize response size. - */ - -// --- Shared function strings (embedded in evaluate scripts) --- - -/** Find visible #modalSurface. 1C may leave multiple #modalSurface in DOM (duplicate id), - * e.g. when a second form (drill-down) creates its own alongside a stale one from the first - * form. getElementById returns the FIRST in document order, which may be hidden. Scan all. */ -const HAS_VISIBLE_MODAL_FN = `function hasVisibleModal() { - const all = document.querySelectorAll('#modalSurface'); - for (const el of all) { if (el.offsetWidth > 0) return true; } - return false; -}`; - -/** Detect active form number. Picks form with most visible elements, skipping form0. - * When modalSurface is visible — prefer the highest-numbered form (modal dialog). */ -const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + ` -function detectForm() { - const counts = {}; - document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => { - if (el.offsetWidth === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) counts[m[1]] = (counts[m[1]] || 0) + 1; - }); - const nums = Object.keys(counts).map(Number); - if (!nums.length) return null; - const candidates = nums.filter(n => n > 0); - if (!candidates.length) return nums[0]; - // When modal surface is visible, prefer the highest-numbered form (modal dialog) - if (hasVisibleModal()) { - const maxForm = Math.max(...candidates); - if (counts[maxForm] >= 1) return maxForm; - } - return candidates.reduce((best, n) => counts[n] > counts[best] ? n : best); -}`; - -/** Detect all open forms + modal state. Returns { activeForm, allForms, formCount, modal }. - * Works even when the open-windows tab bar is hidden. */ -const DETECT_FORMS_FN = HAS_VISIBLE_MODAL_FN + ` -function detectForms() { - const counts = {}; - document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => { - if (el.offsetWidth === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) counts[m[1]] = (counts[m[1]] || 0) + 1; - }); - const nums = Object.keys(counts).map(Number); - return { allForms: nums.sort((a, b) => a - b), formCount: nums.length, modal: hasVisibleModal() }; -}`; - -/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */ -const READ_FORM_FN = `function readForm(p) { - const result = {}; - const fields = []; - const buttons = []; - const formTabs = []; - const texts = []; - const hyperlinks = []; - // Normalize non-breaking spaces to regular spaces - const nbsp = s => (s || '').replace(/\\u00a0/g, ' '); - - // Fields (inputs) - document.querySelectorAll('input.editInput[id^="' + p + '"]').forEach(el => { - if (el.offsetWidth === 0) return; - const name = el.id.replace(p, '').replace(/_i\\d+$/, ''); - const titleEl = document.getElementById(p + name + '#title_text') - || document.getElementById(p + name + '#title_div'); - const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ')); - const actions = []; - if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) actions.push('select'); - if (document.getElementById(p + name + '_OB')?.offsetWidth > 0) actions.push('open'); - if (document.getElementById(p + name + '_CLR')?.offsetWidth > 0) actions.push('clear'); - if (document.getElementById(p + name + '_CB')?.offsetWidth > 0) actions.push('pick'); - const field = { name, value: el.value || '' }; - // Multi-value reference fields keep their value in .chipsItem chips, not in input.value - if (!field.value) { - const labelEl = document.getElementById(p + name); - if (labelEl) { - const chipTexts = [...labelEl.querySelectorAll('.chipsItem .chipsTitle')] - .map(c => nbsp(c.innerText?.trim() || '')) - .filter(Boolean); - if (chipTexts.length) field.value = chipTexts.join(', '); - } - } - if (label && label !== name) field.label = label; - if (el.readOnly) field.readonly = true; - if (el.disabled) field.disabled = true; - if (el.type && el.type !== 'text') field.type = el.type; - if (document.activeElement === el) field.focused = true; - if (actions.length) field.actions = actions; - if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true; - fields.push(field); - }); - - // Textareas - document.querySelectorAll('textarea[id^="' + p + '"]').forEach(el => { - if (el.offsetWidth === 0) return; - const name = el.id.replace(p, '').replace(/_i\\d+$/, ''); - const titleEl = document.getElementById(p + name + '#title_text') - || document.getElementById(p + name + '#title_div'); - const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ')); - const field = { name, value: el.value || '', type: 'textarea' }; - if (label && label !== name) field.label = label; - if (el.readOnly) field.readonly = true; - if (el.disabled) field.disabled = true; - if (document.activeElement === el) field.focused = true; - if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true; - fields.push(field); - }); - - // Checkboxes - document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => { - if (el.offsetWidth === 0) return; - const name = el.id.replace(p, ''); - const titleEl = document.getElementById(p + name + '#title_text'); - const label = nbsp(titleEl?.innerText?.trim() || ''); - const field = { - name, - value: el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'), - type: 'checkbox' - }; - if (label && label !== name) field.label = label; - fields.push(field); - }); - - // Radio buttons — base element is option 0, others are #N#radio (N >= 1) - const radioGroups = {}; - document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => { - if (el.offsetWidth === 0) return; - const id = el.id.replace(p, ''); - const m = id.match(/^(.+?)#(\\d+)#radio$/); - if (m) { - // Options 1, 2, ... have explicit #N#radio suffix - const [, groupName, idx] = m; - if (!radioGroups[groupName]) radioGroups[groupName] = []; - const labelEl = document.getElementById(p + groupName + '#' + idx + '#radio_text'); - const label = nbsp(labelEl?.innerText?.trim() || 'option' + idx); - radioGroups[groupName].push({ index: parseInt(idx), label, selected: el.classList.contains('select') }); - } else if (!id.includes('#')) { - // Base element = option 0 (no #0#radio suffix) - if (!radioGroups[id]) radioGroups[id] = []; - const labelEl = document.getElementById(p + id + '#0#radio_text'); - const label = nbsp(labelEl?.innerText?.trim() || 'option0'); - radioGroups[id].unshift({ index: 0, label, selected: el.classList.contains('select') }); - } - }); - for (const [name, options] of Object.entries(radioGroups)) { - const titleEl = document.getElementById(p + name + '#title_text'); - const label = titleEl?.innerText?.trim() || ''; - const selected = options.find(o => o.selected); - const field = { - name, - value: selected?.label || '', - type: 'radio', - options: options.map(o => o.label) - }; - if (label && label !== name) field.label = label; - fields.push(field); - } - - // Buttons (a.press) - document.querySelectorAll('a.press[id^="' + p + '"]').forEach(el => { - if (el.offsetWidth === 0) return; - const idName = el.id.replace(p, ''); - if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return; - const span = el.querySelector('.submenuText') || el.querySelector('span'); - const text = nbsp(span?.textContent?.trim() || el.innerText?.trim() || ''); - if (!text && !el.classList.contains('pressCommand')) return; - const btn = { name: text || idName }; - if (el.classList.contains('pressDefault')) btn.default = true; - if (el.classList.contains('pressDisabled')) btn.disabled = true; - // Icon-only buttons: expose tooltip from DOM title attribute (1C puts title on parent .framePress) - if (!text) { - const tip = nbsp(el.title || el.parentElement?.title || ''); - if (tip) btn.tooltip = tip; - } - buttons.push(btn); - }); - - // Frame buttons - document.querySelectorAll('[id^="' + p + '"].frameButton, [id^="' + p + '"] .frameButton').forEach(el => { - if (el.offsetWidth === 0) return; - const text = nbsp(el.innerText?.trim() || ''); - const idName = el.id?.replace(p, '') || ''; - if (!text && !idName) return; - buttons.push({ name: text || idName, frame: true }); - }); - - // Tumbler items - document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => { - if (el.offsetWidth === 0) return; - const text = el.innerText?.trim(); - const idName = el.id?.replace(p, '') || ''; - buttons.push({ name: text || idName, tumbler: true }); - }); - - // Tabs — scoped to form by checking ancestor IDs - document.querySelectorAll('[data-content]').forEach(el => { - if (el.offsetWidth === 0) return; - let node = el.parentElement; - let inForm = false; - while (node) { - if (node.id && node.id.startsWith(p)) { inForm = true; break; } - node = node.parentElement; - } - if (!inForm) return; - const tab = { name: el.dataset.content }; - if (el.classList.contains('select')) tab.active = true; - formTabs.push(tab); - }); - - // Static texts and hyperlinks - document.querySelectorAll('[id^="' + p + '"].staticText').forEach(el => { - if (el.offsetWidth === 0) return; - const name = el.id.replace(p, ''); - if (name.endsWith('_div') || name.includes('#title')) return; - const text = el.innerText?.trim(); - if (!text) return; - if (el.classList.contains('staticTextHyper')) { - hyperlinks.push({ name: text }); - } else { - const titleEl = document.getElementById(p + name + '#title_text'); - const label = titleEl?.innerText?.trim() || ''; - const entry = { name, value: text }; - if (label) entry.label = label; - texts.push(entry); - } - }); - - // Tables/grids — collect ALL visible grids - const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] - .filter(g => g.offsetWidth > 0 && g.offsetHeight > 0); - if (allGrids.length > 0) { - const tables = allGrids.map(grid => { - const name = grid.id ? grid.id.replace(p, '') : ''; - const head = grid.querySelector('.gridHead'); - const body = grid.querySelector('.gridBody'); - const columns = []; - if (head) { - const headLine = head.querySelector('.gridLine') || head; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const textEl = box.querySelector('.gridBoxText'); - const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; - if (text) { - const r = box.getBoundingClientRect(); - columns.push({ text, x: r.x, right: r.x + r.width, y: r.y, h: r.height }); - } else { - // Unnamed column — check if data cells contain checkboxes - const firstLine = body?.querySelector('.gridLine'); - if (firstLine) { - const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0); - const idx = visibleHeaders.indexOf(box); - const cells = [...firstLine.children].filter(c => c.offsetWidth > 0); - if (cells[idx]?.querySelector('.checkbox')) { - columns.push({ text: '(checkbox)', x: 0, right: 0, y: 0, h: 0 }); - } - } - } - }); - // Expand single merged headers with multiple data sub-rows (e.g. "Субконто Дт" → 1/2/3) - const firstLine = body?.querySelector('.gridLine'); - if (firstLine && columns.length > 0) { - const xGrp = new Map(); - columns.forEach(c => { - const k = Math.round(c.x) + ':' + Math.round(c.right); - if (!xGrp.has(k)) xGrp.set(k, []); - xGrp.get(k).push(c); - }); - for (const [k, hdrs] of xGrp) { - if (hdrs.length !== 1) continue; - let cnt = 0; - [...firstLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const r = box.getBoundingClientRect(); - const cx = r.x + r.width / 2; - if (cx >= hdrs[0].x && cx < hdrs[0].right) cnt++; - }); - if (cnt > 1) { - const base = hdrs[0]; - const baseIdx = columns.indexOf(base); - columns.splice(baseIdx, 1); - for (let si = 0; si < cnt; si++) { - columns.splice(baseIdx + si, 0, { text: base.text + ' ' + (si + 1), x: base.x, right: base.right, y: 0, h: 0 }); - } - } - } - } - } - const colNames = columns.map(c => c.text); - const rowCount = body ? body.querySelectorAll('.gridLine').length : 0; - // Visual label from group title (e.g. "Входящие:" for grid "Входящие") - const titleEl = document.getElementById(p + name + '#title_div') - || document.getElementById(p + 'Группа' + name + '#title_div'); - const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null; - return { name, columns: colNames, rowCount, ...(label ? { label } : {}) }; - }); - result.tables = tables; - // Backward compat: table = first grid summary - const first = tables[0]; - result.table = { present: true, columns: first.columns, rowCount: first.rowCount }; - } - - // Active filters (train badges above grid: *СостояниеПросмотра) - const filters = []; - document.querySelectorAll('[id^="' + p + '"].trainItem').forEach(el => { - if (el.offsetWidth === 0) return; - const titleEl = el.querySelector('.trainName'); - const valueEl = el.querySelector('.trainTitle'); - if (!titleEl && !valueEl) return; - const field = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/\\s*:$/, '').trim(); - const value = valueEl?.innerText?.trim()?.replace(/\\n/g, ' ') || ''; - if (field || value) filters.push({ field, value }); - }); - // Also check search field value - const searchInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); - if (searchInput?.value) { - filters.push({ type: 'search', value: searchInput.value }); - } - 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 => { - if (el.offsetWidth > 0 && el.offsetHeight > 0) iframeCount++; - }); - if (iframeCount) result.iframes = iframeCount; - - 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; - - // Group DCS report settings into readable format - if (result.fields) { - const dcsRe = /^(.+Элемент(\\d+))(Использование|Значение|ВидСравнения)$/; - const dcsGroups = {}; - const dcsNames = new Set(); - for (const f of result.fields) { - const m = f.name.match(dcsRe); - if (!m) continue; - if (!dcsGroups[m[1]]) dcsGroups[m[1]] = { _n: parseInt(m[2]) }; - dcsGroups[m[1]][m[3]] = f; - dcsNames.add(f.name); - } - const dcsEntries = Object.entries(dcsGroups).sort((a, b) => a[1]._n - b[1]._n); - if (dcsEntries.length) { - result.reportSettings = dcsEntries.map(([, g]) => { - const cb = g['Использование']; - const val = g['Значение']; - if (!cb && !val) return null; - // No checkbox present (class="staticText" instead of .checkbox) — setting is always enabled - const label = (val?.label || cb?.label || val?.name || cb?.name || '').replace(/:$/, '').trim(); - const s = { name: label, enabled: cb ? !!cb.value : true }; - if (val) { - s.value = val.value || ''; - if (val.actions && val.actions.length) s.actions = val.actions; - } - return s; - }).filter(Boolean); - result.fields = result.fields.filter(f => !dcsNames.has(f.name)); - if (!result.fields.length) delete result.fields; - } - } - - return result; -}`; - -// --- Exported script generators --- - -/** - * Detect the active form number. - * Picks the form with the most visible elements (excluding form0 = home page). - */ -export function detectFormScript() { - return `(() => { - ${DETECT_FORM_FN} - return detectForm(); - })()`; -} - -/** Read sections panel (left sidebar). */ -export function readSectionsScript() { - return `(() => { - const sections = []; - document.querySelectorAll('[id^="themesCell_theme_"]').forEach(el => { - const entry = { name: el.innerText?.trim() || '' }; - if (el.classList.contains('select')) entry.active = true; - sections.push(entry); - }); - return sections; - })()`; -} - -/** Read open tabs bar. */ -export function readTabsScript() { - return `(() => { - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); - const tabs = []; - document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => { - const text = norm(el.innerText); - if (!text) return; - const entry = { name: text }; - if (el.classList.contains('select')) entry.active = true; - tabs.push(entry); - }); - return tabs; - })()`; -} - -/** Switch to a tab by name (fuzzy match). Returns matched name or { error, available }. */ -export function switchTabScript(name) { - return `(() => { - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); - const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))}; - const tabs = [...document.querySelectorAll('[id^="openedCell_cmd_"]')].filter(el => el.offsetWidth > 0 && norm(el.innerText)); - let best = tabs.find(el => norm(el.innerText).toLowerCase() === target); - if (!best) best = tabs.find(el => norm(el.innerText).toLowerCase().includes(target)); - if (best) { best.click(); return norm(best.innerText); } - return { error: 'not_found', available: tabs.map(el => norm(el.innerText)) }; - })()`; -} - -/** Read commands in the function panel (current section). */ -export function readCommandsScript() { - return `(() => { - const groups = []; - const container = document.querySelector('#funcPanel_container table tr'); - if (!container) return groups; - for (const td of container.children) { - const commands = []; - td.querySelectorAll('[id^="cmd_"][id$="_txt"]').forEach(el => { - if (el.offsetWidth === 0) return; - commands.push(el.innerText?.trim() || ''); - }); - if (commands.length > 0) groups.push(commands); - } - return groups; - })()`; -} - -/** - * Read full form state for a given form number. - * Uses shared READ_FORM_FN. - */ -export function readFormScript(formNum) { - const p = `form${formNum}_`; - return `(() => { - ${READ_FORM_FN} - return readForm(${JSON.stringify(p)}); - })()`; -} - -/** - * Resolve a specific grid by semantic name (table parameter). - * Cascade: exact gridName match → gridName contains → column contains. - * Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }. - */ -export function resolveGridScript(formNum, tableName) { - const p = `form${formNum}_`; - return `(() => { - const p = ${JSON.stringify(p)}; - const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))}; - const norm = s => (s || '').replace(/ё/gi, 'е'); - const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] - .filter(g => g.offsetWidth > 0 && g.offsetHeight > 0); - if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' }; - const infos = allGrids.map((g, idx) => { - const gridId = g.id || ''; - const gridName = gridId.replace(p, ''); - const head = g.querySelector('.gridHead'); - const columns = []; - if (head) { - const headLine = head.querySelector('.gridLine') || head; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const textEl = box.querySelector('.gridBoxText'); - const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; - if (text) columns.push(text); - }); - } - // Visual label from group title element - const titleEl = document.getElementById(p + gridName + '#title_div') - || document.getElementById(p + 'Группа' + gridName + '#title_div'); - const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/\u00a0/g, ' ') || '') : ''; - return { idx, gridId, gridName, label, columns, el: g }; - }); - // 1. Exact gridName match (case-insensitive) - let found = infos.find(i => norm(i.gridName).toLowerCase() === target); - // 2. Exact label match - if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase() === target); - // 3. gridName contains target - if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target)); - // 4. Label contains target - if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase().includes(target)); - // 5. Any column contains target - if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target))); - if (found) { - return { - gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null, - gridId: found.gridId, - gridName: found.gridName, - gridIndex: found.idx, - columns: found.columns - }; - } - return { - error: 'not_found', - message: 'Table "' + ${JSON.stringify(tableName)} + '" not found', - available: infos.map(i => ({ name: i.gridName, ...(i.label ? { label: i.label } : {}), columns: i.columns })) - }; - })()`; -} - -/** - * Read table/grid data with pagination. - * Parses grid.innerText — \n separates rows, \t separates cells. - * First row = column headers. - * Returns { name, columns[], rows[{col:val}], total, offset, shown }. - */ -export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) { - const p = `form${formNum}_`; - return `(() => { - const p = ${JSON.stringify(p)}; - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] - .find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`}; - if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' }; - const name = grid.id ? grid.id.replace(p, '') : ''; - - // DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells - const head = grid.querySelector('.gridHead'); - const body = grid.querySelector('.gridBody'); - if (!head || !body) { - // Fallback: innerText-based (for non-standard grids) - const gText = grid.innerText?.trim() || ''; - const lines = gText.split('\\n').filter(Boolean); - return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0, - hint: 'Grid has no gridHead/gridBody structure' }; - } - - // Extract column headers with X-coordinates for alignment - const columns = []; - const headLine = head.querySelector('.gridLine') || head; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const textEl = box.querySelector('.gridBoxText'); - const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; - if (!text) { - // Unnamed column — check if data cells contain checkboxes - const firstLine = body?.querySelector('.gridLine'); - if (firstLine) { - const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0); - const idx = visibleHeaders.indexOf(box); - const cells = [...firstLine.children].filter(c => c.offsetWidth > 0); - if (cells[idx]?.querySelector('.checkbox')) { - const r = box.getBoundingClientRect(); - columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height }); - } - } - return; - } - const r = box.getBoundingClientRect(); - columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height }); - }); - - // Multi-row grid support: detect stacked/merged headers. - // Group headers by X-range. For each group, count data sub-rows from first line. - // - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order - // - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1") - const xGroups = new Map(); - columns.forEach(c => { - const key = Math.round(c.x) + ':' + Math.round(c.right); - if (!xGroups.has(key)) xGroups.set(key, []); - xGroups.get(key).push(c); - }); - for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y); - - const firstDataLine = body?.querySelector('.gridLine'); - const subRowMap = new Map(); - if (firstDataLine) { - [...firstDataLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const r = box.getBoundingClientRect(); - const cx = r.x + r.width / 2; - for (const [key, hdrs] of xGroups) { - const h0 = hdrs[0]; - if (cx >= h0.x && cx < h0.right) { - if (!subRowMap.has(key)) subRowMap.set(key, []); - subRowMap.get(key).push({ y: r.y }); - break; - } - } - }); - for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y); - } - - const multiRowGroups = new Map(); - for (const [key, hdrs] of xGroups) { - const subs = subRowMap.get(key); - if (!subs || subs.length <= 1) continue; - if (hdrs.length >= 2) { - multiRowGroups.set(key, hdrs); - } else if (hdrs.length === 1 && subs.length > 1) { - const base = hdrs[0]; - const baseIdx = columns.indexOf(base); - columns.splice(baseIdx, 1); - const expanded = []; - for (let si = 0; si < subs.length; si++) { - const numbered = { - text: base.text + ' ' + (si + 1), - x: base.x, w: base.w, right: base.right, - y: base.y + si, h: base.h / subs.length, _subIdx: si - }; - columns.splice(baseIdx + si, 0, numbered); - expanded.push(numbered); - } - multiRowGroups.set(key, expanded); - } - } - - function matchColumn(cellX, cellW, cellY) { - const cx = cellX + cellW / 2; - for (const [key, hdrs] of multiRowGroups) { - const h0 = hdrs[0]; - if (cx >= h0.x && cx < h0.right) { - const subs = subRowMap.get(key); - if (subs) { - const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5); - if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx]; - } - let best = hdrs[0], bestDist = Infinity; - for (const h of hdrs) { - const dist = Math.abs(cellY - h.y); - if (dist < bestDist) { bestDist = dist; best = h; } - } - return best; - } - } - return columns.find(c => cx >= c.x && cx < c.right); - } - - // Extract data rows from gridBody - const allLines = body.querySelectorAll('.gridLine'); - const total = allLines.length; - const rows = []; - const end = Math.min(${offset} + ${maxRows}, total); - for (let i = ${offset}; i < end; i++) { - const line = allLines[i]; - if (!line) break; - const row = {}; - columns.forEach(c => { row[c.text] = ''; }); - [...line.children].forEach(box => { - if (box.offsetWidth === 0) return; - const textEl = box.querySelector('.gridBoxText'); - const chk = box.querySelector('.checkbox'); - let val; - if (chk) { - val = chk.classList.contains('select') ? 'true' : 'false'; - } else { - val = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; - if (!val) return; - } - // Match cell to column by X+Y overlap (multi-row aware) - const r = box.getBoundingClientRect(); - const col = matchColumn(r.x, r.width, r.y); - if (col) { - row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val; - } - }); - // Detect row kind: group (gridListH), parent/up (gridListV), or element - const imgBox = line.querySelector('.gridBoxImg'); - if (imgBox) { - if (imgBox.querySelector('.gridListH')) row._kind = 'group'; - else if (imgBox.querySelector('.gridListV')) row._kind = 'parent'; - } - // Tree mode: detect expand/collapse state and indent level - const treeBox = line.querySelector('.gridBoxTree'); - if (treeBox) { - const treeIcon = imgBox?.querySelector('[tree="true"]'); - if (treeIcon) { - const bg = treeIcon.style.backgroundImage || ''; - row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed'; - } - row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0; - } - // Selection state: selRow = selected row in grid - if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true; - rows.push(row); - } - const isTree = !!body.querySelector('.gridBoxTree'); - const hasGroups = rows.some(r => r._kind === 'group'); - const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length }; - if (isTree) result.viewMode = 'tree'; - if (hasGroups) result.hierarchical = true; - return result; - })()`; -} - -/** - * Combined: detect form + read form + read open tabs. - * Single evaluate call instead of 3. Used by browser.getFormState(). - */ -export function getFormStateScript() { - return `(() => { - ${DETECT_FORM_FN} - ${DETECT_FORMS_FN} - ${READ_FORM_FN} - const formNum = detectForm(); - const meta = detectForms(); - if (formNum === null) return { form: null, formCount: 0, message: 'No form detected' }; - const p = 'form' + formNum + '_'; - const formData = readForm(p); - // Open tabs bar (present only when tab panel is enabled in 1C settings) - const openTabs = []; - document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => { - const text = el.innerText?.trim(); - if (!text) return; - const entry = { name: text }; - if (el.classList.contains('select')) entry.active = true; - openTabs.push(entry); - }); - const activeTab = openTabs.find(t => t.active)?.name || null; - const result = { form: formNum, activeTab, openForms: meta.allForms, formCount: meta.formCount, ...formData }; - if (meta.modal) result.modal = true; - if (openTabs.length) result.openTabs = openTabs; - return result; - })()`; -} - -/** - * Navigate to a section by name (fuzzy match). - * Returns the matched section name, or { error, available }. - */ -export function navigateSectionScript(name) { - return `(() => { - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ').replace(/[\\r\\n]+/g, ' ').replace(/ +/g, ' ') || '').replace(/ё/gi, 'е'); - const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е').replace(/[\r\n]+/g, ' ').replace(/ +/g, ' '))}; - const els = [...document.querySelectorAll('[id^="themesCell_theme_"]')]; - let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target); - if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target)); - if (bestEl) { bestEl.click(); return norm(bestEl.innerText); } - return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) }; - })()`; -} - -/** - * Open a command from function panel by name (fuzzy match). - */ -export function openCommandScript(name) { - return `(() => { - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); - const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))}; - const els = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(el => el.offsetWidth > 0); - let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target); - if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target)); - if (bestEl) { bestEl.click(); return norm(bestEl.innerText); } - return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) }; - })()`; -} - -/** - * Find a clickable element on the current form (button, hyperlink, tab, frame button). - * Returns { id, kind, name } for Playwright page.click(), or { error, available }. - * Supports synonym matching: visible text AND internal name from DOM ID. - * Fuzzy order: exact name -> exact label -> includes name -> includes label. - */ -export function findClickTargetScript(formNum, text, { tableName, gridSelector } = {}) { - const p = `form${formNum}_`; - return `(() => { - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); - const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))}; - const p = ${JSON.stringify(p)}; - const tableName = ${JSON.stringify(tableName || '')}; - const gridSelector = ${JSON.stringify(gridSelector || '')}; - const items = []; - - // Buttons (a.press) - [...document.querySelectorAll('a.press[id^="' + p + '"]')].filter(el => el.offsetWidth > 0).forEach(el => { - const idName = el.id.replace(p, ''); - if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return; - const span = el.querySelector('.submenuText') || el.querySelector('span'); - const text = norm(span?.textContent) || norm(el.innerText); - if (!text && !el.classList.contains('pressCommand')) return; - const isSubmenu = /^(?:Подменю|allActions)/i.test(idName); - const item = { id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' }; - // Icon-only buttons: use tooltip for fuzzy match (1C puts title on parent .framePress) - if (!text) { const tip = norm(el.title || el.parentElement?.title || ''); if (tip) item.tooltip = tip; } - items.push(item); - }); - - // Hyperlinks (staticTextHyper) - [...document.querySelectorAll('[id^="' + p + '"].staticTextHyper')].filter(el => el.offsetWidth > 0).forEach(el => { - const idName = el.id.replace(p, ''); - const text = norm(el.innerText); - items.push({ id: el.id, name: text, label: idName, kind: 'hyperlink' }); - }); - - // Frame buttons - [...document.querySelectorAll('[id^="' + p + '"] .frameButton, [id^="' + p + '"].frameButton')].filter(el => el.offsetWidth > 0).forEach(el => { - const text = norm(el.innerText); - const idName = el.id.replace(p, ''); - if (!text && !idName) return; - items.push({ id: el.id, name: text || idName, label: text ? '' : idName, kind: 'frameButton' }); - }); - - // Tumbler items (toggle switch segments) - [...document.querySelectorAll('[id^="' + p + '"].tumblerItem')].filter(el => el.offsetWidth > 0).forEach(el => { - const idName = el.id.replace(p, ''); - const text = norm(el.innerText); - items.push({ id: el.id, name: text || idName, label: idName, kind: 'tumbler' }); - }); - - // Checkboxes (div.checkbox) — match by label or internal name - [...document.querySelectorAll('[id^="' + p + '"].checkbox')].filter(el => el.offsetWidth > 0).forEach(el => { - const idName = el.id.replace(p, ''); - const titleEl = document.getElementById(p + idName + '#title_text'); - const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim(); - items.push({ id: el.id, name: label || idName, label: idName, kind: 'checkbox' }); - }); - - // Tabs (scoped to form) - [...document.querySelectorAll('[data-content]')].filter(el => { - if (el.offsetWidth === 0) return false; - let node = el.parentElement; - while (node) { - if (node.id && node.id.startsWith(p)) return true; - node = node.parentElement; - } - return false; - }).forEach(el => { - const r = el.getBoundingClientRect(); - items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab', - x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); - }); - - // 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); - if (gridEl) { - // Find parent container that has id with formPrefix and contains the grid - let container = gridEl.parentElement; - while (container && container !== document.body) { - if (container.id && container.id.startsWith(p)) break; - container = container.parentElement; - } - // Filter items to those inside the container - const containerItems = container && container !== document.body - ? items.filter(i => { const el = document.getElementById(i.id); return el && container.contains(el); }) - : []; - // Try fuzzy match within container first - let cf = containerItems.find(i => i.name.toLowerCase() === target); - if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target); - if (!cf && target.length >= 4) cf = containerItems.find(i => i.name.toLowerCase().includes(target)); - if (!cf && target.length >= 4) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target)); - if (cf) { const res = { id: cf.id, kind: cf.kind, name: cf.name }; if (cf.x != null) { res.x = cf.x; res.y = cf.y; } return res; } - // Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить) - const gridName = gridEl.id ? gridEl.id.replace(p, '') : ''; - if (gridName) { - const prefixItems = items.filter(i => i.label && i.label.includes(gridName)); - let pf = prefixItems.find(i => i.name.toLowerCase() === target); - if (!pf && target.length >= 4) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target)); - if (!pf && target.length >= 4) pf = prefixItems.find(i => i.name.toLowerCase().includes(target)); - if (pf) { const res = { id: pf.id, kind: pf.kind, name: pf.name }; if (pf.x != null) { res.x = pf.x; res.y = pf.y; } return res; } - } - } - // Fall through to unscoped search - } - - // Fuzzy match: exact name -> exact label -> exact tooltip -> startsWith name -> startsWith label -> includes name -> includes label -> includes tooltip - // Skip includes() for short strings (< 4 chars) to avoid false positives - // e.g. "Да" matching "КомандаУстановитьВсе" - let found = items.find(i => i.name.toLowerCase() === target); - if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target); - if (!found) found = items.find(i => i.tooltip && i.tooltip.toLowerCase() === target); - if (!found) found = items.find(i => i.name.toLowerCase().startsWith(target)); - if (!found) found = items.find(i => i.label && i.label.toLowerCase().startsWith(target)); - if (!found && target.length >= 4) found = items.find(i => i.name.toLowerCase().includes(target)); - if (!found && target.length >= 4) found = items.find(i => i.label && i.label.toLowerCase().includes(target)); - if (!found && target.length >= 4) found = items.find(i => i.tooltip && i.tooltip.toLowerCase().includes(target)); - - if (found) { - const res = { id: found.id, kind: found.kind, name: found.name }; - if (found.x != null) { res.x = found.x; res.y = found.y; } - return res; - } - - // Grid rows — fallback: search in table rows (for hierarchical/tree navigation) - // Search ALL visible grids (or specific grid when table parameter is set) - let grids; - if (gridSelector) { - const g = document.querySelector(gridSelector); - grids = g ? [g] : []; - } else { - grids = [...document.querySelectorAll('[id^="' + p + '"].grid')].filter(g => g.offsetWidth > 0); - } - for (const grid of grids) { - const body = grid.querySelector('.gridBody'); - if (!body) continue; - const lines = [...body.querySelectorAll('.gridLine')]; - for (const line of lines) { - const textBoxes = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0); - const rowTexts = textBoxes.map(b => norm(b.innerText) || '').filter(Boolean); - const firstCell = rowTexts[0]?.toLowerCase() || ''; - const rowText = rowTexts.join(' ').toLowerCase(); - if (firstCell === target || rowText === target || (target.length >= 4 && (firstCell.includes(target) || rowText.includes(target)))) { - const imgBox = line.querySelector('.gridBoxImg'); - const isGroup = imgBox?.querySelector('.gridListH') !== null; - const isParent = imgBox?.querySelector('.gridListV') !== null; - const isTreeNode = line.querySelector('.gridBoxTree') !== null; - const hasChildren = line.querySelector('[tree="true"]') !== null; - let kind; - if (isGroup) kind = 'gridGroup'; - else if (isParent) kind = 'gridParent'; - else if (isTreeNode && hasChildren) kind = 'gridTreeNode'; - else kind = 'gridRow'; - const r = line.getBoundingClientRect(); - return { id: '', kind, name: rowTexts[0] || '', gridId: grid.id, - x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - } - } - } - - return { error: 'not_found', available: items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean) }; - })()`; -} - -/** - * Find a field's action button (DLB, OB, CLR, CB) by fuzzy field name. - * Returns { fieldName, buttonId, buttonType } or { error, available }. - */ -export function findFieldButtonScript(formNum, fieldName, buttonSuffix = 'DLB') { - const p = `form${formNum}_`; - return `(() => { - const p = ${JSON.stringify(p)}; - const target = ${JSON.stringify(fieldName.toLowerCase().replace(/ё/g, 'е'))}; - const suffix = ${JSON.stringify(buttonSuffix)}; - const allFields = []; - document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => { - if (el.offsetWidth === 0) return; - const name = el.id.replace(p, '').replace(/_i\\d+$/, ''); - const titleEl = document.getElementById(p + name + '#title_text') - || document.getElementById(p + name + '#title_div'); - const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); - allFields.push({ name, label }); - }); - // Also collect checkboxes for DCS pair matching - const allCheckboxes = []; - document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => { - if (el.offsetWidth === 0) return; - const name = el.id.replace(p, ''); - const titleEl = document.getElementById(p + name + '#title_text'); - const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); - allCheckboxes.push({ inputId: el.id, name, label }); - }); - // Build DCS pairs: checkbox label → paired value field - const dcsPairs = {}; - for (const f of [...allFields, ...allCheckboxes]) { - const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/); - if (!m) continue; - if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {}; - dcsPairs[m[1]][m[2]] = f; - } - let found = allFields.find(f => f.name.toLowerCase() === target); - if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target); - if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target)); - if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target)); - // DCS pair: match checkbox or value label → resolve to paired value field - let dcsCheckbox = null; - if (!found) { - for (const pair of Object.values(dcsPairs)) { - const cb = pair['Использование']; - const val = pair['Значение']; - if (!cb || !val) continue; - const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase(); - if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) { - found = val; - dcsCheckbox = cb; - break; - } - } - } - if (!found) { - return { error: 'field_not_found', available: allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name) }; - } - const btnId = p + found.name + '_' + suffix; - const btn = document.getElementById(btnId); - if (!btn || btn.offsetWidth === 0) { - return { error: 'button_not_found', fieldName: found.name, message: suffix + ' button not visible for field ' + found.name }; - } - const result = { fieldName: found.name, buttonId: btnId, buttonType: suffix }; - if (dcsCheckbox) result.dcsCheckbox = { inputId: dcsCheckbox.inputId }; - return result; - })()`; -} - -/** - * Read open popup/submenu items. - * Looks for absolutely positioned visible popup containers with a.press items inside. - * Returns [{ id, name }] or { error }. - */ -export function readSubmenuScript() { - return `(() => { - const items = []; - const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); - - // 1. DLB dropdown (#editDropDown with .eddText items) - const edd = document.getElementById('editDropDown'); - if (edd && edd.offsetWidth > 0 && edd.offsetHeight > 0) { - edd.querySelectorAll('.eddText').forEach(el => { - if (el.offsetWidth === 0) return; - const text = norm(el.innerText); - if (!text) return; - const r = el.getBoundingClientRect(); - items.push({ id: '', name: text, kind: 'dropdown', - x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); - }); - // Detect "Показать все" link in EDD footer - // Structure: div.eddBottom > div > span.hyperlink "Показать все" - let showAllEl = edd.querySelector('.eddBottom .hyperlink'); - if (!showAllEl || showAllEl.offsetWidth === 0) { - // Fallback: scan all visible elements for text match - const candidates = [...edd.querySelectorAll('a.press, a, span, div')] - .filter(el => el.offsetWidth > 0 && el.children.length === 0); - showAllEl = candidates.find(el => { - const t = norm(el.innerText).toLowerCase(); - return t === 'показать все' || t === 'show all'; - }); - } - if (showAllEl) { - const r = showAllEl.getBoundingClientRect(); - items.push({ id: showAllEl.id || '', name: norm(showAllEl.innerText), kind: 'showAll', - x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); - } - if (items.length > 0) return items; - } - - // 2. Cloud submenu (allActions / command panel menus — div.cloud with .submenuText items) - // Read ALL visible high-z clouds (main menu + nested submenus) - const clouds = [...document.querySelectorAll('.cloud')].filter(c => c.offsetWidth > 0 && c.offsetHeight > 0); - const seen = new Set(); - clouds.forEach(c => { - const z = parseInt(getComputedStyle(c).zIndex) || 0; - if (z <= 1000) return; - c.querySelectorAll('.submenuText').forEach(el => { - if (el.offsetWidth === 0) return; - const text = norm(el.innerText); - if (!text || seen.has(text)) return; - seen.add(text); - const block = el.closest('.submenuBlock'); - if (block && block.classList.contains('submenuBlockDisabled')) return; - const hasSub = block && /_sub$/.test(block.id); - const r = el.getBoundingClientRect(); - items.push({ id: block?.id || '', name: text, kind: hasSub ? 'submenuArrow' : 'submenu', - x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); - }); - }); - if (items.length > 0) return items; - - // 3. Submenu popups — find the topmost positioned container with non-form a.press items - const popups = [...document.querySelectorAll('div')].filter(c => { - const style = getComputedStyle(c); - return (style.position === 'absolute' || style.position === 'fixed') - && c.offsetWidth > 0 && c.offsetHeight > 0; - }).sort((a, b) => { - const za = parseInt(getComputedStyle(a).zIndex) || 0; - const zb = parseInt(getComputedStyle(b).zIndex) || 0; - return zb - za; - }); - for (const container of popups) { - // Only direct a.press children or those not nested in another positioned div - const menuItems = [...container.querySelectorAll('a.press')].filter(el => { - if (el.offsetWidth === 0) return false; - if (el.id && /^form\\d+_/.test(el.id)) return false; - // Skip if this a.press is inside a deeper positioned container - let parent = el.parentElement; - while (parent && parent !== container) { - const ps = getComputedStyle(parent).position; - if (ps === 'absolute' || ps === 'fixed') return false; - parent = parent.parentElement; - } - return true; - }); - if (menuItems.length < 2) continue; // Not a real menu - const seen = new Set(); - menuItems.forEach(el => { - const text = norm(el.innerText); - if (!text) return; - if (seen.has(text)) return; - seen.add(text); - const r = el.getBoundingClientRect(); - items.push({ id: el.id || '', name: text, kind: 'submenu', - x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); - }); - if (items.length > 0) break; // Found the popup menu - } - - if (items.length === 0) return { error: 'no_popup', message: 'No open popup/submenu found' }; - return items; - })()`; -} - -/** - * Click a popup/dropdown item by text match (evaluate-based for items without IDs). - * Returns true if clicked, false if not found. - */ -export function clickPopupItemScript(text) { - return `(() => { - const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))}; - // 1. DLB dropdown (#editDropDown .eddText items) - const edd = document.getElementById('editDropDown'); - if (edd && edd.offsetWidth > 0) { - for (const el of edd.querySelectorAll('.eddText')) { - if (el.offsetWidth === 0) continue; - const t = el.innerText?.trim() || ''; - if (t.toLowerCase() === target || t.toLowerCase().includes(target)) { - el.click(); - return t; - } - } - } - - // 2. Submenu popups (a.press in absolutely positioned containers) - const containers = [...document.querySelectorAll('div')].filter(c => { - const style = getComputedStyle(c); - return (style.position === 'absolute' || style.position === 'fixed') - && c.offsetWidth > 0 && c.offsetHeight > 0; - }); - for (const container of containers) { - const items = [...container.querySelectorAll('a.press')] - .filter(el => el.offsetWidth > 0); - for (const el of items) { - const t = el.innerText?.trim() || ''; - if (t.toLowerCase() === target || t.toLowerCase().includes(target)) { - el.click(); - return t; - } - } - } - return null; - })()`; -} - -/** - * Check for validation errors / diagnostics after an action. - * Detects three patterns: - * 1. Inline balloon tooltip (div.balloon with .balloonMessage) - * 2. Messages panel (div.messages with msg0, msg1... grid rows) - * 3. Modal error dialog (high-numbered form with pressDefault + static texts) - * Returns { balloon, messages[], modal } or null if no errors. - */ -export function checkErrorsScript() { - return `(() => { - const result = {}; - - // 1. Inline balloon tooltip - const balloon = document.querySelector('.balloon'); - if (balloon && balloon.offsetWidth > 0) { - const msg = balloon.querySelector('.balloonMessage'); - const title = balloon.querySelector('.balloonTitle'); - if (msg) { - result.balloon = { - title: title?.innerText?.trim() || 'Ошибка', - message: msg.innerText?.trim() || '' - }; - // Count navigation arrows to indicate total errors - const fwd = balloon.querySelector('.balloonJumpFwd'); - const back = balloon.querySelector('.balloonJumpBack'); - const fwdDisabled = fwd?.classList.contains('disabled'); - const backDisabled = back?.classList.contains('disabled'); - if (fwd && !fwdDisabled) result.balloon.hasNext = true; - if (back && !backDisabled) result.balloon.hasPrev = true; - } - } - - // 2. Messages panel (div.messages — pick visible one, multiple may exist across tabs) - const msgPanels = [...document.querySelectorAll('.messages')].filter(el => el.offsetWidth > 0); - for (const msgPanel of msgPanels) { - const msgs = []; - msgPanel.querySelectorAll('[id^="msg"]').forEach(line => { - if (line.offsetWidth === 0) return; - const textEl = line.querySelector('.gridBoxText'); - const text = (textEl || line).innerText?.trim(); - if (text) msgs.push(text); - }); - if (msgs.length > 0) { result.messages = msgs; break; } - } - - // 3+4. Modal dialogs: confirmation (multiple buttons) or error (single pressDefault) - // Uses form container ancestry to group buttons — pressButton elements often lack form-prefixed IDs - // Note: 1C shows some modals WITHOUT #modalSurface (e.g. "Не удалось записать" uses ps*win floating window) - // so we always scan for small forms with button patterns, regardless of modalSurface state - const formButtons = {}; - [...document.querySelectorAll('a.press.pressButton')].forEach(btn => { - if (btn.offsetWidth === 0) return; - const container = btn.closest('[id$="_container"]'); - const m = container?.id?.match(/^form(\\d+)_/); - if (!m) return; - const fn = m[1]; - if (!formButtons[fn]) formButtons[fn] = []; - formButtons[fn].push(btn); - }); - - for (const [fn, buttons] of Object.entries(formButtons)) { - const p = 'form' + fn + '_'; - const elCount = document.querySelectorAll('[id^="' + p + '"]').length; - if (elCount > 100) continue; // Skip large content forms - if (buttons.length > 1) { - // Confirmation dialog (multiple buttons: Да/Нет, OK/Отмена, etc.) - // Must have a Message element — real 1C confirmations always have form{N}_Message. - // Without it, this is just a regular form with multiple buttons (e.g. EPF form). - const msgEl = document.getElementById(p + 'Message'); - if (!msgEl || msgEl.offsetWidth === 0) continue; - const message = msgEl.innerText?.trim() || ''; - const btnNames = buttons.map(el => { - const b = { name: el.innerText?.trim() || '' }; - if (el.classList.contains('pressDefault')) b.default = true; - return b; - }).filter(b => b.name); - result.confirmation = { message, buttons: btnNames.map(b => b.name), formNum: parseInt(fn) }; - break; - } - } - - // Single-button modal: error dialog with pressDefault + staticText - // Skip forms with input fields — those are data entry forms (e.g. register record), - // not error dialogs. Real error modals only have staticText + buttons. - if (!result.confirmation) { - for (const [fn, buttons] of Object.entries(formButtons)) { - const p = 'form' + fn + '_'; - const elCount = document.querySelectorAll('[id^="' + p + '"]').length; - if (elCount > 100) continue; - if (buttons.length !== 1 || !buttons[0].classList.contains('pressDefault')) continue; - const hasInputs = document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').length > 0; - if (hasInputs) continue; - const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')] - .filter(el => el.offsetWidth > 0) - .map(el => el.innerText?.trim()) - .filter(Boolean); - if (texts.length > 0) { - result.modal = { message: texts.join(' '), formNum: parseInt(fn), button: buttons[0].innerText?.trim() || '' }; - // Check if OpenReport link is available (platform exceptions have visible link text) - const reportLink = document.getElementById(p + 'OpenReport#text'); - if (reportLink && reportLink.offsetWidth > 2 && reportLink.textContent.trim()) { - result.modal.hasReport = true; - } - // Grab AdditionalInfo/ServerText if filled (may contain extra error details) - const addInfo = document.getElementById(p + 'AdditionalInfo'); - if (addInfo && addInfo.textContent && addInfo.textContent.trim()) result.modal.additionalInfo = addInfo.textContent.trim(); - const srvText = document.getElementById(p + 'ServerText'); - if (srvText && srvText.textContent && srvText.textContent.trim()) result.modal.serverText = srvText.textContent.trim(); - break; - } - } - } - - // 5. SpreadsheetDocument state window (info bar inside moxelContainer) - // Shows messages like "Не установлено значение параметра X" or "Отчет не сформирован" - const stateWins = [...document.querySelectorAll('.stateWindowSupportSurface')].filter(el => el.offsetWidth > 0); - if (stateWins.length) { - const texts = stateWins.map(el => el.innerText?.trim()).filter(Boolean); - if (texts.length) result.stateText = texts; - } - - return (result.balloon || result.messages || result.modal || result.confirmation || result.stateText) ? result : null; - })()`; -} - -/** - * Resolve field names to element IDs for Playwright page.fill(). - * Returns [{ field, inputId, name, label }] or [{ field, error, available }]. - * Supports synonym matching: internal name AND visible label. - * Fuzzy order: exact name -> exact label -> includes name -> includes label. - */ -export function resolveFieldsScript(formNum, fields) { - const p = `form${formNum}_`; - return `(() => { - const p = ${JSON.stringify(p)}; - const fieldNames = ${JSON.stringify(Object.keys(fields))}; - const results = []; - - // Build field map with name + label for synonym matching - const allFields = []; - document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => { - if (el.offsetWidth === 0) return; - const name = el.id.replace(p, '').replace(/_i\\d+$/, ''); - const titleEl = document.getElementById(p + name + '#title_text') - || document.getElementById(p + name + '#title_div'); - const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); - const last = { inputId: el.id, name, label }; - if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) last.hasSelect = true; - const cbEl = document.getElementById(p + name + '_CB'); - if (cbEl?.offsetWidth > 0) { - last.hasPick = true; - if (cbEl.classList.contains('iCalendB')) last.isDate = true; - } - allFields.push(last); - }); - // Checkboxes - document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => { - if (el.offsetWidth === 0) return; - const name = el.id.replace(p, ''); - const titleEl = document.getElementById(p + name + '#title_text'); - const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); - const checked = el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'); - allFields.push({ inputId: el.id, name, label, isCheckbox: true, checked }); - }); - // Radio button groups — base element = option 0, others are #N#radio - const radioSeen = new Set(); - document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => { - if (el.offsetWidth === 0) return; - const id = el.id.replace(p, ''); - // Skip if already processed or if it's a sub-element (#N#radio) - const m = id.match(/^(.+?)#(\\d+)#radio$/); - const groupName = m ? m[1] : (!id.includes('#') ? id : null); - if (!groupName || radioSeen.has(groupName)) return; - radioSeen.add(groupName); - const titleEl = document.getElementById(p + groupName + '#title_text'); - const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); - // Collect options: option 0 is the base element, options 1+ have #N#radio - const options = []; - // Option 0: base element - const base = document.getElementById(p + groupName); - if (base && base.classList.contains('radio') && base.offsetWidth > 0) { - const textEl = document.getElementById(p + groupName + '#0#radio_text'); - options.push({ index: 0, label: textEl?.innerText?.trim() || '', selected: base.classList.contains('select') }); - } - // Options 1+ - for (let i = 1; i < 20; i++) { - const opt = document.getElementById(p + groupName + '#' + i + '#radio'); - if (!opt || opt.offsetWidth === 0) break; - const textEl = document.getElementById(p + groupName + '#' + i + '#radio_text'); - options.push({ index: i, label: textEl?.innerText?.trim() || '', selected: opt.classList.contains('select') }); - } - allFields.push({ inputId: p + groupName, name: groupName, label, isRadio: true, options }); - }); - - // Build DCS pairs: checkbox label → paired value field - const dcsPairs = {}; - for (const f of allFields) { - const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/); - if (!m) continue; - if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {}; - dcsPairs[m[1]][m[2]] = f; - } - - for (const fieldName of fieldNames) { - const target = fieldName.toLowerCase().replace(/\\n/g, ' ').replace(/:$/, ''); - // Fuzzy: exact name -> exact label -> includes name -> includes label - let found = allFields.find(f => f.name.toLowerCase() === target); - if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target); - if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target)); - if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target)); - // DCS pair: match checkbox or value label → resolve to paired value field - if (!found) { - for (const pair of Object.values(dcsPairs)) { - const cb = pair['Использование']; - const val = pair['Значение']; - if (!cb || !val) continue; - const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase(); - if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) { - found = val; - found._dcsCheckbox = cb; - break; - } - } - } - - if (found) { - const entry = { field: fieldName, inputId: found.inputId, name: found.name, label: found.label }; - if (found.isCheckbox) { entry.isCheckbox = true; entry.checked = found.checked; } - if (found.isRadio) { entry.isRadio = true; entry.options = found.options; } - if (found.hasSelect) entry.hasSelect = true; - if (found.hasPick) entry.hasPick = true; - if (found.isDate) entry.isDate = true; - if (found._dcsCheckbox) { - entry.dcsCheckbox = { inputId: found._dcsCheckbox.inputId, checked: found._dcsCheckbox.checked }; - delete found._dcsCheckbox; - } - results.push(entry); - } else { - const available = allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name); - results.push({ field: fieldName, error: 'not_found', available }); - } - } - return results; - })()`; -} +// web-test dom v1.8 — 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. + * Внутренности живут в dom/*. Публичный набор имён неизменен. + * + * All functions return JavaScript strings for page.evaluate(). + * They produce clean semantic structures — no DOM IDs or CSS classes leak out. + * Only non-default property values are included to minimize response size. + */ + +export { + detectFormScript, + readFormScript, + findClickTargetScript, + findFieldButtonScript, + resolveFieldsScript, +} from './dom/forms.mjs'; + +export { getFormStateScript } from './dom/form-state.mjs'; + +export { + resolveGridScript, + readTableScript, +} from './dom/grid.mjs'; + +export { + readSectionsScript, + readTabsScript, + switchTabScript, + readCommandsScript, + navigateSectionScript, + openCommandScript, +} from './dom/nav.mjs'; + +export { + readSubmenuScript, + clickPopupItemScript, +} from './dom/submenu.mjs'; + +export { checkErrorsScript } from './dom/errors.mjs'; diff --git a/.claude/skills/web-test/scripts/dom/_shared.mjs b/.claude/skills/web-test/scripts/dom/_shared.mjs new file mode 100644 index 00000000..18fb5b4e --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/_shared.mjs @@ -0,0 +1,391 @@ +// web-test dom shared v1.0 — embedded JS function constants +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +/** + * Shared function strings embedded into page.evaluate() generators. + * Не экспортируются наружу через dom.mjs facade — внутренняя кухня. + */ + +/** Find visible #modalSurface. 1C may leave multiple #modalSurface in DOM (duplicate id), + * e.g. when a second form (drill-down) creates its own alongside a stale one from the first + * form. getElementById returns the FIRST in document order, which may be hidden. Scan all. */ +export const HAS_VISIBLE_MODAL_FN = `function hasVisibleModal() { + const all = document.querySelectorAll('#modalSurface'); + for (const el of all) { if (el.offsetWidth > 0) return true; } + return false; +}`; + +/** Detect active form number. Picks form with most visible elements, skipping form0. + * When modalSurface is visible — prefer the highest-numbered form (modal dialog). */ +export const DETECT_FORM_FN = HAS_VISIBLE_MODAL_FN + ` +function detectForm() { + const counts = {}; + document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => { + if (el.offsetWidth === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) counts[m[1]] = (counts[m[1]] || 0) + 1; + }); + const nums = Object.keys(counts).map(Number); + if (!nums.length) return null; + const candidates = nums.filter(n => n > 0); + if (!candidates.length) return nums[0]; + // When modal surface is visible, prefer the highest-numbered form (modal dialog) + if (hasVisibleModal()) { + const maxForm = Math.max(...candidates); + if (counts[maxForm] >= 1) return maxForm; + } + return candidates.reduce((best, n) => counts[n] > counts[best] ? n : best); +}`; + +/** Detect all open forms + modal state. Returns { activeForm, allForms, formCount, modal }. + * Works even when the open-windows tab bar is hidden. */ +export const DETECT_FORMS_FN = HAS_VISIBLE_MODAL_FN + ` +function detectForms() { + const counts = {}; + document.querySelectorAll('input.editInput[id], textarea[id], a.press[id]').forEach(el => { + if (el.offsetWidth === 0) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) counts[m[1]] = (counts[m[1]] || 0) + 1; + }); + const nums = Object.keys(counts).map(Number); + return { allForms: nums.sort((a, b) => a - b), formCount: nums.length, modal: hasVisibleModal() }; +}`; + +/** Read form state given prefix p. Returns { fields, buttons, tabs, texts, hyperlinks, table, iframes }. */ +export const READ_FORM_FN = `function readForm(p) { + const result = {}; + const fields = []; + const buttons = []; + const formTabs = []; + const texts = []; + const hyperlinks = []; + // Normalize non-breaking spaces to regular spaces + const nbsp = s => (s || '').replace(/\\u00a0/g, ' '); + + // Fields (inputs) + document.querySelectorAll('input.editInput[id^="' + p + '"]').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, '').replace(/_i\\d+$/, ''); + const titleEl = document.getElementById(p + name + '#title_text') + || document.getElementById(p + name + '#title_div'); + const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ')); + const actions = []; + if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) actions.push('select'); + if (document.getElementById(p + name + '_OB')?.offsetWidth > 0) actions.push('open'); + if (document.getElementById(p + name + '_CLR')?.offsetWidth > 0) actions.push('clear'); + if (document.getElementById(p + name + '_CB')?.offsetWidth > 0) actions.push('pick'); + const field = { name, value: el.value || '' }; + // Multi-value reference fields keep their value in .chipsItem chips, not in input.value + if (!field.value) { + const labelEl = document.getElementById(p + name); + if (labelEl) { + const chipTexts = [...labelEl.querySelectorAll('.chipsItem .chipsTitle')] + .map(c => nbsp(c.innerText?.trim() || '')) + .filter(Boolean); + if (chipTexts.length) field.value = chipTexts.join(', '); + } + } + if (label && label !== name) field.label = label; + if (el.readOnly) field.readonly = true; + if (el.disabled) field.disabled = true; + if (el.type && el.type !== 'text') field.type = el.type; + if (document.activeElement === el) field.focused = true; + if (actions.length) field.actions = actions; + if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true; + fields.push(field); + }); + + // Textareas + document.querySelectorAll('textarea[id^="' + p + '"]').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, '').replace(/_i\\d+$/, ''); + const titleEl = document.getElementById(p + name + '#title_text') + || document.getElementById(p + name + '#title_div'); + const label = nbsp((titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ')); + const field = { name, value: el.value || '', type: 'textarea' }; + if (label && label !== name) field.label = label; + if (el.readOnly) field.readonly = true; + if (el.disabled) field.disabled = true; + if (document.activeElement === el) field.focused = true; + if (el.closest('.inputsBox')?.classList.contains('markIncomplete')) field.required = true; + fields.push(field); + }); + + // Checkboxes + document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, ''); + const titleEl = document.getElementById(p + name + '#title_text'); + const label = nbsp(titleEl?.innerText?.trim() || ''); + const field = { + name, + value: el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'), + type: 'checkbox' + }; + if (label && label !== name) field.label = label; + fields.push(field); + }); + + // Radio buttons — base element is option 0, others are #N#radio (N >= 1) + const radioGroups = {}; + document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => { + if (el.offsetWidth === 0) return; + const id = el.id.replace(p, ''); + const m = id.match(/^(.+?)#(\\d+)#radio$/); + if (m) { + // Options 1, 2, ... have explicit #N#radio suffix + const [, groupName, idx] = m; + if (!radioGroups[groupName]) radioGroups[groupName] = []; + const labelEl = document.getElementById(p + groupName + '#' + idx + '#radio_text'); + const label = nbsp(labelEl?.innerText?.trim() || 'option' + idx); + radioGroups[groupName].push({ index: parseInt(idx), label, selected: el.classList.contains('select') }); + } else if (!id.includes('#')) { + // Base element = option 0 (no #0#radio suffix) + if (!radioGroups[id]) radioGroups[id] = []; + const labelEl = document.getElementById(p + id + '#0#radio_text'); + const label = nbsp(labelEl?.innerText?.trim() || 'option0'); + radioGroups[id].unshift({ index: 0, label, selected: el.classList.contains('select') }); + } + }); + for (const [name, options] of Object.entries(radioGroups)) { + const titleEl = document.getElementById(p + name + '#title_text'); + const label = titleEl?.innerText?.trim() || ''; + const selected = options.find(o => o.selected); + const field = { + name, + value: selected?.label || '', + type: 'radio', + options: options.map(o => o.label) + }; + if (label && label !== name) field.label = label; + fields.push(field); + } + + // Buttons (a.press) + document.querySelectorAll('a.press[id^="' + p + '"]').forEach(el => { + if (el.offsetWidth === 0) return; + const idName = el.id.replace(p, ''); + if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return; + const span = el.querySelector('.submenuText') || el.querySelector('span'); + const text = nbsp(span?.textContent?.trim() || el.innerText?.trim() || ''); + if (!text && !el.classList.contains('pressCommand')) return; + const btn = { name: text || idName }; + if (el.classList.contains('pressDefault')) btn.default = true; + if (el.classList.contains('pressDisabled')) btn.disabled = true; + // Icon-only buttons: expose tooltip from DOM title attribute (1C puts title on parent .framePress) + if (!text) { + const tip = nbsp(el.title || el.parentElement?.title || ''); + if (tip) btn.tooltip = tip; + } + buttons.push(btn); + }); + + // Frame buttons + document.querySelectorAll('[id^="' + p + '"].frameButton, [id^="' + p + '"] .frameButton').forEach(el => { + if (el.offsetWidth === 0) return; + const text = nbsp(el.innerText?.trim() || ''); + const idName = el.id?.replace(p, '') || ''; + if (!text && !idName) return; + buttons.push({ name: text || idName, frame: true }); + }); + + // Tumbler items + document.querySelectorAll('[id^="' + p + '"].tumblerItem').forEach(el => { + if (el.offsetWidth === 0) return; + const text = el.innerText?.trim(); + const idName = el.id?.replace(p, '') || ''; + buttons.push({ name: text || idName, tumbler: true }); + }); + + // Tabs — scoped to form by checking ancestor IDs + document.querySelectorAll('[data-content]').forEach(el => { + if (el.offsetWidth === 0) return; + let node = el.parentElement; + let inForm = false; + while (node) { + if (node.id && node.id.startsWith(p)) { inForm = true; break; } + node = node.parentElement; + } + if (!inForm) return; + const tab = { name: el.dataset.content }; + if (el.classList.contains('select')) tab.active = true; + formTabs.push(tab); + }); + + // Static texts and hyperlinks + document.querySelectorAll('[id^="' + p + '"].staticText').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, ''); + if (name.endsWith('_div') || name.includes('#title')) return; + const text = el.innerText?.trim(); + if (!text) return; + if (el.classList.contains('staticTextHyper')) { + hyperlinks.push({ name: text }); + } else { + const titleEl = document.getElementById(p + name + '#title_text'); + const label = titleEl?.innerText?.trim() || ''; + const entry = { name, value: text }; + if (label) entry.label = label; + texts.push(entry); + } + }); + + // Tables/grids — collect ALL visible grids + const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .filter(g => g.offsetWidth > 0 && g.offsetHeight > 0); + if (allGrids.length > 0) { + const tables = allGrids.map(grid => { + const name = grid.id ? grid.id.replace(p, '') : ''; + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + const columns = []; + if (head) { + const headLine = head.querySelector('.gridLine') || head; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const textEl = box.querySelector('.gridBoxText'); + const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; + if (text) { + const r = box.getBoundingClientRect(); + columns.push({ text, x: r.x, right: r.x + r.width, y: r.y, h: r.height }); + } else { + // Unnamed column — check if data cells contain checkboxes + const firstLine = body?.querySelector('.gridLine'); + if (firstLine) { + const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0); + const idx = visibleHeaders.indexOf(box); + const cells = [...firstLine.children].filter(c => c.offsetWidth > 0); + if (cells[idx]?.querySelector('.checkbox')) { + columns.push({ text: '(checkbox)', x: 0, right: 0, y: 0, h: 0 }); + } + } + } + }); + // Expand single merged headers with multiple data sub-rows (e.g. "Субконто Дт" → 1/2/3) + const firstLine = body?.querySelector('.gridLine'); + if (firstLine && columns.length > 0) { + const xGrp = new Map(); + columns.forEach(c => { + const k = Math.round(c.x) + ':' + Math.round(c.right); + if (!xGrp.has(k)) xGrp.set(k, []); + xGrp.get(k).push(c); + }); + for (const [k, hdrs] of xGrp) { + if (hdrs.length !== 1) continue; + let cnt = 0; + [...firstLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const r = box.getBoundingClientRect(); + const cx = r.x + r.width / 2; + if (cx >= hdrs[0].x && cx < hdrs[0].right) cnt++; + }); + if (cnt > 1) { + const base = hdrs[0]; + const baseIdx = columns.indexOf(base); + columns.splice(baseIdx, 1); + for (let si = 0; si < cnt; si++) { + columns.splice(baseIdx + si, 0, { text: base.text + ' ' + (si + 1), x: base.x, right: base.right, y: 0, h: 0 }); + } + } + } + } + } + const colNames = columns.map(c => c.text); + const rowCount = body ? body.querySelectorAll('.gridLine').length : 0; + // Visual label from group title (e.g. "Входящие:" for grid "Входящие") + const titleEl = document.getElementById(p + name + '#title_div') + || document.getElementById(p + 'Группа' + name + '#title_div'); + const label = titleEl ? (titleEl.innerText?.trim().replace(/:\\s*$/, '').replace(/\\u00a0/g, ' ') || null) : null; + return { name, columns: colNames, rowCount, ...(label ? { label } : {}) }; + }); + result.tables = tables; + // Backward compat: table = first grid summary + const first = tables[0]; + result.table = { present: true, columns: first.columns, rowCount: first.rowCount }; + } + + // Active filters (train badges above grid: *СостояниеПросмотра) + const filters = []; + document.querySelectorAll('[id^="' + p + '"].trainItem').forEach(el => { + if (el.offsetWidth === 0) return; + const titleEl = el.querySelector('.trainName'); + const valueEl = el.querySelector('.trainTitle'); + if (!titleEl && !valueEl) return; + const field = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/\\s*:$/, '').trim(); + const value = valueEl?.innerText?.trim()?.replace(/\\n/g, ' ') || ''; + if (field || value) filters.push({ field, value }); + }); + // Also check search field value + const searchInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); + if (searchInput?.value) { + filters.push({ type: 'search', value: searchInput.value }); + } + 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 => { + if (el.offsetWidth > 0 && el.offsetHeight > 0) iframeCount++; + }); + if (iframeCount) result.iframes = iframeCount; + + 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; + + // Group DCS report settings into readable format + if (result.fields) { + const dcsRe = /^(.+Элемент(\\d+))(Использование|Значение|ВидСравнения)$/; + const dcsGroups = {}; + const dcsNames = new Set(); + for (const f of result.fields) { + const m = f.name.match(dcsRe); + if (!m) continue; + if (!dcsGroups[m[1]]) dcsGroups[m[1]] = { _n: parseInt(m[2]) }; + dcsGroups[m[1]][m[3]] = f; + dcsNames.add(f.name); + } + const dcsEntries = Object.entries(dcsGroups).sort((a, b) => a[1]._n - b[1]._n); + if (dcsEntries.length) { + result.reportSettings = dcsEntries.map(([, g]) => { + const cb = g['Использование']; + const val = g['Значение']; + if (!cb && !val) return null; + // No checkbox present (class="staticText" instead of .checkbox) — setting is always enabled + const label = (val?.label || cb?.label || val?.name || cb?.name || '').replace(/:$/, '').trim(); + const s = { name: label, enabled: cb ? !!cb.value : true }; + if (val) { + s.value = val.value || ''; + if (val.actions && val.actions.length) s.actions = val.actions; + } + return s; + }).filter(Boolean); + result.fields = result.fields.filter(f => !dcsNames.has(f.name)); + if (!result.fields.length) delete result.fields; + } + } + + return result; +}`; diff --git a/.claude/skills/web-test/scripts/dom/errors.mjs b/.claude/skills/web-test/scripts/dom/errors.mjs new file mode 100644 index 00000000..3d5e6f39 --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/errors.mjs @@ -0,0 +1,127 @@ +// web-test dom/errors v1.0 — error/diagnostic detection (balloon, messages, modal, stateWindow) +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +/** + * Check for validation errors / diagnostics after an action. + * Detects three patterns: + * 1. Inline balloon tooltip (div.balloon with .balloonMessage) + * 2. Messages panel (div.messages with msg0, msg1... grid rows) + * 3. Modal error dialog (high-numbered form with pressDefault + static texts) + * Returns { balloon, messages[], modal } or null if no errors. + */ +export function checkErrorsScript() { + return `(() => { + const result = {}; + + // 1. Inline balloon tooltip + const balloon = document.querySelector('.balloon'); + if (balloon && balloon.offsetWidth > 0) { + const msg = balloon.querySelector('.balloonMessage'); + const title = balloon.querySelector('.balloonTitle'); + if (msg) { + result.balloon = { + title: title?.innerText?.trim() || 'Ошибка', + message: msg.innerText?.trim() || '' + }; + // Count navigation arrows to indicate total errors + const fwd = balloon.querySelector('.balloonJumpFwd'); + const back = balloon.querySelector('.balloonJumpBack'); + const fwdDisabled = fwd?.classList.contains('disabled'); + const backDisabled = back?.classList.contains('disabled'); + if (fwd && !fwdDisabled) result.balloon.hasNext = true; + if (back && !backDisabled) result.balloon.hasPrev = true; + } + } + + // 2. Messages panel (div.messages — pick visible one, multiple may exist across tabs) + const msgPanels = [...document.querySelectorAll('.messages')].filter(el => el.offsetWidth > 0); + for (const msgPanel of msgPanels) { + const msgs = []; + msgPanel.querySelectorAll('[id^="msg"]').forEach(line => { + if (line.offsetWidth === 0) return; + const textEl = line.querySelector('.gridBoxText'); + const text = (textEl || line).innerText?.trim(); + if (text) msgs.push(text); + }); + if (msgs.length > 0) { result.messages = msgs; break; } + } + + // 3+4. Modal dialogs: confirmation (multiple buttons) or error (single pressDefault) + // Uses form container ancestry to group buttons — pressButton elements often lack form-prefixed IDs + // Note: 1C shows some modals WITHOUT #modalSurface (e.g. "Не удалось записать" uses ps*win floating window) + // so we always scan for small forms with button patterns, regardless of modalSurface state + const formButtons = {}; + [...document.querySelectorAll('a.press.pressButton')].forEach(btn => { + if (btn.offsetWidth === 0) return; + const container = btn.closest('[id$="_container"]'); + const m = container?.id?.match(/^form(\\d+)_/); + if (!m) return; + const fn = m[1]; + if (!formButtons[fn]) formButtons[fn] = []; + formButtons[fn].push(btn); + }); + + for (const [fn, buttons] of Object.entries(formButtons)) { + const p = 'form' + fn + '_'; + const elCount = document.querySelectorAll('[id^="' + p + '"]').length; + if (elCount > 100) continue; // Skip large content forms + if (buttons.length > 1) { + // Confirmation dialog (multiple buttons: Да/Нет, OK/Отмена, etc.) + // Must have a Message element — real 1C confirmations always have form{N}_Message. + // Without it, this is just a regular form with multiple buttons (e.g. EPF form). + const msgEl = document.getElementById(p + 'Message'); + if (!msgEl || msgEl.offsetWidth === 0) continue; + const message = msgEl.innerText?.trim() || ''; + const btnNames = buttons.map(el => { + const b = { name: el.innerText?.trim() || '' }; + if (el.classList.contains('pressDefault')) b.default = true; + return b; + }).filter(b => b.name); + result.confirmation = { message, buttons: btnNames.map(b => b.name), formNum: parseInt(fn) }; + break; + } + } + + // Single-button modal: error dialog with pressDefault + staticText + // Skip forms with input fields — those are data entry forms (e.g. register record), + // not error dialogs. Real error modals only have staticText + buttons. + if (!result.confirmation) { + for (const [fn, buttons] of Object.entries(formButtons)) { + const p = 'form' + fn + '_'; + const elCount = document.querySelectorAll('[id^="' + p + '"]').length; + if (elCount > 100) continue; + if (buttons.length !== 1 || !buttons[0].classList.contains('pressDefault')) continue; + const hasInputs = document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').length > 0; + if (hasInputs) continue; + const texts = [...document.querySelectorAll('[id^="' + p + '"].staticText')] + .filter(el => el.offsetWidth > 0) + .map(el => el.innerText?.trim()) + .filter(Boolean); + if (texts.length > 0) { + result.modal = { message: texts.join(' '), formNum: parseInt(fn), button: buttons[0].innerText?.trim() || '' }; + // Check if OpenReport link is available (platform exceptions have visible link text) + const reportLink = document.getElementById(p + 'OpenReport#text'); + if (reportLink && reportLink.offsetWidth > 2 && reportLink.textContent.trim()) { + result.modal.hasReport = true; + } + // Grab AdditionalInfo/ServerText if filled (may contain extra error details) + const addInfo = document.getElementById(p + 'AdditionalInfo'); + if (addInfo && addInfo.textContent && addInfo.textContent.trim()) result.modal.additionalInfo = addInfo.textContent.trim(); + const srvText = document.getElementById(p + 'ServerText'); + if (srvText && srvText.textContent && srvText.textContent.trim()) result.modal.serverText = srvText.textContent.trim(); + break; + } + } + } + + // 5. SpreadsheetDocument state window (info bar inside moxelContainer) + // Shows messages like "Не установлено значение параметра X" or "Отчет не сформирован" + const stateWins = [...document.querySelectorAll('.stateWindowSupportSurface')].filter(el => el.offsetWidth > 0); + if (stateWins.length) { + const texts = stateWins.map(el => el.innerText?.trim()).filter(Boolean); + if (texts.length) result.stateText = texts; + } + + return (result.balloon || result.messages || result.modal || result.confirmation || result.stateText) ? result : null; + })()`; +} diff --git a/.claude/skills/web-test/scripts/dom/form-state.mjs b/.claude/skills/web-test/scripts/dom/form-state.mjs new file mode 100644 index 00000000..346471e0 --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/form-state.mjs @@ -0,0 +1,34 @@ +// web-test dom/form-state v1.0 — combined detectForm + readForm + open tabs +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { DETECT_FORM_FN, DETECT_FORMS_FN, READ_FORM_FN } from './_shared.mjs'; + +/** + * Combined: detect form + read form + read open tabs. + * Single evaluate call instead of 3. Used by browser.getFormState(). + */ +export function getFormStateScript() { + return `(() => { + ${DETECT_FORM_FN} + ${DETECT_FORMS_FN} + ${READ_FORM_FN} + const formNum = detectForm(); + const meta = detectForms(); + if (formNum === null) return { form: null, formCount: 0, message: 'No form detected' }; + const p = 'form' + formNum + '_'; + const formData = readForm(p); + // Open tabs bar (present only when tab panel is enabled in 1C settings) + const openTabs = []; + document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => { + const text = el.innerText?.trim(); + if (!text) return; + const entry = { name: text }; + if (el.classList.contains('select')) entry.active = true; + openTabs.push(entry); + }); + const activeTab = openTabs.find(t => t.active)?.name || null; + const result = { form: formNum, activeTab, openForms: meta.allForms, formCount: meta.formCount, ...formData }; + if (meta.modal) result.modal = true; + if (openTabs.length) result.openTabs = openTabs; + return result; + })()`; +} diff --git a/.claude/skills/web-test/scripts/dom/forms.mjs b/.claude/skills/web-test/scripts/dom/forms.mjs new file mode 100644 index 00000000..fcbe9af0 --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/forms.mjs @@ -0,0 +1,398 @@ +// web-test dom/forms v1.0 — 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 } from './_shared.mjs'; + +/** + * Detect the active form number. + * Picks the form with the most visible elements (excluding form0 = home page). + */ +export function detectFormScript() { + return `(() => { + ${DETECT_FORM_FN} + return detectForm(); + })()`; +} + +/** + * Read full form state for a given form number. + * Uses shared READ_FORM_FN. + */ +export function readFormScript(formNum) { + const p = `form${formNum}_`; + return `(() => { + ${READ_FORM_FN} + return readForm(${JSON.stringify(p)}); + })()`; +} + +/** + * Find a clickable element on the current form (button, hyperlink, tab, frame button). + * Returns { id, kind, name } for Playwright page.click(), or { error, available }. + * Supports synonym matching: visible text AND internal name from DOM ID. + * Fuzzy order: exact name -> exact label -> includes name -> includes label. + */ +export function findClickTargetScript(formNum, text, { tableName, gridSelector } = {}) { + const p = `form${formNum}_`; + return `(() => { + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); + const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))}; + const p = ${JSON.stringify(p)}; + const tableName = ${JSON.stringify(tableName || '')}; + const gridSelector = ${JSON.stringify(gridSelector || '')}; + const items = []; + + // Buttons (a.press) + [...document.querySelectorAll('a.press[id^="' + p + '"]')].filter(el => el.offsetWidth > 0).forEach(el => { + const idName = el.id.replace(p, ''); + if (/_(?:DLB|CLR|OB|CB)$/.test(idName)) return; + const span = el.querySelector('.submenuText') || el.querySelector('span'); + const text = norm(span?.textContent) || norm(el.innerText); + if (!text && !el.classList.contains('pressCommand')) return; + const isSubmenu = /^(?:Подменю|allActions)/i.test(idName); + const item = { id: el.id, name: text || idName, label: idName, kind: isSubmenu ? 'submenu' : 'button' }; + // Icon-only buttons: use tooltip for fuzzy match (1C puts title on parent .framePress) + if (!text) { const tip = norm(el.title || el.parentElement?.title || ''); if (tip) item.tooltip = tip; } + items.push(item); + }); + + // Hyperlinks (staticTextHyper) + [...document.querySelectorAll('[id^="' + p + '"].staticTextHyper')].filter(el => el.offsetWidth > 0).forEach(el => { + const idName = el.id.replace(p, ''); + const text = norm(el.innerText); + items.push({ id: el.id, name: text, label: idName, kind: 'hyperlink' }); + }); + + // Frame buttons + [...document.querySelectorAll('[id^="' + p + '"] .frameButton, [id^="' + p + '"].frameButton')].filter(el => el.offsetWidth > 0).forEach(el => { + const text = norm(el.innerText); + const idName = el.id.replace(p, ''); + if (!text && !idName) return; + items.push({ id: el.id, name: text || idName, label: text ? '' : idName, kind: 'frameButton' }); + }); + + // Tumbler items (toggle switch segments) + [...document.querySelectorAll('[id^="' + p + '"].tumblerItem')].filter(el => el.offsetWidth > 0).forEach(el => { + const idName = el.id.replace(p, ''); + const text = norm(el.innerText); + items.push({ id: el.id, name: text || idName, label: idName, kind: 'tumbler' }); + }); + + // Checkboxes (div.checkbox) — match by label or internal name + [...document.querySelectorAll('[id^="' + p + '"].checkbox')].filter(el => el.offsetWidth > 0).forEach(el => { + const idName = el.id.replace(p, ''); + const titleEl = document.getElementById(p + idName + '#title_text'); + const label = norm(titleEl?.innerText || '').replace(/:/g, '').trim(); + items.push({ id: el.id, name: label || idName, label: idName, kind: 'checkbox' }); + }); + + // Tabs (scoped to form) + [...document.querySelectorAll('[data-content]')].filter(el => { + if (el.offsetWidth === 0) return false; + let node = el.parentElement; + while (node) { + if (node.id && node.id.startsWith(p)) return true; + node = node.parentElement; + } + return false; + }).forEach(el => { + const r = el.getBoundingClientRect(); + items.push({ id: el.id, name: el.dataset.content, label: '', kind: 'tab', + x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); + }); + + // 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); + if (gridEl) { + // Find parent container that has id with formPrefix and contains the grid + let container = gridEl.parentElement; + while (container && container !== document.body) { + if (container.id && container.id.startsWith(p)) break; + container = container.parentElement; + } + // Filter items to those inside the container + const containerItems = container && container !== document.body + ? items.filter(i => { const el = document.getElementById(i.id); return el && container.contains(el); }) + : []; + // Try fuzzy match within container first + let cf = containerItems.find(i => i.name.toLowerCase() === target); + if (!cf) cf = containerItems.find(i => i.label && i.label.toLowerCase() === target); + if (!cf && target.length >= 4) cf = containerItems.find(i => i.name.toLowerCase().includes(target)); + if (!cf && target.length >= 4) cf = containerItems.find(i => i.label && i.label.toLowerCase().includes(target)); + if (cf) { const res = { id: cf.id, kind: cf.kind, name: cf.name }; if (cf.x != null) { res.x = cf.x; res.y = cf.y; } return res; } + // Fallback: filter by gridName id-prefix (e.g. ИсходящиеКоманднаяПанель_Добавить) + const gridName = gridEl.id ? gridEl.id.replace(p, '') : ''; + if (gridName) { + const prefixItems = items.filter(i => i.label && i.label.includes(gridName)); + let pf = prefixItems.find(i => i.name.toLowerCase() === target); + if (!pf && target.length >= 4) pf = prefixItems.find(i => i.label && i.label.toLowerCase().includes(target)); + if (!pf && target.length >= 4) pf = prefixItems.find(i => i.name.toLowerCase().includes(target)); + if (pf) { const res = { id: pf.id, kind: pf.kind, name: pf.name }; if (pf.x != null) { res.x = pf.x; res.y = pf.y; } return res; } + } + } + // Fall through to unscoped search + } + + // Fuzzy match: exact name -> exact label -> exact tooltip -> startsWith name -> startsWith label -> includes name -> includes label -> includes tooltip + // Skip includes() for short strings (< 4 chars) to avoid false positives + // e.g. "Да" matching "КомандаУстановитьВсе" + let found = items.find(i => i.name.toLowerCase() === target); + if (!found) found = items.find(i => i.label && i.label.toLowerCase() === target); + if (!found) found = items.find(i => i.tooltip && i.tooltip.toLowerCase() === target); + if (!found) found = items.find(i => i.name.toLowerCase().startsWith(target)); + if (!found) found = items.find(i => i.label && i.label.toLowerCase().startsWith(target)); + if (!found && target.length >= 4) found = items.find(i => i.name.toLowerCase().includes(target)); + if (!found && target.length >= 4) found = items.find(i => i.label && i.label.toLowerCase().includes(target)); + if (!found && target.length >= 4) found = items.find(i => i.tooltip && i.tooltip.toLowerCase().includes(target)); + + if (found) { + const res = { id: found.id, kind: found.kind, name: found.name }; + if (found.x != null) { res.x = found.x; res.y = found.y; } + return res; + } + + // Grid rows — fallback: search in table rows (for hierarchical/tree navigation) + // Search ALL visible grids (or specific grid when table parameter is set) + let grids; + if (gridSelector) { + const g = document.querySelector(gridSelector); + grids = g ? [g] : []; + } else { + grids = [...document.querySelectorAll('[id^="' + p + '"].grid')].filter(g => g.offsetWidth > 0); + } + for (const grid of grids) { + const body = grid.querySelector('.gridBody'); + if (!body) continue; + const lines = [...body.querySelectorAll('.gridLine')]; + for (const line of lines) { + const textBoxes = [...line.querySelectorAll('.gridBoxText')].filter(b => b.offsetWidth > 0); + const rowTexts = textBoxes.map(b => norm(b.innerText) || '').filter(Boolean); + const firstCell = rowTexts[0]?.toLowerCase() || ''; + const rowText = rowTexts.join(' ').toLowerCase(); + if (firstCell === target || rowText === target || (target.length >= 4 && (firstCell.includes(target) || rowText.includes(target)))) { + const imgBox = line.querySelector('.gridBoxImg'); + const isGroup = imgBox?.querySelector('.gridListH') !== null; + const isParent = imgBox?.querySelector('.gridListV') !== null; + const isTreeNode = line.querySelector('.gridBoxTree') !== null; + const hasChildren = line.querySelector('[tree="true"]') !== null; + let kind; + if (isGroup) kind = 'gridGroup'; + else if (isParent) kind = 'gridParent'; + else if (isTreeNode && hasChildren) kind = 'gridTreeNode'; + else kind = 'gridRow'; + const r = line.getBoundingClientRect(); + return { id: '', kind, name: rowTexts[0] || '', gridId: grid.id, + x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + } + } + } + + return { error: 'not_found', available: items.map(i => i.tooltip ? i.name + ' [' + i.tooltip + ']' : i.name).filter(Boolean) }; + })()`; +} + +/** + * Find a field's action button (DLB, OB, CLR, CB) by fuzzy field name. + * Returns { fieldName, buttonId, buttonType } or { error, available }. + */ +export function findFieldButtonScript(formNum, fieldName, buttonSuffix = 'DLB') { + const p = `form${formNum}_`; + return `(() => { + const p = ${JSON.stringify(p)}; + const target = ${JSON.stringify(fieldName.toLowerCase().replace(/ё/g, 'е'))}; + const suffix = ${JSON.stringify(buttonSuffix)}; + const allFields = []; + document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, '').replace(/_i\\d+$/, ''); + const titleEl = document.getElementById(p + name + '#title_text') + || document.getElementById(p + name + '#title_div'); + const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); + allFields.push({ name, label }); + }); + // Also collect checkboxes for DCS pair matching + const allCheckboxes = []; + document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, ''); + const titleEl = document.getElementById(p + name + '#title_text'); + const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); + allCheckboxes.push({ inputId: el.id, name, label }); + }); + // Build DCS pairs: checkbox label → paired value field + const dcsPairs = {}; + for (const f of [...allFields, ...allCheckboxes]) { + const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/); + if (!m) continue; + if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {}; + dcsPairs[m[1]][m[2]] = f; + } + let found = allFields.find(f => f.name.toLowerCase() === target); + if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target); + if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target)); + if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target)); + // DCS pair: match checkbox or value label → resolve to paired value field + let dcsCheckbox = null; + if (!found) { + for (const pair of Object.values(dcsPairs)) { + const cb = pair['Использование']; + const val = pair['Значение']; + if (!cb || !val) continue; + const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase(); + if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) { + found = val; + dcsCheckbox = cb; + break; + } + } + } + if (!found) { + return { error: 'field_not_found', available: allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name) }; + } + const btnId = p + found.name + '_' + suffix; + const btn = document.getElementById(btnId); + if (!btn || btn.offsetWidth === 0) { + return { error: 'button_not_found', fieldName: found.name, message: suffix + ' button not visible for field ' + found.name }; + } + const result = { fieldName: found.name, buttonId: btnId, buttonType: suffix }; + if (dcsCheckbox) result.dcsCheckbox = { inputId: dcsCheckbox.inputId }; + return result; + })()`; +} + +/** + * Resolve field names to element IDs for Playwright page.fill(). + * Returns [{ field, inputId, name, label }] or [{ field, error, available }]. + * Supports synonym matching: internal name AND visible label. + * Fuzzy order: exact name -> exact label -> includes name -> includes label. + */ +export function resolveFieldsScript(formNum, fields) { + const p = `form${formNum}_`; + return `(() => { + const p = ${JSON.stringify(p)}; + const fieldNames = ${JSON.stringify(Object.keys(fields))}; + const results = []; + + // Build field map with name + label for synonym matching + const allFields = []; + document.querySelectorAll('input.editInput[id^="' + p + '"], textarea[id^="' + p + '"]').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, '').replace(/_i\\d+$/, ''); + const titleEl = document.getElementById(p + name + '#title_text') + || document.getElementById(p + name + '#title_div'); + const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); + const last = { inputId: el.id, name, label }; + if (document.getElementById(p + name + '_DLB')?.offsetWidth > 0) last.hasSelect = true; + const cbEl = document.getElementById(p + name + '_CB'); + if (cbEl?.offsetWidth > 0) { + last.hasPick = true; + if (cbEl.classList.contains('iCalendB')) last.isDate = true; + } + allFields.push(last); + }); + // Checkboxes + document.querySelectorAll('[id^="' + p + '"].checkbox').forEach(el => { + if (el.offsetWidth === 0) return; + const name = el.id.replace(p, ''); + const titleEl = document.getElementById(p + name + '#title_text'); + const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); + const checked = el.classList.contains('checked') || el.classList.contains('checkboxOn') || el.classList.contains('select'); + allFields.push({ inputId: el.id, name, label, isCheckbox: true, checked }); + }); + // Radio button groups — base element = option 0, others are #N#radio + const radioSeen = new Set(); + document.querySelectorAll('[id^="' + p + '"].radio').forEach(el => { + if (el.offsetWidth === 0) return; + const id = el.id.replace(p, ''); + // Skip if already processed or if it's a sub-element (#N#radio) + const m = id.match(/^(.+?)#(\\d+)#radio$/); + const groupName = m ? m[1] : (!id.includes('#') ? id : null); + if (!groupName || radioSeen.has(groupName)) return; + radioSeen.add(groupName); + const titleEl = document.getElementById(p + groupName + '#title_text'); + const label = (titleEl?.innerText?.trim() || '').replace(/\\n/g, ' ').replace(/:$/, ''); + // Collect options: option 0 is the base element, options 1+ have #N#radio + const options = []; + // Option 0: base element + const base = document.getElementById(p + groupName); + if (base && base.classList.contains('radio') && base.offsetWidth > 0) { + const textEl = document.getElementById(p + groupName + '#0#radio_text'); + options.push({ index: 0, label: textEl?.innerText?.trim() || '', selected: base.classList.contains('select') }); + } + // Options 1+ + for (let i = 1; i < 20; i++) { + const opt = document.getElementById(p + groupName + '#' + i + '#radio'); + if (!opt || opt.offsetWidth === 0) break; + const textEl = document.getElementById(p + groupName + '#' + i + '#radio_text'); + options.push({ index: i, label: textEl?.innerText?.trim() || '', selected: opt.classList.contains('select') }); + } + allFields.push({ inputId: p + groupName, name: groupName, label, isRadio: true, options }); + }); + + // Build DCS pairs: checkbox label → paired value field + const dcsPairs = {}; + for (const f of allFields) { + const m = f.name.match(/^(.+Элемент\\d+)(Использование|Значение)$/); + if (!m) continue; + if (!dcsPairs[m[1]]) dcsPairs[m[1]] = {}; + dcsPairs[m[1]][m[2]] = f; + } + + for (const fieldName of fieldNames) { + const target = fieldName.toLowerCase().replace(/\\n/g, ' ').replace(/:$/, ''); + // Fuzzy: exact name -> exact label -> includes name -> includes label + let found = allFields.find(f => f.name.toLowerCase() === target); + if (!found) found = allFields.find(f => f.label && f.label.toLowerCase() === target); + if (!found) found = allFields.find(f => f.name.toLowerCase().includes(target)); + if (!found) found = allFields.find(f => f.label && f.label.toLowerCase().includes(target)); + // DCS pair: match checkbox or value label → resolve to paired value field + if (!found) { + for (const pair of Object.values(dcsPairs)) { + const cb = pair['Использование']; + const val = pair['Значение']; + if (!cb || !val) continue; + const pairLabel = ((val.label || cb.label || '').replace(/:$/, '')).toLowerCase(); + if (pairLabel && (pairLabel === target || pairLabel.includes(target) || target.includes(pairLabel))) { + found = val; + found._dcsCheckbox = cb; + break; + } + } + } + + if (found) { + const entry = { field: fieldName, inputId: found.inputId, name: found.name, label: found.label }; + if (found.isCheckbox) { entry.isCheckbox = true; entry.checked = found.checked; } + if (found.isRadio) { entry.isRadio = true; entry.options = found.options; } + if (found.hasSelect) entry.hasSelect = true; + if (found.hasPick) entry.hasPick = true; + if (found.isDate) entry.isDate = true; + if (found._dcsCheckbox) { + entry.dcsCheckbox = { inputId: found._dcsCheckbox.inputId, checked: found._dcsCheckbox.checked }; + delete found._dcsCheckbox; + } + results.push(entry); + } else { + const available = allFields.map(f => f.label ? f.name + ' (' + f.label + ')' : f.name); + results.push({ field: fieldName, error: 'not_found', available }); + } + } + return results; + })()`; +} diff --git a/.claude/skills/web-test/scripts/dom/grid.mjs b/.claude/skills/web-test/scripts/dom/grid.mjs new file mode 100644 index 00000000..8c205ebf --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/grid.mjs @@ -0,0 +1,249 @@ +// web-test dom/grid v1.0 — grid resolution + table reading +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +/** + * Resolve a specific grid by semantic name (table parameter). + * Cascade: exact gridName match → gridName contains → column contains. + * Returns { gridSelector, gridId, gridName, gridIndex, columns } or { error, available }. + */ +export function resolveGridScript(formNum, tableName) { + const p = `form${formNum}_`; + return `(() => { + const p = ${JSON.stringify(p)}; + const target = ${JSON.stringify(tableName.toLowerCase().replace(/ё/g, 'е'))}; + const norm = s => (s || '').replace(/ё/gi, 'е'); + const allGrids = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .filter(g => g.offsetWidth > 0 && g.offsetHeight > 0); + if (!allGrids.length) return { error: 'no_grids', message: 'No grids found on form' }; + const infos = allGrids.map((g, idx) => { + const gridId = g.id || ''; + const gridName = gridId.replace(p, ''); + const head = g.querySelector('.gridHead'); + const columns = []; + if (head) { + const headLine = head.querySelector('.gridLine') || head; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const textEl = box.querySelector('.gridBoxText'); + const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; + if (text) columns.push(text); + }); + } + // Visual label from group title element + const titleEl = document.getElementById(p + gridName + '#title_div') + || document.getElementById(p + 'Группа' + gridName + '#title_div'); + const label = titleEl ? (titleEl.innerText?.trim().replace(/:\s*$/, '').replace(/ /g, ' ') || '') : ''; + return { idx, gridId, gridName, label, columns, el: g }; + }); + // 1. Exact gridName match (case-insensitive) + let found = infos.find(i => norm(i.gridName).toLowerCase() === target); + // 2. Exact label match + if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase() === target); + // 3. gridName contains target + if (!found) found = infos.find(i => norm(i.gridName).toLowerCase().includes(target)); + // 4. Label contains target + if (!found) found = infos.find(i => i.label && norm(i.label).toLowerCase().includes(target)); + // 5. Any column contains target + if (!found) found = infos.find(i => i.columns.some(c => norm(c).toLowerCase().includes(target))); + if (found) { + return { + gridSelector: found.gridId ? '#' + CSS.escape(found.gridId) : null, + gridId: found.gridId, + gridName: found.gridName, + gridIndex: found.idx, + columns: found.columns + }; + } + return { + error: 'not_found', + message: 'Table "' + ${JSON.stringify(tableName)} + '" not found', + available: infos.map(i => ({ name: i.gridName, ...(i.label ? { label: i.label } : {}), columns: i.columns })) + }; + })()`; +} + +/** + * Read table/grid data with pagination. + * Parses grid.innerText — \n separates rows, \t separates cells. + * First row = column headers. + * Returns { name, columns[], rows[{col:val}], total, offset, shown }. + */ +export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelector } = {}) { + const p = `form${formNum}_`; + return `(() => { + const p = ${JSON.stringify(p)}; + const grid = ${gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `[...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0 && g.offsetHeight > 0)`}; + if (!grid) return { error: 'no_table', message: 'No table found on form ${formNum}' }; + const name = grid.id ? grid.id.replace(p, '') : ''; + + // DOM-based parsing: gridHead → columns, gridBody → gridLine rows → gridBox cells + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + if (!head || !body) { + // Fallback: innerText-based (for non-standard grids) + const gText = grid.innerText?.trim() || ''; + const lines = gText.split('\\n').filter(Boolean); + return { name, columns: [], rows: [], total: lines.length, offset: 0, shown: 0, + hint: 'Grid has no gridHead/gridBody structure' }; + } + + // Extract column headers with X-coordinates for alignment + const columns = []; + const headLine = head.querySelector('.gridLine') || head; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const textEl = box.querySelector('.gridBoxText'); + const text = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; + if (!text) { + // Unnamed column — check if data cells contain checkboxes + const firstLine = body?.querySelector('.gridLine'); + if (firstLine) { + const visibleHeaders = [...headLine.children].filter(c => c.offsetWidth > 0); + const idx = visibleHeaders.indexOf(box); + const cells = [...firstLine.children].filter(c => c.offsetWidth > 0); + if (cells[idx]?.querySelector('.checkbox')) { + const r = box.getBoundingClientRect(); + columns.push({ text: '(checkbox)', x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height }); + } + } + return; + } + const r = box.getBoundingClientRect(); + columns.push({ text, x: r.x, w: r.width, right: r.x + r.width, y: r.y, h: r.height }); + }); + + // Multi-row grid support: detect stacked/merged headers. + // Group headers by X-range. For each group, count data sub-rows from first line. + // - Stacked headers (2+ headers at same X) with multiple data rows → match by Y-order + // - Single merged header with multiple data rows → expand to numbered columns (e.g. "Субконто Дт 1") + const xGroups = new Map(); + columns.forEach(c => { + const key = Math.round(c.x) + ':' + Math.round(c.right); + if (!xGroups.has(key)) xGroups.set(key, []); + xGroups.get(key).push(c); + }); + for (const [, hdrs] of xGroups) hdrs.sort((a, b) => a.y - b.y); + + const firstDataLine = body?.querySelector('.gridLine'); + const subRowMap = new Map(); + if (firstDataLine) { + [...firstDataLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const r = box.getBoundingClientRect(); + const cx = r.x + r.width / 2; + for (const [key, hdrs] of xGroups) { + const h0 = hdrs[0]; + if (cx >= h0.x && cx < h0.right) { + if (!subRowMap.has(key)) subRowMap.set(key, []); + subRowMap.get(key).push({ y: r.y }); + break; + } + } + }); + for (const [, subs] of subRowMap) subs.sort((a, b) => a.y - b.y); + } + + const multiRowGroups = new Map(); + for (const [key, hdrs] of xGroups) { + const subs = subRowMap.get(key); + if (!subs || subs.length <= 1) continue; + if (hdrs.length >= 2) { + multiRowGroups.set(key, hdrs); + } else if (hdrs.length === 1 && subs.length > 1) { + const base = hdrs[0]; + const baseIdx = columns.indexOf(base); + columns.splice(baseIdx, 1); + const expanded = []; + for (let si = 0; si < subs.length; si++) { + const numbered = { + text: base.text + ' ' + (si + 1), + x: base.x, w: base.w, right: base.right, + y: base.y + si, h: base.h / subs.length, _subIdx: si + }; + columns.splice(baseIdx + si, 0, numbered); + expanded.push(numbered); + } + multiRowGroups.set(key, expanded); + } + } + + function matchColumn(cellX, cellW, cellY) { + const cx = cellX + cellW / 2; + for (const [key, hdrs] of multiRowGroups) { + const h0 = hdrs[0]; + if (cx >= h0.x && cx < h0.right) { + const subs = subRowMap.get(key); + if (subs) { + const subIdx = subs.findIndex(s => Math.abs(s.y - cellY) < 5); + if (subIdx >= 0 && subIdx < hdrs.length) return hdrs[subIdx]; + } + let best = hdrs[0], bestDist = Infinity; + for (const h of hdrs) { + const dist = Math.abs(cellY - h.y); + if (dist < bestDist) { bestDist = dist; best = h; } + } + return best; + } + } + return columns.find(c => cx >= c.x && cx < c.right); + } + + // Extract data rows from gridBody + const allLines = body.querySelectorAll('.gridLine'); + const total = allLines.length; + const rows = []; + const end = Math.min(${offset} + ${maxRows}, total); + for (let i = ${offset}; i < end; i++) { + const line = allLines[i]; + if (!line) break; + const row = {}; + columns.forEach(c => { row[c.text] = ''; }); + [...line.children].forEach(box => { + if (box.offsetWidth === 0) return; + const textEl = box.querySelector('.gridBoxText'); + const chk = box.querySelector('.checkbox'); + let val; + if (chk) { + val = chk.classList.contains('select') ? 'true' : 'false'; + } else { + val = (textEl || box).innerText?.trim().replace(/\\n/g, ' ') || ''; + if (!val) return; + } + // Match cell to column by X+Y overlap (multi-row aware) + const r = box.getBoundingClientRect(); + const col = matchColumn(r.x, r.width, r.y); + if (col) { + row[col.text] = row[col.text] ? row[col.text] + ' / ' + val : val; + } + }); + // Detect row kind: group (gridListH), parent/up (gridListV), or element + const imgBox = line.querySelector('.gridBoxImg'); + if (imgBox) { + if (imgBox.querySelector('.gridListH')) row._kind = 'group'; + else if (imgBox.querySelector('.gridListV')) row._kind = 'parent'; + } + // Tree mode: detect expand/collapse state and indent level + const treeBox = line.querySelector('.gridBoxTree'); + if (treeBox) { + const treeIcon = imgBox?.querySelector('[tree="true"]'); + if (treeIcon) { + const bg = treeIcon.style.backgroundImage || ''; + row._tree = bg.includes('gx=0') ? 'expanded' : 'collapsed'; + } + row._level = imgBox ? imgBox.querySelectorAll('.dIB').length - 1 : 0; + } + // Selection state: selRow = selected row in grid + if (line.classList.contains('selRow') || line.classList.contains('select')) row._selected = true; + rows.push(row); + } + const isTree = !!body.querySelector('.gridBoxTree'); + const hasGroups = rows.some(r => r._kind === 'group'); + const result = { name, columns: columns.map(c => c.text), rows, total, offset: ${offset}, shown: rows.length }; + if (isTree) result.viewMode = 'tree'; + if (hasGroups) result.hierarchical = true; + return result; + })()`; +} diff --git a/.claude/skills/web-test/scripts/dom/nav.mjs b/.claude/skills/web-test/scripts/dom/nav.mjs new file mode 100644 index 00000000..32e01642 --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/nav.mjs @@ -0,0 +1,93 @@ +// web-test dom/nav v1.0 — sections panel, tabs bar, function panel commands +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +/** Read sections panel (left sidebar). */ +export function readSectionsScript() { + return `(() => { + const sections = []; + document.querySelectorAll('[id^="themesCell_theme_"]').forEach(el => { + const entry = { name: el.innerText?.trim() || '' }; + if (el.classList.contains('select')) entry.active = true; + sections.push(entry); + }); + return sections; + })()`; +} + +/** Read open tabs bar. */ +export function readTabsScript() { + return `(() => { + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); + const tabs = []; + document.querySelectorAll('[id^="openedCell_cmd_"]').forEach(el => { + const text = norm(el.innerText); + if (!text) return; + const entry = { name: text }; + if (el.classList.contains('select')) entry.active = true; + tabs.push(entry); + }); + return tabs; + })()`; +} + +/** Switch to a tab by name (fuzzy match). Returns matched name or { error, available }. */ +export function switchTabScript(name) { + return `(() => { + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); + const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))}; + const tabs = [...document.querySelectorAll('[id^="openedCell_cmd_"]')].filter(el => el.offsetWidth > 0 && norm(el.innerText)); + let best = tabs.find(el => norm(el.innerText).toLowerCase() === target); + if (!best) best = tabs.find(el => norm(el.innerText).toLowerCase().includes(target)); + if (best) { best.click(); return norm(best.innerText); } + return { error: 'not_found', available: tabs.map(el => norm(el.innerText)) }; + })()`; +} + +/** Read commands in the function panel (current section). */ +export function readCommandsScript() { + return `(() => { + const groups = []; + const container = document.querySelector('#funcPanel_container table tr'); + if (!container) return groups; + for (const td of container.children) { + const commands = []; + td.querySelectorAll('[id^="cmd_"][id$="_txt"]').forEach(el => { + if (el.offsetWidth === 0) return; + commands.push(el.innerText?.trim() || ''); + }); + if (commands.length > 0) groups.push(commands); + } + return groups; + })()`; +} + +/** + * Navigate to a section by name (fuzzy match). + * Returns the matched section name, or { error, available }. + */ +export function navigateSectionScript(name) { + return `(() => { + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ').replace(/[\\r\\n]+/g, ' ').replace(/ +/g, ' ') || '').replace(/ё/gi, 'е'); + const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е').replace(/[\r\n]+/g, ' ').replace(/ +/g, ' '))}; + const els = [...document.querySelectorAll('[id^="themesCell_theme_"]')]; + let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target); + if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target)); + if (bestEl) { bestEl.click(); return norm(bestEl.innerText); } + return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) }; + })()`; +} + +/** + * Open a command from function panel by name (fuzzy match). + */ +export function openCommandScript(name) { + return `(() => { + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); + const target = ${JSON.stringify(name.toLowerCase().replace(/ё/g, 'е'))}; + const els = [...document.querySelectorAll('[id^="cmd_"][id$="_txt"]')].filter(el => el.offsetWidth > 0); + let bestEl = els.find(el => norm(el.innerText).toLowerCase() === target); + if (!bestEl) bestEl = els.find(el => norm(el.innerText).toLowerCase().includes(target)); + if (bestEl) { bestEl.click(); return norm(bestEl.innerText); } + return { error: 'not_found', available: els.map(el => norm(el.innerText)).filter(Boolean) }; + })()`; +} diff --git a/.claude/skills/web-test/scripts/dom/submenu.mjs b/.claude/skills/web-test/scripts/dom/submenu.mjs new file mode 100644 index 00000000..337c3b27 --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/submenu.mjs @@ -0,0 +1,149 @@ +// web-test dom/submenu v1.0 — popup/submenu reading and clicking +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +/** + * Read open popup/submenu items. + * Looks for absolutely positioned visible popup containers with a.press items inside. + * Returns [{ id, name }] or { error }. + */ +export function readSubmenuScript() { + return `(() => { + const items = []; + const norm = s => (s?.trim().replace(/\\u00a0/g, ' ') || '').replace(/ё/gi, 'е'); + + // 1. DLB dropdown (#editDropDown with .eddText items) + const edd = document.getElementById('editDropDown'); + if (edd && edd.offsetWidth > 0 && edd.offsetHeight > 0) { + edd.querySelectorAll('.eddText').forEach(el => { + if (el.offsetWidth === 0) return; + const text = norm(el.innerText); + if (!text) return; + const r = el.getBoundingClientRect(); + items.push({ id: '', name: text, kind: 'dropdown', + x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); + }); + // Detect "Показать все" link in EDD footer + // Structure: div.eddBottom > div > span.hyperlink "Показать все" + let showAllEl = edd.querySelector('.eddBottom .hyperlink'); + if (!showAllEl || showAllEl.offsetWidth === 0) { + // Fallback: scan all visible elements for text match + const candidates = [...edd.querySelectorAll('a.press, a, span, div')] + .filter(el => el.offsetWidth > 0 && el.children.length === 0); + showAllEl = candidates.find(el => { + const t = norm(el.innerText).toLowerCase(); + return t === 'показать все' || t === 'show all'; + }); + } + if (showAllEl) { + const r = showAllEl.getBoundingClientRect(); + items.push({ id: showAllEl.id || '', name: norm(showAllEl.innerText), kind: 'showAll', + x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); + } + if (items.length > 0) return items; + } + + // 2. Cloud submenu (allActions / command panel menus — div.cloud with .submenuText items) + // Read ALL visible high-z clouds (main menu + nested submenus) + const clouds = [...document.querySelectorAll('.cloud')].filter(c => c.offsetWidth > 0 && c.offsetHeight > 0); + const seen = new Set(); + clouds.forEach(c => { + const z = parseInt(getComputedStyle(c).zIndex) || 0; + if (z <= 1000) return; + c.querySelectorAll('.submenuText').forEach(el => { + if (el.offsetWidth === 0) return; + const text = norm(el.innerText); + if (!text || seen.has(text)) return; + seen.add(text); + const block = el.closest('.submenuBlock'); + if (block && block.classList.contains('submenuBlockDisabled')) return; + const hasSub = block && /_sub$/.test(block.id); + const r = el.getBoundingClientRect(); + items.push({ id: block?.id || '', name: text, kind: hasSub ? 'submenuArrow' : 'submenu', + x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); + }); + }); + if (items.length > 0) return items; + + // 3. Submenu popups — find the topmost positioned container with non-form a.press items + const popups = [...document.querySelectorAll('div')].filter(c => { + const style = getComputedStyle(c); + return (style.position === 'absolute' || style.position === 'fixed') + && c.offsetWidth > 0 && c.offsetHeight > 0; + }).sort((a, b) => { + const za = parseInt(getComputedStyle(a).zIndex) || 0; + const zb = parseInt(getComputedStyle(b).zIndex) || 0; + return zb - za; + }); + for (const container of popups) { + // Only direct a.press children or those not nested in another positioned div + const menuItems = [...container.querySelectorAll('a.press')].filter(el => { + if (el.offsetWidth === 0) return false; + if (el.id && /^form\\d+_/.test(el.id)) return false; + // Skip if this a.press is inside a deeper positioned container + let parent = el.parentElement; + while (parent && parent !== container) { + const ps = getComputedStyle(parent).position; + if (ps === 'absolute' || ps === 'fixed') return false; + parent = parent.parentElement; + } + return true; + }); + if (menuItems.length < 2) continue; // Not a real menu + const seen = new Set(); + menuItems.forEach(el => { + const text = norm(el.innerText); + if (!text) return; + if (seen.has(text)) return; + seen.add(text); + const r = el.getBoundingClientRect(); + items.push({ id: el.id || '', name: text, kind: 'submenu', + x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); + }); + if (items.length > 0) break; // Found the popup menu + } + + if (items.length === 0) return { error: 'no_popup', message: 'No open popup/submenu found' }; + return items; + })()`; +} + +/** + * Click a popup/dropdown item by text match (evaluate-based for items without IDs). + * Returns true if clicked, false if not found. + */ +export function clickPopupItemScript(text) { + return `(() => { + const target = ${JSON.stringify(text.toLowerCase().replace(/ё/g, 'е'))}; + // 1. DLB dropdown (#editDropDown .eddText items) + const edd = document.getElementById('editDropDown'); + if (edd && edd.offsetWidth > 0) { + for (const el of edd.querySelectorAll('.eddText')) { + if (el.offsetWidth === 0) continue; + const t = el.innerText?.trim() || ''; + if (t.toLowerCase() === target || t.toLowerCase().includes(target)) { + el.click(); + return t; + } + } + } + + // 2. Submenu popups (a.press in absolutely positioned containers) + const containers = [...document.querySelectorAll('div')].filter(c => { + const style = getComputedStyle(c); + return (style.position === 'absolute' || style.position === 'fixed') + && c.offsetWidth > 0 && c.offsetHeight > 0; + }); + for (const container of containers) { + const items = [...container.querySelectorAll('a.press')] + .filter(el => el.offsetWidth > 0); + for (const el of items) { + const t = el.innerText?.trim() || ''; + if (t.toLowerCase() === target || t.toLowerCase().includes(target)) { + el.click(); + return t; + } + } + } + return null; + })()`; +} From 65ea06ab6e6af53c37e28f971d20976384b82bc6 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 18:08:15 +0300 Subject: [PATCH 27/47] =?UTF-8?q?refactor(web-test):=20run.mjs=20=D1=80?= =?UTF-8?q?=D0=B0=D1=81=D0=BF=D0=B8=D0=BB=D0=B5=D0=BD=20=D0=BF=D0=BE=20cli?= =?UTF-8?q?/=20(1258=20=E2=86=92=2065=20LOC=20entry)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Внутренности move в cli/: - util.mjs — out/die/json/readBody/readStdin/elapsed/elapsed2/slugify/formatDuration/xmlEscape/interpolate/printSteps/usage - session.mjs — SESSION_FILE, loadSession, cleanup - exec-context.mjs — buildContext, buildScopedContext, executeScript - server.mjs — handleRequest (HTTP сервер в процессе start) - commands/{start,run,exec,shot,stop,status,test}.mjs — по одной команде на файл - test-runner/assertions.mjs — createAssertions (ctx.assert API) - test-runner/severity.mjs — SEVERITY_RANK/LEVELS, buildSeverityIndex, resolveSeverity - test-runner/reporters.mjs — writeAllure, allureStep, syncAllureExtras, buildJUnit - test-runner/discover.mjs — discoverTests, resetState run.mjs остался публичным entry-point с CLI-парсингом и dispatcher'ом. Регресс tests/web-test/ зелёный (19/19, 9m 28s). --- .../web-test/scripts/cli/commands/exec.mjs | 36 + .../web-test/scripts/cli/commands/run.mjs | 22 + .../web-test/scripts/cli/commands/shot.mjs | 18 + .../web-test/scripts/cli/commands/start.mjs | 33 + .../web-test/scripts/cli/commands/status.mjs | 14 + .../web-test/scripts/cli/commands/stop.mjs | 17 + .../web-test/scripts/cli/commands/test.mjs | 433 ++++++ .../web-test/scripts/cli/exec-context.mjs | 148 ++ .../skills/web-test/scripts/cli/server.mjs | 37 + .../skills/web-test/scripts/cli/session.mjs | 20 + .../scripts/cli/test-runner/assertions.mjs | 64 + .../scripts/cli/test-runner/discover.mjs | 32 + .../scripts/cli/test-runner/reporters.mjs | 113 ++ .../scripts/cli/test-runner/severity.mjs | 66 + .claude/skills/web-test/scripts/cli/util.mjs | 111 ++ .claude/skills/web-test/scripts/run.mjs | 1323 +---------------- 16 files changed, 1229 insertions(+), 1258 deletions(-) create mode 100644 .claude/skills/web-test/scripts/cli/commands/exec.mjs create mode 100644 .claude/skills/web-test/scripts/cli/commands/run.mjs create mode 100644 .claude/skills/web-test/scripts/cli/commands/shot.mjs create mode 100644 .claude/skills/web-test/scripts/cli/commands/start.mjs create mode 100644 .claude/skills/web-test/scripts/cli/commands/status.mjs create mode 100644 .claude/skills/web-test/scripts/cli/commands/stop.mjs create mode 100644 .claude/skills/web-test/scripts/cli/commands/test.mjs create mode 100644 .claude/skills/web-test/scripts/cli/exec-context.mjs create mode 100644 .claude/skills/web-test/scripts/cli/server.mjs create mode 100644 .claude/skills/web-test/scripts/cli/session.mjs create mode 100644 .claude/skills/web-test/scripts/cli/test-runner/assertions.mjs create mode 100644 .claude/skills/web-test/scripts/cli/test-runner/discover.mjs create mode 100644 .claude/skills/web-test/scripts/cli/test-runner/reporters.mjs create mode 100644 .claude/skills/web-test/scripts/cli/test-runner/severity.mjs create mode 100644 .claude/skills/web-test/scripts/cli/util.mjs diff --git a/.claude/skills/web-test/scripts/cli/commands/exec.mjs b/.claude/skills/web-test/scripts/cli/commands/exec.mjs new file mode 100644 index 00000000..3c3186b6 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/exec.mjs @@ -0,0 +1,36 @@ +// web-test cli/commands/exec v1.0 — send script to running server +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import http from 'http'; +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import { out, die, readStdin } from '../util.mjs'; +import { loadSession } from '../session.mjs'; + +export async function cmdExec(fileOrDash, flags = {}) { + if (!fileOrDash) die('Usage: node src/run.mjs exec [--no-record]'); + + const code = fileOrDash === '-' + ? await readStdin() + : readFileSync(resolve(fileOrDash), 'utf-8'); + + const sess = loadSession(); + const headers = {}; + if (flags.noRecord) headers['x-no-record'] = '1'; + const timeoutMs = flags.execTimeoutMs ?? 30 * 60 * 1000; + const result = await new Promise((resolveP, reject) => { + const req = http.request({ + hostname: '127.0.0.1', port: sess.port, path: '/exec', + method: 'POST', timeout: timeoutMs, headers, + }, res => { + let data = ''; + res.on('data', chunk => data += chunk); + res.on('end', () => { try { resolveP(JSON.parse(data)); } catch { reject(new Error(data)); } }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(new Error(`Exec timeout (${Math.round(timeoutMs / 60000)} min)`)); }); + req.write(code); + req.end(); + }); + out(result); + if (!result.ok) process.exit(1); +} diff --git a/.claude/skills/web-test/scripts/cli/commands/run.mjs b/.claude/skills/web-test/scripts/cli/commands/run.mjs new file mode 100644 index 00000000..cb3631cd --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/run.mjs @@ -0,0 +1,22 @@ +// web-test cli/commands/run v1.0 — autonomous connect → exec → disconnect (no server) +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { readFileSync } from 'fs'; +import { resolve } from 'path'; +import * as browser from '../../browser.mjs'; +import { out, die, readStdin } from '../util.mjs'; +import { executeScript } from '../exec-context.mjs'; + +export async function cmdRun(url, fileOrDash) { + if (!url || !fileOrDash) die('Usage: node src/run.mjs run '); + + const code = fileOrDash === '-' + ? await readStdin() + : readFileSync(resolve(fileOrDash), 'utf-8'); + + await browser.connect(url); + const result = await executeScript(code); + await browser.disconnect(); + + out(result); + if (!result.ok) process.exit(1); +} diff --git a/.claude/skills/web-test/scripts/cli/commands/shot.mjs b/.claude/skills/web-test/scripts/cli/commands/shot.mjs new file mode 100644 index 00000000..71badfb2 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/shot.mjs @@ -0,0 +1,18 @@ +// web-test cli/commands/shot v1.0 — take screenshot via server +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { writeFileSync } from 'fs'; +import { out, die } from '../util.mjs'; +import { loadSession } from '../session.mjs'; + +export async function cmdShot(file) { + const sess = loadSession(); + const resp = await fetch(`http://127.0.0.1:${sess.port}/shot`); + if (!resp.ok) { + const err = await resp.text(); + die(`Screenshot failed: ${err}`); + } + const buf = Buffer.from(await resp.arrayBuffer()); + const outFile = file || 'shot.png'; + writeFileSync(outFile, buf); + out({ ok: true, file: outFile }); +} diff --git a/.claude/skills/web-test/scripts/cli/commands/start.mjs b/.claude/skills/web-test/scripts/cli/commands/start.mjs new file mode 100644 index 00000000..b83d5ec2 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/start.mjs @@ -0,0 +1,33 @@ +// web-test cli/commands/start v1.0 +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import http from 'http'; +import { writeFileSync } from 'fs'; +import * as browser from '../../browser.mjs'; +import { out, die } from '../util.mjs'; +import { SESSION_FILE, cleanup } from '../session.mjs'; +import { handleRequest } from '../server.mjs'; + +export async function cmdStart(url) { + if (!url) die('Usage: node src/run.mjs start '); + + const state = await browser.connect(url); + + const httpServer = http.createServer(handleRequest); + httpServer.listen(0, '127.0.0.1', () => { + const port = httpServer.address().port; + const session = { + port, + url, + pid: process.pid, + startedAt: new Date().toISOString() + }; + writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2)); + out({ ok: true, message: 'Browser ready', port, ...state }); + }); + + process.on('SIGINT', async () => { + await browser.disconnect(); + cleanup(); + process.exit(0); + }); +} diff --git a/.claude/skills/web-test/scripts/cli/commands/status.mjs b/.claude/skills/web-test/scripts/cli/commands/status.mjs new file mode 100644 index 00000000..f183629f --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/status.mjs @@ -0,0 +1,14 @@ +// web-test cli/commands/status v1.0 — check session +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { existsSync, readFileSync } from 'fs'; +import { out } from '../util.mjs'; +import { SESSION_FILE } from '../session.mjs'; + +export function cmdStatus() { + if (!existsSync(SESSION_FILE)) { + out({ ok: false, message: 'No active session' }); + process.exit(1); + } + const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8')); + out({ ok: true, ...sess }); +} diff --git a/.claude/skills/web-test/scripts/cli/commands/stop.mjs b/.claude/skills/web-test/scripts/cli/commands/stop.mjs new file mode 100644 index 00000000..456f15f4 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/stop.mjs @@ -0,0 +1,17 @@ +// web-test cli/commands/stop v1.0 — send stop to server +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { out } from '../util.mjs'; +import { loadSession, cleanup } from '../session.mjs'; + +export async function cmdStop() { + const sess = loadSession(); + try { + const resp = await fetch(`http://127.0.0.1:${sess.port}/stop`, { method: 'POST' }); + const result = await resp.json(); + out(result); + } catch { + // Server may have already exited before responding + out({ ok: true, message: 'Stopped' }); + } + cleanup(); +} diff --git a/.claude/skills/web-test/scripts/cli/commands/test.mjs b/.claude/skills/web-test/scripts/cli/commands/test.mjs new file mode 100644 index 00000000..3eed600f --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/commands/test.mjs @@ -0,0 +1,433 @@ +// web-test cli/commands/test v1.0 — regression test runner +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { existsSync, writeFileSync, mkdirSync } from 'fs'; +import { resolve, dirname, basename, relative } from 'path'; +import * as browser from '../../browser.mjs'; +import { out, die, elapsed, slugify, formatDuration, interpolate, printSteps } from '../util.mjs'; +import { buildContext, buildScopedContext } from '../exec-context.mjs'; +import { createAssertions } from '../test-runner/assertions.mjs'; +import { buildSeverityIndex } from '../test-runner/severity.mjs'; +import { writeAllure, buildJUnit, syncAllureExtras } from '../test-runner/reporters.mjs'; +import { discoverTests, resetState } from '../test-runner/discover.mjs'; + +export async function cmdTest(rawArgs) { + // Split off everything after `--` — those args belong to user-defined hooks + // (see spec §6: "all arguments after `--` are forwarded verbatim to _hooks.mjs + // via the hookArgs field; the runner does not interpret them"). + const sepIdx = rawArgs.indexOf('--'); + const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs; + const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : []; + + // Parse flags + const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false }; + let tags = null, grep = null; + const positional = []; + for (const a of ownArgs) { + if (a.startsWith('--tags=')) tags = a.slice(7).split(','); + else if (a.startsWith('--grep=')) grep = new RegExp(a.slice(7), 'i'); + else if (a === '--bail') opts.bail = true; + else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0; + else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000; + else if (a.startsWith('--report=')) opts.report = a.slice(9); + else if (a.startsWith('--format=')) opts.format = a.slice(9); + else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13); + else if (a.startsWith('--report-dir=')) opts.reportDir = a.slice(13); + else if (a === '--record') opts.record = true; + else if (!a.startsWith('--')) positional.push(a); + } + + // Determine URL and test path + let url, testPath; + if (positional.length === 2) { + url = positional[0]; + testPath = resolve(positional[1]); + } else if (positional.length === 1) { + testPath = resolve(positional[0]); + } else { + die('Usage: node run.mjs test [url] [--tags=...] [--bail] [--retry=N] [--timeout=ms] [--report=path]'); + } + + // Load config if exists + const isFile = testPath.endsWith('.test.mjs'); + const testDir = isFile ? dirname(testPath) : testPath; + const configPath = resolve(testDir, 'webtest.config.mjs'); + let config = {}; + if (existsSync(configPath)) { + const mod = await import('file:///' + configPath.replace(/\\/g, '/')); + config = mod.default || {}; + } + const severityIndex = buildSeverityIndex(config); + + // Build context registry: name → url. Supports config.contexts or single config.url / CLI url. + const contextSpecs = {}; + let defaultContextName = 'default'; + const defaultIsolation = config.isolation || 'tab'; + if (config.contexts && typeof config.contexts === 'object' && Object.keys(config.contexts).length) { + for (const [n, spec] of Object.entries(config.contexts)) { + contextSpecs[n] = { ...spec }; + } + defaultContextName = config.defaultContext || Object.keys(config.contexts)[0]; + if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url }; + } else { + const fallbackUrl = url || config.url; + if (!fallbackUrl) die('No URL provided and no webtest.config.mjs found'); + contextSpecs.default = { url: fallbackUrl }; + } + if (!contextSpecs[defaultContextName]) { + die(`defaultContext "${defaultContextName}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`); + } + if (!url) url = contextSpecs[defaultContextName].url; + + // Apply config defaults (CLI flags override) + if (!tags && config.tags) tags = config.tags; + opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout); + opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry); + if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) { + browser.setPreserveClipboard(false); + } + opts.record = opts.record || !!config.record; + opts.screenshot = opts.screenshot || config.screenshot || 'on-failure'; + if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) { + die(`Invalid --screenshot=${opts.screenshot} (expected on-failure|every-step|off)`); + } + if (!['json', 'allure', 'junit'].includes(opts.format)) { + die(`Invalid --format=${opts.format} (expected json|allure|junit)`); + } + if (opts.format === 'junit' && !opts.report) { + die('--format=junit requires --report=path.xml'); + } + const reportDir = opts.reportDir + ? resolve(opts.reportDir) + : (opts.report ? dirname(resolve(opts.report)) : testDir); + if (opts.screenshot !== 'off') { + try { mkdirSync(reportDir, { recursive: true }); } catch {} + } + + // Discover test files + const testFiles = discoverTests(testPath); + if (!testFiles.length) die(`No *.test.mjs files found in ${testPath}`); + + // Import and filter tests + const tests = []; + let hasOnly = false; + for (const file of testFiles) { + const mod = await import('file:///' + file.replace(/\\/g, '/')); + const base = { + file: relative(testDir, file).replace(/\\/g, '/'), + name: mod.name || basename(file, '.test.mjs'), + tags: mod.tags || [], + timeout: mod.timeout || opts.timeout, + skip: mod.skip || false, + only: mod.only || false, + setup: mod.setup, + teardown: mod.teardown, + fn: mod.default, + param: undefined, + context: mod.context || null, + contexts: Array.isArray(mod.contexts) ? mod.contexts : null, + severity: typeof mod.severity === 'string' ? mod.severity : null, + }; + if (base.only) hasOnly = true; + if (Array.isArray(mod.params) && mod.params.length) { + for (let i = 0; i < mod.params.length; i++) { + const p = mod.params[i]; + const name = base.name.includes('{') ? interpolate(base.name, p) : `${base.name}[${i}]`; + tests.push({ ...base, name, param: p }); + } + } else { + tests.push(base); + } + } + + // Filter + const filtered = tests.filter(t => { + if (hasOnly && !t.only) return false; + if (tags && !tags.some(tag => t.tags.includes(tag))) return false; + if (grep && !grep.test(t.name)) return false; + return true; + }); + + // Load hooks + const hooksPath = resolve(testDir, '_hooks.mjs'); + let hooks = {}; + if (existsSync(hooksPath)) { + hooks = await import('file:///' + hooksPath.replace(/\\/g, '/')); + } + + // Console header + const W = process.stderr; + W.write(`\nweb-test -- ${url}\n`); + W.write(`Running ${filtered.length} tests from ${relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'}/\n\n`); + + const startedAt = new Date().toISOString(); + const results = []; + let passCount = 0, failCount = 0, skipCount = 0; + + const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`); + const hookEnv = { hookArgs, log: hookLog, config }; + if (hooks.prepare) await hooks.prepare(hookEnv); + + // Lazy context creation + async function ensureContext(name) { + if (browser.hasContext(name)) return; + const spec = contextSpecs[name]; + if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`); + await browser.createContext(name, spec.url, { isolation: spec.isolation || defaultIsolation }); + if (hooks.afterOpenContext && hookCtx) { + try { await hooks.afterOpenContext(hookCtx, name, spec); } + catch (e) { hookLog(`afterOpenContext("${name}") threw: ${e.message.split('\n')[0]}`); } + } + } + + let hookCtx = null; + + function wrapCloseContextHook(target) { + const orig = target.closeContext; + if (typeof orig !== 'function') return; + target.closeContext = async (name) => { + if (hooks.beforeCloseContext) { + try { await hooks.beforeCloseContext(target, name, contextSpecs[name]); } + catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); } + } + return await orig(name); + }; + } + + try { + // Connect: create default context up front + await ensureContext(defaultContextName); + + const ctx = buildContext({ noRecord: false }); + ctx.assert = createAssertions(); + ctx.log = (...a) => { /* per-test, overridden below */ }; + wrapCloseContextHook(ctx); + hookCtx = ctx; + + // Default context was created BEFORE hookCtx existed → fire afterOpenContext now. + if (hooks.afterOpenContext) { + try { await hooks.afterOpenContext(ctx, defaultContextName, contextSpecs[defaultContextName]); } + catch (e) { hookLog(`afterOpenContext("${defaultContextName}") threw: ${e.message.split('\n')[0]}`); } + } + + if (hooks.beforeAll) await hooks.beforeAll(ctx); + + let testIdx = 0; + for (const t of filtered) { + testIdx++; + const declaredContexts = t.contexts && t.contexts.length + ? t.contexts + : [t.context || defaultContextName]; + + if (t.skip) { + const reason = typeof t.skip === 'string' ? t.skip : ''; + W.write(` ○ ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`); + results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null }); + skipCount++; + continue; + } + + const testContextNames = declaredContexts; + try { + for (const cn of testContextNames) await ensureContext(cn); + await browser.setActiveContext(testContextNames[0]); + } catch (e) { + W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`); + results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null }); + failCount++; + if (opts.bail) break; + continue; + } + + let lastError = null; + let testResult = null; + const maxAttempts = 1 + opts.retry; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const output = []; + let steps = []; + let currentSteps = steps; + let stepIdx = 0; + const t0 = Date.now(); + + ctx.testInfo = { + name: t.name, + file: basename(t.file), + filePath: t.file, + tags: t.tags, + timeout: t.timeout, + attempt, + maxAttempts, + param: t.param, + contexts: Object.fromEntries(testContextNames.map(n => [n, contextSpecs[n]])), + primaryContext: testContextNames[0], + }; + ctx.testResult = null; + + let videoFile = null; + if (opts.record) { + videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`); + try { await browser.startRecording(videoFile, { force: true }); } catch { videoFile = null; } + } + + ctx.log = (...a) => output.push(a.map(String).join(' ')); + ctx.step = async (name, fn) => { + const s = { name, start: Date.now(), status: 'passed', steps: [] }; + currentSteps.push(s); + const prev = currentSteps; + currentSteps = s.steps; + stepIdx++; + const myIdx = stepIdx; + try { + await fn(); + } catch (e) { + s.status = 'failed'; + s.error = e.message; + throw e; + } finally { + s.stop = Date.now(); + currentSteps = prev; + if (opts.screenshot === 'every-step' && s.status === 'passed') { + try { + const slug = slugify(name); + const file = resolve(reportDir, `${testIdx}-${myIdx}-${slug}.png`); + const png = await browser.screenshot(); + writeFileSync(file, png); + s.screenshot = file; + } catch {} + } + } + }; + + const scopedKeys = []; + if (t.contexts && t.contexts.length) { + for (const cn of t.contexts) { + ctx[cn] = buildScopedContext(cn); + wrapCloseContextHook(ctx[cn]); + scopedKeys.push(cn); + } + } + + try { + if (hooks.beforeEach) await hooks.beforeEach(ctx); + if (t.setup) await t.setup(ctx); + + await Promise.race([ + t.fn(ctx, t.param), + new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout)), + ]); + + if (t.teardown) try { await t.teardown(ctx); } catch {} + ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps }; + if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {} + for (const cn of testContextNames) { + try { await browser.setActiveContext(cn); await resetState(ctx); } catch {} + } + for (const k of scopedKeys) delete ctx[k]; + + if (videoFile) { + try { await browser.stopRecording(); } catch {} + } + const dur = elapsed(t0); + testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile }; + lastError = null; + break; + + } catch (e) { + // Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI. + let shotFile = e.onecError?.screenshot; + if (!shotFile && opts.screenshot !== 'off') { + try { + const png = await browser.screenshot(); + shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`); + writeFileSync(shotFile, png); + } catch {} + } + + if (t.teardown) try { await t.teardown(ctx); } catch {} + const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError }; + ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps }; + if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {} + for (const cn of testContextNames) { + try { await browser.setActiveContext(cn); await resetState(ctx); } catch {} + } + for (const k of scopedKeys) delete ctx[k]; + + if (videoFile) { + try { await browser.stopRecording(); } catch {} + } + lastError = errInfo; + const dur = elapsed(t0); + testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile }; + } + } + + results.push(testResult); + + if (testResult.status === 'passed') { + passCount++; + W.write(` ✓ ${t.name} (${testResult.duration}s)\n`); + } else { + failCount++; + W.write(` ✗ ${t.name} (${testResult.duration}s)\n`); + printSteps(W, testResult.steps, ' '); + if (lastError?.message) W.write(` ${lastError.message}\n`); + if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`); + } + + if (opts.bail && testResult.status === 'failed') break; + } + + if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {} + + } finally { + // Per-context teardown + try { + const remaining = browser.listContexts(); + if (remaining.length > 0) { + const survivor = remaining[0]; + try { await browser.setActiveContext(survivor); } catch {} + for (let i = remaining.length - 1; i >= 1; i--) { + const name = remaining[i]; + if (hooks.beforeCloseContext && hookCtx) { + try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); } + catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); } + } + try { await browser.closeContext(name); } + catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); } + } + if (hooks.beforeCloseContext && hookCtx) { + try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); } + catch (e) { hookLog(`beforeCloseContext("${survivor}") threw: ${e.message.split('\n')[0]}`); } + } + } + } catch (e) { + hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`); + } + try { await browser.disconnect(); } catch {} + if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {} + } + + const finishedAt = new Date().toISOString(); + const totalDuration = results.reduce((s, r) => s + r.duration, 0); + + W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`); + + const report = { + runner: 'web-test', url, startedAt, finishedAt, + duration: totalDuration, + summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount }, + tests: results, + }; + out(report); + + if (opts.format === 'allure') { + writeAllure(results, reportDir, severityIndex); + syncAllureExtras(testDir, reportDir); + } else if (opts.format === 'junit') { + writeFileSync(resolve(opts.report), buildJUnit(report, testDir)); + } else if (opts.report) { + writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2)); + } + + if (failCount > 0) process.exit(1); +} diff --git a/.claude/skills/web-test/scripts/cli/exec-context.mjs b/.claude/skills/web-test/scripts/cli/exec-context.mjs new file mode 100644 index 00000000..a6c0964f --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/exec-context.mjs @@ -0,0 +1,148 @@ +// web-test cli/exec-context v1.0 — buildContext + executeScript для run/exec/test +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { readFileSync, writeFileSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import * as browser from '../browser.mjs'; +import { elapsed } from './util.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ERROR_SHOT_PATH = resolve(__dirname, '..', '..', 'error-shot.png'); + +/** + * Build a per-context wrapper: same shape as buildContext output, but every call + * is prefixed with `setActiveContext(name)` so the test can interleave actions + * across contexts (`ctx.a.click(...); ctx.b.click(...)`). + */ +export function buildScopedContext(name) { + const inner = buildContext({ noRecord: false }); + const scoped = {}; + for (const [k, v] of Object.entries(inner)) { + if (typeof v === 'function') { + scoped[k] = async (...args) => { + await browser.setActiveContext(name); + return v(...args); + }; + } else { + scoped[k] = v; + } + } + return scoped; +} + +export function buildContext({ noRecord = false } = {}) { + const ctx = {}; + for (const [k, v] of Object.entries(browser)) { + if (k !== 'default') ctx[k] = v; + } + ctx.writeFileSync = writeFileSync; + ctx.readFileSync = readFileSync; + + // --no-record: stub recording/narration functions to return safe defaults + if (noRecord) { + const noop = async () => {}; + ctx.startRecording = noop; + ctx.stopRecording = async () => ({ file: null, duration: 0, size: 0 }); + ctx.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 }); + for (const fn of ['showCaption', 'hideCaption']) { + ctx[fn] = noop; + } + ctx.isRecording = () => false; + ctx.getCaptions = () => []; + } + + // Wrap action functions to auto-detect 1C errors (modal, balloon) + // and stop execution immediately with diagnostic info + const ACTION_FNS = [ + 'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow', + 'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile', + 'closeForm', 'filterList', 'unfilterList' + ]; + for (const name of ACTION_FNS) { + if (typeof ctx[name] !== 'function') continue; + const orig = ctx[name]; + ctx[name] = async (...args) => { + const result = await orig(...args); + const errors = result?.errors; + if (errors?.modal || errors?.balloon) { + // Screenshot while the error modal is still visible (before fetchErrorStack closes it) + let errorShot; + try { + const png = await ctx.screenshot(); + errorShot = ERROR_SHOT_PATH; + writeFileSync(errorShot, png); + } catch {} + // Try to fetch call stack for modal errors before throwing + let stack = null; + if (errors?.modal && typeof ctx.fetchErrorStack === 'function') { + try { + stack = await ctx.fetchErrorStack(errors.modal.formNum, errors.modal.hasReport); + } catch { /* don't fail if stack fetch fails */ } + } + const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error'; + const err = new Error(msg); + err.onecError = { step: name, args, errors, formState: result, stack, screenshot: errorShot }; + throw err; + } + return result; + }; + } + + return ctx; +} + +export async function executeScript(code, { noRecord } = {}) { + const output = []; + const origLog = console.log; + const origErr = console.error; + console.log = (...a) => output.push(a.map(String).join(' ')); + console.error = (...a) => output.push('[ERR] ' + a.map(String).join(' ')); + + const t0 = Date.now(); + try { + const ctx = buildContext({ noRecord }); + + // Normalize Windows backslash paths to prevent JS parse errors + // (e.g. C:\Users\... → \u triggers "Invalid Unicode escape sequence") + code = code.replace(/[A-Za-z]:\\[^\s'"`;\n)}\]]+/g, m => m.replace(/\\/g, '/')); + + const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; + const fn = new AsyncFunction(...Object.keys(ctx), code); + await fn(...Object.values(ctx)); + + console.log = origLog; + console.error = origErr; + return { ok: true, output: output.join('\n'), elapsed: elapsed(t0) }; + } catch (e) { + console.log = origLog; + console.error = origErr; + + // Auto-stop recording if active (prevents "Already recording" on next exec) + if (browser.isRecording()) { + try { await browser.stopRecording(); } catch {} + } + + // Error screenshot (skip if already taken before fetchErrorStack closed the modal) + let shotFile = e.onecError?.screenshot; + if (!shotFile) { + try { + const png = await browser.screenshot(); + shotFile = ERROR_SHOT_PATH; + writeFileSync(shotFile, png); + } catch {} + } + + const result = { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) }; + + // Enrich with 1C error context if available + if (e.onecError) { + result.step = e.onecError.step; + result.stepArgs = e.onecError.args; + result.onecErrors = e.onecError.errors; + result.formState = e.onecError.formState; + if (e.onecError.stack) result.stack = e.onecError.stack; + } + + return result; + } +} diff --git a/.claude/skills/web-test/scripts/cli/server.mjs b/.claude/skills/web-test/scripts/cli/server.mjs new file mode 100644 index 00000000..b8a75dc3 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/server.mjs @@ -0,0 +1,37 @@ +// web-test cli/server v1.0 — HTTP server для exec/shot/stop/status в процессе start +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import * as browser from '../browser.mjs'; +import { json, readBody } from './util.mjs'; +import { cleanup } from './session.mjs'; +import { executeScript } from './exec-context.mjs'; + +export async function handleRequest(req, res) { + try { + if (req.method === 'POST' && req.url === '/exec') { + const code = await readBody(req); + 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') { + const png = await browser.screenshot(); + res.writeHead(200, { 'Content-Type': 'image/png' }); + res.end(png); + + } else if (req.method === 'POST' && req.url === '/stop') { + json(res, { ok: true, message: 'Stopping' }); + await browser.disconnect(); + cleanup(); + process.exit(0); + + } else if (req.method === 'GET' && req.url === '/status') { + json(res, { ok: true, connected: browser.isConnected() }); + + } else { + res.writeHead(404); + res.end('Not found'); + } + } catch (e) { + json(res, { ok: false, error: e.message }, 500); + } +} diff --git a/.claude/skills/web-test/scripts/cli/session.mjs b/.claude/skills/web-test/scripts/cli/session.mjs new file mode 100644 index 00000000..b4008f3f --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/session.mjs @@ -0,0 +1,20 @@ +// web-test cli/session v1.0 — session-file helpers for HTTP-server mode +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { existsSync, readFileSync, unlinkSync } from 'fs'; +import { resolve, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { die } from './util.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const SESSION_FILE = resolve(__dirname, '..', '..', '.browser-session.json'); + +export function loadSession() { + if (!existsSync(SESSION_FILE)) { + die('No active session. Run: node src/run.mjs start '); + } + return JSON.parse(readFileSync(SESSION_FILE, 'utf-8')); +} + +export function cleanup() { + try { unlinkSync(SESSION_FILE); } catch {} +} diff --git a/.claude/skills/web-test/scripts/cli/test-runner/assertions.mjs b/.claude/skills/web-test/scripts/cli/test-runner/assertions.mjs new file mode 100644 index 00000000..23afb2d0 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/test-runner/assertions.mjs @@ -0,0 +1,64 @@ +// web-test cli/test-runner/assertions v1.0 — ctx.assert API +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +export function createAssertions() { + class AssertionError extends Error { + constructor(msg, actual, expected) { + super(msg); + this.name = 'AssertionError'; + this.actual = actual; + this.expected = expected; + } + } + + return { + ok(value, msg) { + if (!value) throw new AssertionError(msg || `Expected truthy, got ${JSON.stringify(value)}`, value, true); + }, + equal(actual, expected, msg) { + if (actual !== expected) throw new AssertionError(msg || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, actual, expected); + }, + notEqual(actual, expected, msg) { + if (actual === expected) throw new AssertionError(msg || `Expected not ${JSON.stringify(expected)}`, actual, expected); + }, + deepEqual(actual, expected, msg) { + const a = JSON.stringify(actual), b = JSON.stringify(expected); + if (a !== b) throw new AssertionError(msg || `Deep equal failed:\n actual: ${a}\n expected: ${b}`, actual, expected); + }, + includes(haystack, needle, msg) { + const h = Array.isArray(haystack) ? haystack : String(haystack); + if (!h.includes(needle)) throw new AssertionError(msg || `Expected ${JSON.stringify(h)} to include ${JSON.stringify(needle)}`, haystack, needle); + }, + match(string, regex, msg) { + if (!regex.test(string)) throw new AssertionError(msg || `Expected ${JSON.stringify(string)} to match ${regex}`, string, regex); + }, + async throws(fn, msg) { + try { await fn(); } catch { return; } + throw new AssertionError(msg || 'Expected function to throw'); + }, + // 1C-specific + formHasField(state, fieldName, msg) { + if (!state?.fields?.[fieldName]) throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${Object.keys(state?.fields || {}).join(', ')}`, null, fieldName); + }, + formTitle(state, expected, msg) { + if (!state?.title?.includes(expected)) throw new AssertionError(msg || `Form title "${state?.title}" does not contain "${expected}"`, state?.title, expected); + }, + tableHasRow(table, predicate, msg) { + const rows = table?.rows || []; + let found; + if (typeof predicate === 'function') { + found = rows.some(predicate); + } else { + found = rows.some(r => Object.entries(predicate).every(([k, v]) => r[k] === v)); + } + if (!found) throw new AssertionError(msg || `No row matching predicate in table (${rows.length} rows)`, null, predicate); + }, + tableRowCount(table, expected, msg) { + const actual = table?.rows?.length ?? 0; + if (actual !== expected) throw new AssertionError(msg || `Expected ${expected} rows, got ${actual}`, actual, expected); + }, + noErrors(state, msg) { + if (state?.errors) throw new AssertionError(msg || `Form has errors: ${JSON.stringify(state.errors)}`, state.errors, null); + }, + }; +} diff --git a/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs b/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs new file mode 100644 index 00000000..bffbe8e9 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/test-runner/discover.mjs @@ -0,0 +1,32 @@ +// web-test cli/test-runner/discover v1.0 — test file discovery + state reset between tests +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { existsSync, readdirSync } from 'fs'; +import { resolve } from 'path'; + +export function discoverTests(testPath) { + if (testPath.endsWith('.test.mjs')) return existsSync(testPath) ? [testPath] : []; + const files = []; + function walk(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue; + const full = resolve(dir, entry.name); + if (entry.isDirectory()) walk(full); + else if (entry.name.endsWith('.test.mjs')) files.push(full); + } + } + walk(testPath); + return files.sort(); +} + +export async function resetState(ctx) { + try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {} + for (let i = 0; i < 10; i++) { + try { + const state = await ctx.getFormState(); + // form === null means no form open (desktop). form === 0 is a real background form + // 1C exposes in some states — must still close it to fully reset. + if (state.form == null) break; + await ctx.closeForm({ save: false }); + } catch { break; } + } +} diff --git a/.claude/skills/web-test/scripts/cli/test-runner/reporters.mjs b/.claude/skills/web-test/scripts/cli/test-runner/reporters.mjs new file mode 100644 index 00000000..2a4cab29 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/test-runner/reporters.mjs @@ -0,0 +1,113 @@ +// web-test cli/test-runner/reporters v1.0 — Allure/JUnit writers + extras sync +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { writeFileSync, existsSync, readdirSync, copyFileSync, statSync } from 'fs'; +import { resolve, dirname, basename, relative } from 'path'; +import { randomUUID } from 'crypto'; +import { xmlEscape } from '../util.mjs'; +import { resolveSeverity } from './severity.mjs'; + +/** + * Copy any files from `/_allure/` into `reportDir`. Convention for + * Allure customization that doesn't fit inside per-test JSON: + * - `categories.json` — failure classification (regex → bucket) + * - `environment.properties` — values shown in the Environment widget + * - `executor.json` — CI/CD metadata + * Underscored folder mirrors `_hooks.mjs` convention (infra, not a test). + * Silent if folder absent. + */ +export function syncAllureExtras(testDir, reportDir) { + const extrasDir = resolve(testDir, '_allure'); + if (!existsSync(extrasDir)) return; + try { + if (!statSync(extrasDir).isDirectory()) return; + } catch { return; } + for (const entry of readdirSync(extrasDir, { withFileTypes: true })) { + if (!entry.isFile()) continue; + try { copyFileSync(resolve(extrasDir, entry.name), resolve(reportDir, entry.name)); } + catch { /* best-effort */ } + } +} + +export function writeAllure(results, reportDir, severityIndex) { + for (const tr of results) { + if (tr.status === 'skipped') continue; // Allure ignores skipped without start/stop + const uuid = randomUUID(); + const suite = dirname(tr.file); + const suiteLabel = (suite && suite !== '.') ? suite : 'root'; + const severity = resolveSeverity(tr, severityIndex); + const out = { + uuid, + name: tr.name, + fullName: tr.file, + status: tr.status, + stage: 'finished', + start: tr.start, + stop: tr.stop, + labels: [ + ...(tr.tags || []).map(t => ({ name: 'tag', value: t })), + { name: 'suite', value: suiteLabel }, + { name: 'severity', value: severity }, + ], + steps: (tr.steps || []).map(allureStep), + attachments: [ + ...(tr.screenshot ? [{ name: 'Screenshot on failure', source: basename(tr.screenshot), type: 'image/png' }] : []), + ...(tr.video ? [{ name: 'Video', source: basename(tr.video), type: 'video/mp4' }] : []), + ], + }; + if (tr.status === 'failed' && tr.error) { + const traceParts = []; + if (tr.output) traceParts.push(tr.output); + const onecStack = tr.error.onecError?.stack?.raw; + if (onecStack) { + if (traceParts.length) traceParts.push('\n--- 1C stack ---\n'); + traceParts.push(onecStack); + } + out.statusDetails = { message: tr.error.message || '', trace: traceParts.join('') }; + } + writeFileSync(resolve(reportDir, `${uuid}-result.json`), JSON.stringify(out, null, 2)); + } +} + +function allureStep(s) { + const out = { + name: s.name, + status: s.status, + stage: 'finished', + start: s.start, + stop: s.stop, + steps: (s.steps || []).map(allureStep), + }; + if (s.screenshot) { + out.attachments = [{ name: 'Screenshot', source: basename(s.screenshot), type: 'image/png' }]; + } + if (s.status === 'failed' && s.error) { + out.statusDetails = { message: s.error, trace: s.error }; + } + return out; +} + +export function buildJUnit(report, testDir) { + const { summary, duration, tests } = report; + const suiteName = relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'; + const lines = ['']; + lines.push(``); + lines.push(` `); + for (const t of tests) { + const attrs = `name="${xmlEscape(t.name)}" classname="${xmlEscape(t.file)}" time="${(t.duration || 0).toFixed(3)}"`; + if (t.status === 'passed') { + lines.push(` `); + } else if (t.status === 'skipped') { + lines.push(` `); + } else { + lines.push(` `); + const msg = t.error?.message || ''; + const trace = t.output || ''; + lines.push(` ${xmlEscape(trace)}`); + if (t.screenshot) lines.push(` screenshot: ${xmlEscape(t.screenshot)}`); + lines.push(` `); + } + } + lines.push(` `); + lines.push(``); + return lines.join('\n'); +} diff --git a/.claude/skills/web-test/scripts/cli/test-runner/severity.mjs b/.claude/skills/web-test/scripts/cli/test-runner/severity.mjs new file mode 100644 index 00000000..d76b753d --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/test-runner/severity.mjs @@ -0,0 +1,66 @@ +// web-test cli/test-runner/severity v1.0 — Allure severity policy resolver +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +import { die } from '../util.mjs'; + +export const SEVERITY_RANK = { blocker: 5, critical: 4, normal: 3, minor: 2, trivial: 1 }; +export const SEVERITY_LEVELS = Object.keys(SEVERITY_RANK); + +/** + * Validate config.severity (inverted map: severity → [tags]) at config load time. + * Returns: + * - tagToSeverity: Map (precomputed lookup for the resolver) + * - defaultSeverity: string (validated, defaults to 'normal') + * Throws (via die) on invalid keys, invalid default, or duplicate tag across buckets. + */ +export function buildSeverityIndex(config) { + const tagToSeverity = new Map(); + const sev = config.severity || {}; + if (typeof sev !== 'object' || Array.isArray(sev)) { + die(`config.severity must be an object, got ${typeof sev}`); + } + for (const [level, tags] of Object.entries(sev)) { + if (!SEVERITY_LEVELS.includes(level)) { + die(`config.severity: unknown level "${level}". Allowed: ${SEVERITY_LEVELS.join('|')}`); + } + if (!Array.isArray(tags)) { + die(`config.severity.${level} must be an array of tag names, got ${typeof tags}`); + } + for (const tag of tags) { + if (tagToSeverity.has(tag)) { + die(`config.severity: tag "${tag}" listed under both "${tagToSeverity.get(tag)}" and "${level}" — pick one`); + } + tagToSeverity.set(tag, level); + } + } + const def = config.defaultSeverity || 'normal'; + if (!SEVERITY_LEVELS.includes(def)) { + die(`config.defaultSeverity: "${def}" is not a valid level. Allowed: ${SEVERITY_LEVELS.join('|')}`); + } + return { tagToSeverity, defaultSeverity: def }; +} + +/** + * Resolve a test's severity. Precedence: + * 1. explicit `export const severity` from the test module + * 2. max-rank severity found among tags (either standard severity name, or mapped via config) + * 3. defaultSeverity from config (or 'normal' if not set) + * Returns one of SEVERITY_LEVELS. + */ +export function resolveSeverity(t, severityIndex) { + if (t.severity) { + if (!SEVERITY_LEVELS.includes(t.severity)) { + return severityIndex.defaultSeverity; + } + return t.severity; + } + let best = null; + for (const tag of t.tags || []) { + let candidate = null; + if (SEVERITY_LEVELS.includes(tag)) candidate = tag; + else if (severityIndex.tagToSeverity.has(tag)) candidate = severityIndex.tagToSeverity.get(tag); + if (candidate && (best === null || SEVERITY_RANK[candidate] > SEVERITY_RANK[best])) { + best = candidate; + } + } + return best || severityIndex.defaultSeverity; +} diff --git a/.claude/skills/web-test/scripts/cli/util.mjs b/.claude/skills/web-test/scripts/cli/util.mjs new file mode 100644 index 00000000..c24a52e6 --- /dev/null +++ b/.claude/skills/web-test/scripts/cli/util.mjs @@ -0,0 +1,111 @@ +// web-test cli/util v1.0 — generic helpers for CLI commands +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +export function out(obj) { + process.stdout.write(JSON.stringify(obj, null, 2) + '\n'); +} + +export function die(msg) { + process.stderr.write(msg + '\n'); + process.exit(1); +} + +export function json(res, obj, status = 200) { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(obj, null, 2)); +} + +export async function readBody(req) { + const chunks = []; + for await (const chunk of req) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf-8'); +} + +export async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf-8'); +} + +export function elapsed(t0) { + return Math.round((Date.now() - t0) / 100) / 10; +} + +export function elapsed2(start, stop) { + return Math.round(((stop || Date.now()) - start) / 100) / 10; +} + +export function slugify(s) { + return String(s).trim() + .replace(/[\s/\\:*?"<>|]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 60) || 'step'; +} + +export function formatDuration(seconds) { + if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`; + const m = Math.floor(seconds / 60); + const s = Math.round((seconds - m * 60) * 10) / 10; + return `${m}m ${s}s`; +} + +export function xmlEscape(s) { + return String(s == null ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} + +export function interpolate(template, params) { + return String(template).replace(/\{(\w+)\}/g, (_, key) => + params[key] !== undefined ? String(params[key]) : `{${key}}`); +} + +export function printSteps(W, steps, indent) { + for (let i = 0; i < steps.length; i++) { + const s = steps[i]; + const last = i === steps.length - 1; + const prefix = last ? '└' : '├'; + const mark = s.status === 'failed' ? '✗ ' : ''; + W.write(`${indent}${prefix} ${mark}${s.name} (${elapsed2(s.start, s.stop)}s)\n`); + if (s.error && s.status === 'failed') { + W.write(`${indent} ${s.error}\n`); + } + if (s.steps.length) printSteps(W, s.steps, indent + ' '); + } +} + +export function usage() { + die(`Usage: node run.mjs [args] + +Commands: + start Launch browser and connect to 1C web client + run Autonomous: connect, execute script, disconnect + exec [options] Execute script (file path or - for stdin) + shot [file] Take screenshot (default: shot.png) + stop Logout and close browser + status Check session status + test [url] Run regression tests (*.test.mjs) + +Options for exec: + --no-record Skip video recording (record() becomes no-op) + +Global options (any command): + --no-preserve-clipboard Don't save/restore OS clipboard around action calls. + Default: on (env: WEB_TEST_PRESERVE_CLIPBOARD=0 to disable globally). + +Options for test: + --tags=smoke,crud Filter tests by tags + --grep=pattern Filter tests by name (regex) + --bail Stop on first failure + --retry=N Retry failed tests N times + --timeout=ms Per-test timeout (default: 30000) + --report=path Write JSON report to file + --report-dir=path Directory for screenshots and other artifacts + --screenshot=mode on-failure (default) | every-step | off + --format=fmt json (default) | allure | junit + --record Record video for each test (mp4 in report-dir) + -- Everything after \`--\` is forwarded to _hooks.mjs + prepare/cleanup as hookArgs (runner does not parse it). + Example: ... tests/web-test/ -- --rebuild-stand`); +} diff --git a/.claude/skills/web-test/scripts/run.mjs b/.claude/skills/web-test/scripts/run.mjs index 74cf6a12..7fb2e1e6 100644 --- a/.claude/skills/web-test/scripts/run.mjs +++ b/.claude/skills/web-test/scripts/run.mjs @@ -1,1258 +1,65 @@ -#!/usr/bin/env node -// web-test run v1.16 — CLI runner for 1C web client automation -// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills -/** - * CLI runner for 1C web client automation. - * - * Architecture: `start` launches browser + HTTP server in one process. - * `exec`, `shot`, `stop` send requests to the running server. - * - * Usage: - * node src/run.mjs start — launch browser, connect to 1C, serve requests - * node src/run.mjs run — autonomous: connect, execute script, disconnect - * node src/run.mjs exec — run script against existing session - * node src/run.mjs shot [file] — take screenshot - * node src/run.mjs stop — logout + close browser - * node src/run.mjs status — check session - * node src/run.mjs test [url] — run regression tests - */ -import http from 'http'; -import * as browser from './browser.mjs'; -import { readFileSync, writeFileSync, unlinkSync, existsSync, readdirSync, mkdirSync, copyFileSync, statSync } from 'fs'; -import { resolve, dirname, basename, relative } from 'path'; -import { fileURLToPath } from 'url'; -import { randomUUID } from 'crypto'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const SESSION_FILE = resolve(__dirname, '..', '.browser-session.json'); - -// Allure severity policy. Declared early so buildSeverityIndex (called inside -// cmdTest) can use these constants — top-level const are not hoisted, and -// cmdTest is invoked synchronously below via `await cmdTest(rawArgs)`. -const SEVERITY_RANK = { blocker: 5, critical: 4, normal: 3, minor: 2, trivial: 1 }; -const SEVERITY_LEVELS = Object.keys(SEVERITY_RANK); - -const [,, cmd, ...rawArgs] = process.argv; -const flags = { - noRecord: rawArgs.includes('--no-record'), - execTimeoutMs: parseExecTimeoutMs(rawArgs), -}; -const args = rawArgs.filter(a => !a.startsWith('--')); - -// Clipboard preservation: default ON. Disabled by --no-preserve-clipboard CLI flag -// or WEB_TEST_PRESERVE_CLIPBOARD=0 env. cmdTest may further disable via config. -// Forwarded to browser.setPreserveClipboard() — narrow save/restore lives around -// each writeText+Ctrl+V pair inside pasteText() in browser.mjs. -const preserveClipboard = !rawArgs.includes('--no-preserve-clipboard') - && process.env.WEB_TEST_PRESERVE_CLIPBOARD !== '0'; -browser.setPreserveClipboard(preserveClipboard); - -function parseExecTimeoutMs(argv) { - const DEFAULT_MS = 30 * 60 * 1000; - const flagMs = argv.find(a => a.startsWith('--timeout=')); - if (flagMs) return Math.max(1, Number(flagMs.slice('--timeout='.length))) || DEFAULT_MS; - const flagMin = argv.find(a => a.startsWith('--timeout-min=')); - if (flagMin) return Math.max(1, Number(flagMin.slice('--timeout-min='.length))) * 60 * 1000 || DEFAULT_MS; - const env = process.env.WEB_TEST_EXEC_TIMEOUT_MS; - if (env) return Math.max(1, Number(env)) || DEFAULT_MS; - return DEFAULT_MS; -} - -switch (cmd) { - case 'start': await cmdStart(args[0]); break; - case 'run': await cmdRun(args[0], args[1]); break; - case 'exec': await cmdExec(args[0], flags); break; - case 'shot': await cmdShot(args[0]); break; - case 'stop': await cmdStop(); break; - case 'status': cmdStatus(); break; - case 'test': await cmdTest(rawArgs); break; - default: usage(); -} - - -// ============================================================ -// start: launch browser + HTTP server -// ============================================================ - -async function cmdStart(url) { - if (!url) die('Usage: node src/run.mjs start '); - - // Connect to 1C - const state = await browser.connect(url); - - // Start HTTP server for exec/shot/stop - const httpServer = http.createServer(handleRequest); - httpServer.listen(0, '127.0.0.1', () => { - const port = httpServer.address().port; - const session = { - port, - url, - pid: process.pid, - startedAt: new Date().toISOString() - }; - writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2)); - out({ ok: true, message: 'Browser ready', port, ...state }); - }); - - process.on('SIGINT', async () => { - await browser.disconnect(); - cleanup(); - process.exit(0); - }); -} - -async function handleRequest(req, res) { - try { - if (req.method === 'POST' && req.url === '/exec') { - const code = await readBody(req); - 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') { - const png = await browser.screenshot(); - res.writeHead(200, { 'Content-Type': 'image/png' }); - res.end(png); - - } else if (req.method === 'POST' && req.url === '/stop') { - json(res, { ok: true, message: 'Stopping' }); - await browser.disconnect(); - cleanup(); - process.exit(0); - - } else if (req.method === 'GET' && req.url === '/status') { - json(res, { ok: true, connected: browser.isConnected() }); - - } else { - res.writeHead(404); - res.end('Not found'); - } - } catch (e) { - json(res, { ok: false, error: e.message }, 500); - } -} - -// ============================================================ -// buildContext: assemble browser API with error wrapping -// ============================================================ - -/** - * Build a per-context wrapper: same shape as buildContext output, but every call - * is prefixed with `setActiveContext(name)` so the test can interleave actions - * across contexts (`ctx.a.click(...); ctx.b.click(...)`). - */ -function buildScopedContext(name) { - const inner = buildContext({ noRecord: false }); - const scoped = {}; - for (const [k, v] of Object.entries(inner)) { - if (typeof v === 'function') { - scoped[k] = async (...args) => { - await browser.setActiveContext(name); - return v(...args); - }; - } else { - scoped[k] = v; - } - } - return scoped; -} - -function buildContext({ noRecord = false } = {}) { - const ctx = {}; - for (const [k, v] of Object.entries(browser)) { - if (k !== 'default') ctx[k] = v; - } - ctx.writeFileSync = writeFileSync; - ctx.readFileSync = readFileSync; - - // --no-record: stub recording/narration functions to return safe defaults - if (noRecord) { - const noop = async () => {}; - ctx.startRecording = noop; - ctx.stopRecording = async () => ({ file: null, duration: 0, size: 0 }); - ctx.addNarration = async () => ({ file: null, duration: 0, size: 0, captions: 0 }); - for (const fn of ['showCaption', 'hideCaption']) { - ctx[fn] = noop; - } - ctx.isRecording = () => false; - ctx.getCaptions = () => []; - } - - // Wrap action functions to auto-detect 1C errors (modal, balloon) - // and stop execution immediately with diagnostic info - const ACTION_FNS = [ - 'clickElement', 'fillFields', 'fillField', 'selectValue', 'fillTableRow', - 'deleteTableRow', 'openCommand', 'navigateSection', 'navigateLink', 'openFile', - 'closeForm', 'filterList', 'unfilterList' - ]; - for (const name of ACTION_FNS) { - if (typeof ctx[name] !== 'function') continue; - const orig = ctx[name]; - ctx[name] = async (...args) => { - const result = await orig(...args); - const errors = result?.errors; - if (errors?.modal || errors?.balloon) { - // Screenshot while the error modal is still visible (before fetchErrorStack closes it) - let errorShot; - try { - const png = await ctx.screenshot(); - errorShot = resolve(__dirname, '..', 'error-shot.png'); - writeFileSync(errorShot, png); - } catch {} - // Try to fetch call stack for modal errors before throwing - let stack = null; - if (errors?.modal && typeof ctx.fetchErrorStack === 'function') { - try { - stack = await ctx.fetchErrorStack(errors.modal.formNum, errors.modal.hasReport); - } catch { /* don't fail if stack fetch fails */ } - } - const msg = errors.modal?.message || errors.balloon?.message || 'Unknown 1C error'; - const err = new Error(msg); - err.onecError = { step: name, args, errors, formState: result, stack, screenshot: errorShot }; - throw err; - } - return result; - }; - } - - return ctx; -} - - -async function executeScript(code, { noRecord } = {}) { - const output = []; - const origLog = console.log; - const origErr = console.error; - console.log = (...a) => output.push(a.map(String).join(' ')); - console.error = (...a) => output.push('[ERR] ' + a.map(String).join(' ')); - - const t0 = Date.now(); - try { - const ctx = buildContext({ noRecord }); - - // Normalize Windows backslash paths to prevent JS parse errors - // (e.g. C:\Users\... → \u triggers "Invalid Unicode escape sequence") - code = code.replace(/[A-Za-z]:\\[^\s'"`;\n)}\]]+/g, m => m.replace(/\\/g, '/')); - - const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; - const fn = new AsyncFunction(...Object.keys(ctx), code); - await fn(...Object.values(ctx)); - - console.log = origLog; - console.error = origErr; - return { ok: true, output: output.join('\n'), elapsed: elapsed(t0) }; - } catch (e) { - console.log = origLog; - console.error = origErr; - - // Auto-stop recording if active (prevents "Already recording" on next exec) - if (browser.isRecording()) { - try { await browser.stopRecording(); } catch {} - } - - // Error screenshot (skip if already taken before fetchErrorStack closed the modal) - let shotFile = e.onecError?.screenshot; - if (!shotFile) { - try { - const png = await browser.screenshot(); - shotFile = resolve(__dirname, '..', 'error-shot.png'); - writeFileSync(shotFile, png); - } catch {} - } - - const result = { ok: false, error: e.message, output: output.join('\n'), screenshot: shotFile, elapsed: elapsed(t0) }; - - // Enrich with 1C error context if available - if (e.onecError) { - result.step = e.onecError.step; - result.stepArgs = e.onecError.args; - result.onecErrors = e.onecError.errors; - result.formState = e.onecError.formState; - if (e.onecError.stack) result.stack = e.onecError.stack; - } - - return result; - } -} - - -// ============================================================ -// run: autonomous connect → execute → disconnect (no server) -// ============================================================ - -async function cmdRun(url, fileOrDash) { - if (!url || !fileOrDash) die('Usage: node src/run.mjs run '); - - const code = fileOrDash === '-' - ? await readStdin() - : readFileSync(resolve(fileOrDash), 'utf-8'); - - await browser.connect(url); - const result = await executeScript(code); - await browser.disconnect(); - - out(result); - if (!result.ok) process.exit(1); -} - - -// ============================================================ -// exec: send script to running server -// ============================================================ - -async function cmdExec(fileOrDash, flags = {}) { - if (!fileOrDash) die('Usage: node src/run.mjs exec [--no-record]'); - - let code = fileOrDash === '-' - ? await readStdin() - : readFileSync(resolve(fileOrDash), 'utf-8'); - - const sess = loadSession(); - const headers = {}; - if (flags.noRecord) headers['x-no-record'] = '1'; - const timeoutMs = flags.execTimeoutMs ?? 30 * 60 * 1000; - const result = await new Promise((resolve, reject) => { - const req = http.request({ - hostname: '127.0.0.1', port: sess.port, path: '/exec', - method: 'POST', timeout: timeoutMs, headers, - }, res => { - let data = ''; - res.on('data', chunk => data += chunk); - res.on('end', () => { try { resolve(JSON.parse(data)); } catch { reject(new Error(data)); } }); - }); - req.on('error', reject); - req.on('timeout', () => { req.destroy(new Error(`Exec timeout (${Math.round(timeoutMs / 60000)} min)`)); }); - req.write(code); - req.end(); - }); - out(result); - if (!result.ok) process.exit(1); -} - - -// ============================================================ -// shot: take screenshot via server -// ============================================================ - -async function cmdShot(file) { - const sess = loadSession(); - const resp = await fetch(`http://127.0.0.1:${sess.port}/shot`); - if (!resp.ok) { - const err = await resp.text(); - die(`Screenshot failed: ${err}`); - } - const buf = Buffer.from(await resp.arrayBuffer()); - const outFile = file || 'shot.png'; - writeFileSync(outFile, buf); - out({ ok: true, file: outFile }); -} - - -// ============================================================ -// stop: send stop to server -// ============================================================ - -async function cmdStop() { - const sess = loadSession(); - try { - const resp = await fetch(`http://127.0.0.1:${sess.port}/stop`, { method: 'POST' }); - const result = await resp.json(); - out(result); - } catch { - // Server may have already exited before responding - out({ ok: true, message: 'Stopped' }); - } - cleanup(); -} - - -// ============================================================ -// status: check session -// ============================================================ - -function cmdStatus() { - if (!existsSync(SESSION_FILE)) { - out({ ok: false, message: 'No active session' }); - process.exit(1); - } - const sess = JSON.parse(readFileSync(SESSION_FILE, 'utf-8')); - out({ ok: true, ...sess }); -} - - -// ============================================================ -// test: run regression tests -// ============================================================ - -async function cmdTest(rawArgs) { - // Split off everything after `--` — those args belong to user-defined hooks - // (see spec §6: "all arguments after `--` are forwarded verbatim to _hooks.mjs - // via the hookArgs field; the runner does not interpret them"). - const sepIdx = rawArgs.indexOf('--'); - const ownArgs = sepIdx >= 0 ? rawArgs.slice(0, sepIdx) : rawArgs; - const hookArgs = sepIdx >= 0 ? rawArgs.slice(sepIdx + 1) : []; - - // Parse flags - const opts = { bail: false, retry: 0, timeout: 30000, report: null, format: 'json', screenshot: null, reportDir: null, record: false }; - let tags = null, grep = null; - const positional = []; - for (const a of ownArgs) { - if (a.startsWith('--tags=')) tags = a.slice(7).split(','); - else if (a.startsWith('--grep=')) grep = new RegExp(a.slice(7), 'i'); - else if (a === '--bail') opts.bail = true; - else if (a.startsWith('--retry=')) opts.retry = parseInt(a.slice(8)) || 0; - else if (a.startsWith('--timeout=')) opts.timeout = parseInt(a.slice(10)) || 30000; - else if (a.startsWith('--report=')) opts.report = a.slice(9); - else if (a.startsWith('--format=')) opts.format = a.slice(9); - else if (a.startsWith('--screenshot=')) opts.screenshot = a.slice(13); - else if (a.startsWith('--report-dir=')) opts.reportDir = a.slice(13); - else if (a === '--record') opts.record = true; - else if (!a.startsWith('--')) positional.push(a); - } - - // Determine URL and test path - let url, testPath; - if (positional.length === 2) { - url = positional[0]; - testPath = resolve(positional[1]); - } else if (positional.length === 1) { - testPath = resolve(positional[0]); - } else { - die('Usage: node run.mjs test [url] [--tags=...] [--bail] [--retry=N] [--timeout=ms] [--report=path]'); - } - - // Load config if exists - const isFile = testPath.endsWith('.test.mjs'); - const testDir = isFile ? dirname(testPath) : testPath; - const configPath = resolve(testDir, 'webtest.config.mjs'); - let config = {}; - if (existsSync(configPath)) { - const mod = await import('file:///' + configPath.replace(/\\/g, '/')); - config = mod.default || {}; - } - // Validate severity policy at config load (fail-fast on misconfig). - const severityIndex = buildSeverityIndex(config); - // Build context registry: name → url. Supports config.contexts or single config.url / CLI url. - // CLI url overrides default context's url. - const contextSpecs = {}; // name → { url, isolation } - let defaultContextName = 'default'; - const defaultIsolation = config.isolation || 'tab'; - if (config.contexts && typeof config.contexts === 'object' && Object.keys(config.contexts).length) { - for (const [n, spec] of Object.entries(config.contexts)) { - contextSpecs[n] = { ...spec }; - } - defaultContextName = config.defaultContext || Object.keys(config.contexts)[0]; - if (url) contextSpecs[defaultContextName] = { ...contextSpecs[defaultContextName], url }; // CLI override of default url (preserve custom fields) - } else { - const fallbackUrl = url || config.url; - if (!fallbackUrl) die('No URL provided and no webtest.config.mjs found'); - contextSpecs.default = { url: fallbackUrl }; - } - if (!contextSpecs[defaultContextName]) { - die(`defaultContext "${defaultContextName}" not found in contexts: [${Object.keys(contextSpecs).join(', ')}]`); - } - if (!url) url = contextSpecs[defaultContextName].url; - - // Apply config defaults (CLI flags override) - if (!tags && config.tags) tags = config.tags; - opts.timeout = ownArgs.some(a => a.startsWith('--timeout=')) ? opts.timeout : (config.timeout || opts.timeout); - opts.retry = ownArgs.some(a => a.startsWith('--retry=')) ? opts.retry : (config.retries || opts.retry); - // Clipboard preservation: CLI flag wins (already applied at boot), else config can disable. - if (config.preserveClipboard === false && !ownArgs.includes('--no-preserve-clipboard')) { - browser.setPreserveClipboard(false); - } - opts.record = opts.record || !!config.record; - opts.screenshot = opts.screenshot || config.screenshot || 'on-failure'; - if (!['on-failure', 'every-step', 'off'].includes(opts.screenshot)) { - die(`Invalid --screenshot=${opts.screenshot} (expected on-failure|every-step|off)`); - } - if (!['json', 'allure', 'junit'].includes(opts.format)) { - die(`Invalid --format=${opts.format} (expected json|allure|junit)`); - } - if (opts.format === 'junit' && !opts.report) { - die('--format=junit requires --report=path.xml'); - } - // Resolve report directory: --report-dir, else dirname(--report), else testDir - const reportDir = opts.reportDir - ? resolve(opts.reportDir) - : (opts.report ? dirname(resolve(opts.report)) : testDir); - if (opts.screenshot !== 'off') { - try { mkdirSync(reportDir, { recursive: true }); } catch {} - } - - // Discover test files - const testFiles = discoverTests(testPath); - if (!testFiles.length) die(`No *.test.mjs files found in ${testPath}`); - - // Import and filter tests - const tests = []; - let hasOnly = false; - for (const file of testFiles) { - const mod = await import('file:///' + file.replace(/\\/g, '/')); - const base = { - file: relative(testDir, file).replace(/\\/g, '/'), - name: mod.name || basename(file, '.test.mjs'), - tags: mod.tags || [], - timeout: mod.timeout || opts.timeout, - skip: mod.skip || false, - only: mod.only || false, - setup: mod.setup, - teardown: mod.teardown, - fn: mod.default, - param: undefined, - context: mod.context || null, - contexts: Array.isArray(mod.contexts) ? mod.contexts : null, - severity: typeof mod.severity === 'string' ? mod.severity : null, - }; - if (base.only) hasOnly = true; - if (Array.isArray(mod.params) && mod.params.length) { - for (let i = 0; i < mod.params.length; i++) { - const p = mod.params[i]; - const name = base.name.includes('{') ? interpolate(base.name, p) : `${base.name}[${i}]`; - tests.push({ ...base, name, param: p }); - } - } else { - tests.push(base); - } - } - - // Filter - const filtered = tests.filter(t => { - if (hasOnly && !t.only) return false; - if (tags && !tags.some(tag => t.tags.includes(tag))) return false; - if (grep && !grep.test(t.name)) return false; - return true; - }); - - // Load hooks - const hooksPath = resolve(testDir, '_hooks.mjs'); - let hooks = {}; - if (existsSync(hooksPath)) { - hooks = await import('file:///' + hooksPath.replace(/\\/g, '/')); - } - - // Console header - const W = process.stderr; - W.write(`\nweb-test -- ${url}\n`); - W.write(`Running ${filtered.length} tests from ${relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'}/\n\n`); - - const startedAt = new Date().toISOString(); - const results = []; - let passCount = 0, failCount = 0, skipCount = 0; - - // Prepare: infrastructure hooks (no browser) - // Spec §6: prepare receives { hookArgs, log, config } — see ExternalDoc. - const hookLog = (...a) => W.write(`[hooks] ${a.map(String).join(' ')}\n`); - const hookEnv = { hookArgs, log: hookLog, config }; - if (hooks.prepare) await hooks.prepare(hookEnv); - - // Lazy context creation: ensures the named browser context exists, creating it on first request. - // Fires `afterOpenContext(ctx, name, spec)` once per context — right after createContext succeeds. - // The hook receives the same `ctx` that tests use (assembled below), so it can access browser API. - async function ensureContext(name) { - if (browser.hasContext(name)) return; - const spec = contextSpecs[name]; - if (!spec) throw new Error(`Unknown context "${name}". Defined: [${Object.keys(contextSpecs).join(', ')}]`); - await browser.createContext(name, spec.url, { isolation: spec.isolation || defaultIsolation }); - if (hooks.afterOpenContext && hookCtx) { - try { await hooks.afterOpenContext(hookCtx, name, spec); } - catch (e) { hookLog(`afterOpenContext("${name}") threw: ${e.message.split('\n')[0]}`); } - } - } - - // `hookCtx` is set after buildContext below; ensureContext is also called before ctx exists - // (for the default context), so we tolerate `hookCtx === undefined` there — the default - // context's afterOpenContext fires once ctx is built, in the explicit call below. - let hookCtx = null; - - // Wrap `target.closeContext` so calling it from a test fires `beforeCloseContext(ctx, name, spec)` - // before delegating to the bare browser.closeContext. Applied to the flat ctx and each scoped - // context (ctx.a / ctx.b) so `await a.closeContext('b')` triggers the hook. - function wrapCloseContextHook(target) { - const orig = target.closeContext; - if (typeof orig !== 'function') return; - target.closeContext = async (name) => { - if (hooks.beforeCloseContext) { - try { await hooks.beforeCloseContext(target, name, contextSpecs[name]); } - catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); } - } - return await orig(name); - }; - } - - try { - // Connect: create the default context up front (so beforeAll has a working browser) - await ensureContext(defaultContextName); - - // Build context — flat API for single-context tests; reused across tests via setActiveContext. - // noRecord: false → tests get full API (showCaption, startRecording, etc.). The runner manages - // its own recording via --record; if a test author calls startRecording while the runner already - // records, browser.startRecording throws "Already recording" (loud failure beats silent no-op). - const ctx = buildContext({ noRecord: false }); - ctx.assert = createAssertions(); - ctx.log = (...a) => { /* per-test, overridden below */ }; - wrapCloseContextHook(ctx); - hookCtx = ctx; - - // Default context was created BEFORE hookCtx existed → fire afterOpenContext now. - if (hooks.afterOpenContext) { - try { await hooks.afterOpenContext(ctx, defaultContextName, contextSpecs[defaultContextName]); } - catch (e) { hookLog(`afterOpenContext("${defaultContextName}") threw: ${e.message.split('\n')[0]}`); } - } - - // beforeAll - if (hooks.beforeAll) await hooks.beforeAll(ctx); - - // Execute tests - let testIdx = 0; - for (const t of filtered) { - testIdx++; - // Declared contexts — нужны и в skip-ветке, и в основной, чтобы все - // testResult-записи в отчёте всегда содержали `contexts` поле. - const declaredContexts = t.contexts && t.contexts.length - ? t.contexts - : [t.context || defaultContextName]; - - if (t.skip) { - const reason = typeof t.skip === 'string' ? t.skip : ''; - W.write(` \u25CB ${t.name}${reason ? ` (skip: ${reason})` : ' (skip)'}\n`); - results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'skipped', duration: 0, attempts: 0, steps: [], output: '', error: null, screenshot: null }); - skipCount++; - continue; - } - - // Resolve test's contexts: multi (t.contexts) or single (t.context || default). - // Lazy-create them and set active to the primary one. - const testContextNames = declaredContexts; - try { - for (const cn of testContextNames) await ensureContext(cn); - await browser.setActiveContext(testContextNames[0]); - } catch (e) { - W.write(` ✗ ${t.name} (context setup failed: ${e.message})\n`); - results.push({ name: t.name, file: t.file, tags: t.tags, contexts: declaredContexts, status: 'failed', duration: 0, attempts: 0, steps: [], output: '', error: { message: e.message }, screenshot: null }); - failCount++; - if (opts.bail) break; - continue; - } - - let lastError = null; - let testResult = null; - const maxAttempts = 1 + opts.retry; - - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const output = []; - let steps = []; - let currentSteps = steps; - let stepIdx = 0; - const t0 = Date.now(); - - // testInfo — declarative metadata about the current test, visible - // to test body and hooks (beforeEach/afterEach). Overwritten on - // each attempt and each test (no delete, mirrors ctx.log/step lifecycle). - ctx.testInfo = { - name: t.name, - file: basename(t.file), - filePath: t.file, - tags: t.tags, - timeout: t.timeout, - attempt, - maxAttempts, - param: t.param, - contexts: Object.fromEntries(testContextNames.map(n => [n, contextSpecs[n]])), - primaryContext: testContextNames[0], - }; - ctx.testResult = null; // set right before afterEach - - let videoFile = null; - if (opts.record) { - videoFile = resolve(reportDir, `${testIdx}-${slugify(t.name)}.mp4`); - try { await browser.startRecording(videoFile, { force: true }); } catch { videoFile = null; } - } - - // Wire up per-test log and step - ctx.log = (...a) => output.push(a.map(String).join(' ')); - ctx.step = async (name, fn) => { - const s = { name, start: Date.now(), status: 'passed', steps: [] }; - currentSteps.push(s); - const prev = currentSteps; - currentSteps = s.steps; - stepIdx++; - const myIdx = stepIdx; - try { - await fn(); - } catch (e) { - s.status = 'failed'; - s.error = e.message; - throw e; - } finally { - s.stop = Date.now(); - currentSteps = prev; - if (opts.screenshot === 'every-step' && s.status === 'passed') { - try { - const slug = slugify(name); - const file = resolve(reportDir, `${testIdx}-${myIdx}-${slug}.png`); - const png = await browser.screenshot(); - writeFileSync(file, png); - s.screenshot = file; - } catch {} - } - } - }; - - // For multi-context tests, expose ctx. per-context wrappers - const scopedKeys = []; - if (t.contexts && t.contexts.length) { - for (const cn of t.contexts) { - ctx[cn] = buildScopedContext(cn); - wrapCloseContextHook(ctx[cn]); - scopedKeys.push(cn); - } - } - - try { - // beforeEach - if (hooks.beforeEach) await hooks.beforeEach(ctx); - // per-test setup - if (t.setup) await t.setup(ctx); - - // Run test with timeout - await Promise.race([ - t.fn(ctx, t.param), - new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout (${t.timeout}ms)`)), t.timeout)), - ]); - - // per-test teardown - if (t.teardown) try { await t.teardown(ctx); } catch {} - // Expose testResult to afterEach (preliminary — full testResult assembled below). - ctx.testResult = { status: 'passed', duration: elapsed(t0), attempts: attempt, error: null, steps }; - // afterEach - if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {} - // Built-in state reset across all contexts the test used - for (const cn of testContextNames) { - try { await browser.setActiveContext(cn); await resetState(ctx); } catch {} - } - for (const k of scopedKeys) delete ctx[k]; - - if (videoFile) { - try { await browser.stopRecording(); } catch {} - } - const dur = elapsed(t0); - testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'passed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: null, screenshot: null, video: videoFile }; - lastError = null; - break; - - } catch (e) { - // Screenshot on failure FIRST — before teardown/afterEach/resetState reset the UI. - // Otherwise the shot captures an empty desktop instead of the failure context. - let shotFile = e.onecError?.screenshot; - if (!shotFile && opts.screenshot !== 'off') { - try { - const png = await browser.screenshot(); - shotFile = resolve(reportDir, `error-${testIdx}-${slugify(t.file.replace(/\.test\.mjs$/, ''))}.png`); - writeFileSync(shotFile, png); - } catch {} - } - - // per-test teardown (always) - if (t.teardown) try { await t.teardown(ctx); } catch {} - // Build the error record once: shared between ctx.testResult (afterEach), lastError - // (retry-loop carry-over and console output), and the final report record. - // onecError carries the structured 1C exception payload (stack, formState, args, ...) - // produced by ACTION_FN wrappers — preserve it in the report. - const errInfo = { message: e.message, step: e.onecError?.step, screenshot: shotFile, onecError: e.onecError }; - ctx.testResult = { status: 'failed', duration: elapsed(t0), attempts: attempt, error: errInfo, steps }; - // afterEach (always) - if (hooks.afterEach) try { await hooks.afterEach(ctx); } catch {} - // Built-in state reset across all contexts the test used - for (const cn of testContextNames) { - try { await browser.setActiveContext(cn); await resetState(ctx); } catch {} - } - for (const k of scopedKeys) delete ctx[k]; - - if (videoFile) { - try { await browser.stopRecording(); } catch {} - } - lastError = errInfo; - const dur = elapsed(t0); - testResult = { name: t.name, file: t.file, tags: t.tags, contexts: testContextNames, severity: t.severity, status: 'failed', duration: dur, attempts: attempt, start: t0, stop: Date.now(), steps, output: output.join('\n'), error: errInfo, screenshot: shotFile, video: videoFile }; - } - } - - results.push(testResult); - - // Console output - if (testResult.status === 'passed') { - passCount++; - W.write(` \u2713 ${t.name} (${testResult.duration}s)\n`); - } else { - failCount++; - W.write(` \u2717 ${t.name} (${testResult.duration}s)\n`); - // Show failed steps - printSteps(W, testResult.steps, ' '); - if (lastError?.message) W.write(` ${lastError.message}\n`); - if (lastError?.screenshot) W.write(` screenshot: ${lastError.screenshot}\n`); - } - - if (opts.bail && testResult.status === 'failed') break; - } - - // afterAll - if (hooks.afterAll) try { await hooks.afterAll(ctx); } catch {} - - } finally { - // Per-context teardown: fire beforeCloseContext for every remaining slot, then close. - // Mirror the `ctx.a.closeContext('b')` invariant: active is some OTHER context while - // closing `name`. We keep the first registered context (the default) as the survivor — - // it stays active, hooks fire against it, the other slots are closed one by one. - // The default itself is closed by disconnect() (no surviving context to switch to). - try { - const remaining = browser.listContexts(); - if (remaining.length > 0) { - const survivor = remaining[0]; - try { await browser.setActiveContext(survivor); } catch {} - for (let i = remaining.length - 1; i >= 1; i--) { - const name = remaining[i]; - if (hooks.beforeCloseContext && hookCtx) { - try { await hooks.beforeCloseContext(hookCtx, name, contextSpecs[name]); } - catch (e) { hookLog(`beforeCloseContext("${name}") threw: ${e.message.split('\n')[0]}`); } - } - try { await browser.closeContext(name); } - catch (e) { hookLog(`closeContext("${name}") failed: ${e.message.split('\n')[0]}`); } - } - // Fire beforeCloseContext for the survivor too — disconnect() actually closes it. - if (hooks.beforeCloseContext && hookCtx) { - try { await hooks.beforeCloseContext(hookCtx, survivor, contextSpecs[survivor]); } - catch (e) { hookLog(`beforeCloseContext("${survivor}") threw: ${e.message.split('\n')[0]}`); } - } - } - } catch (e) { - hookLog(`final teardown loop failed: ${e.message.split('\n')[0]}`); - } - // Disconnect — closes the last remaining context + browser. - try { await browser.disconnect(); } catch {} - // Cleanup: infrastructure hooks (same signature as prepare) - if (hooks.cleanup) try { await hooks.cleanup(hookEnv); } catch {} - } - - const finishedAt = new Date().toISOString(); - const totalDuration = results.reduce((s, r) => s + r.duration, 0); - - // Summary - W.write(`\n${passCount} passed, ${failCount} failed, ${skipCount} skipped (${formatDuration(totalDuration)})\n\n`); - - // JSON report - const report = { - runner: 'web-test', url, startedAt, finishedAt, - duration: totalDuration, - summary: { total: results.length, passed: passCount, failed: failCount, skipped: skipCount }, - tests: results, - }; - out(report); - - if (opts.format === 'allure') { - writeAllure(results, reportDir, severityIndex); - syncAllureExtras(testDir, reportDir); - } else if (opts.format === 'junit') { - writeFileSync(resolve(opts.report), buildJUnit(report, testDir)); - } else if (opts.report) { - writeFileSync(resolve(opts.report), JSON.stringify(report, null, 2)); - } - - if (failCount > 0) process.exit(1); -} - -/** - * Copy any files from `/_allure/` into `reportDir`. Convention for - * Allure customization that doesn't fit inside per-test JSON: - * - `categories.json` — failure classification (regex → bucket) - * - `environment.properties` — values shown in the Environment widget - * - `executor.json` — CI/CD metadata - * Underscored folder mirrors `_hooks.mjs` convention (infra, not a test). - * Silent if folder absent. - */ -function syncAllureExtras(testDir, reportDir) { - const extrasDir = resolve(testDir, '_allure'); - if (!existsSync(extrasDir)) return; - try { - if (!statSync(extrasDir).isDirectory()) return; - } catch { return; } - for (const entry of readdirSync(extrasDir, { withFileTypes: true })) { - if (!entry.isFile()) continue; - try { copyFileSync(resolve(extrasDir, entry.name), resolve(reportDir, entry.name)); } - catch { /* best-effort */ } - } -} - -function writeAllure(results, reportDir, severityIndex) { - for (const tr of results) { - if (tr.status === 'skipped') continue; // Allure ignores skipped without start/stop - const uuid = randomUUID(); - // suite: dirname(t.file) даёт автогруппировку отчёта по подкаталогам. - // Плоский слой тестов в корне группируется под 'root'. - const suite = dirname(tr.file); - const suiteLabel = (suite && suite !== '.') ? suite : 'root'; - const severity = resolveSeverity(tr, severityIndex); - const out = { - uuid, - name: tr.name, - fullName: tr.file, - status: tr.status, - stage: 'finished', - start: tr.start, - stop: tr.stop, - labels: [ - ...(tr.tags || []).map(t => ({ name: 'tag', value: t })), - { name: 'suite', value: suiteLabel }, - { name: 'severity', value: severity }, - ], - steps: (tr.steps || []).map(allureStep), - attachments: [ - ...(tr.screenshot ? [{ name: 'Screenshot on failure', source: basename(tr.screenshot), type: 'image/png' }] : []), - ...(tr.video ? [{ name: 'Video', source: basename(tr.video), type: 'video/mp4' }] : []), - ], - }; - if (tr.status === 'failed' && tr.error) { - // Allure UI shows statusDetails.trace right next to the error message. We compose it - // from the test's log() output plus, when present, the platform 1C stack — so the - // raw call chain is visible without opening attachments. - const traceParts = []; - if (tr.output) traceParts.push(tr.output); - const onecStack = tr.error.onecError?.stack?.raw; - if (onecStack) { - if (traceParts.length) traceParts.push('\n--- 1C stack ---\n'); - traceParts.push(onecStack); - } - out.statusDetails = { message: tr.error.message || '', trace: traceParts.join('') }; - } - writeFileSync(resolve(reportDir, `${uuid}-result.json`), JSON.stringify(out, null, 2)); - } -} - -function allureStep(s) { - const out = { - name: s.name, - status: s.status, - stage: 'finished', - start: s.start, - stop: s.stop, - steps: (s.steps || []).map(allureStep), - }; - if (s.screenshot) { - out.attachments = [{ name: 'Screenshot', source: basename(s.screenshot), type: 'image/png' }]; - } - if (s.status === 'failed' && s.error) { - out.statusDetails = { message: s.error, trace: s.error }; - } - return out; -} - -function xmlEscape(s) { - return String(s == null ? '' : s) - .replace(/&/g, '&').replace(//g, '>') - .replace(/"/g, '"').replace(/'/g, '''); -} - -function buildJUnit(report, testDir) { - const { summary, duration, tests } = report; - const suiteName = relative(process.cwd(), testDir).replace(/\\/g, '/') || '.'; - const lines = ['']; - lines.push(``); - lines.push(` `); - for (const t of tests) { - const attrs = `name="${xmlEscape(t.name)}" classname="${xmlEscape(t.file)}" time="${(t.duration || 0).toFixed(3)}"`; - if (t.status === 'passed') { - lines.push(` `); - } else if (t.status === 'skipped') { - lines.push(` `); - } else { - lines.push(` `); - const msg = t.error?.message || ''; - const trace = t.output || ''; - lines.push(` ${xmlEscape(trace)}`); - if (t.screenshot) lines.push(` screenshot: ${xmlEscape(t.screenshot)}`); - lines.push(` `); - } - } - lines.push(` `); - lines.push(``); - return lines.join('\n'); -} - -function discoverTests(testPath) { - if (testPath.endsWith('.test.mjs')) return existsSync(testPath) ? [testPath] : []; - const files = []; - function walk(dir) { - for (const entry of readdirSync(dir, { withFileTypes: true })) { - if (entry.name.startsWith('_') || entry.name.startsWith('.')) continue; - const full = resolve(dir, entry.name); - if (entry.isDirectory()) walk(full); - else if (entry.name.endsWith('.test.mjs')) files.push(full); - } - } - walk(testPath); - return files.sort(); -} - -async function resetState(ctx) { - try { if (typeof ctx.dismissPendingErrors === 'function') await ctx.dismissPendingErrors(); } catch {} - for (let i = 0; i < 10; i++) { - try { - const state = await ctx.getFormState(); - // form === null means no form open (desktop). form === 0 is a real background form - // 1C exposes in some states — must still close it to fully reset. - if (state.form == null) break; - await ctx.closeForm({ save: false }); - } catch { break; } - } -} - -function printSteps(W, steps, indent) { - for (let i = 0; i < steps.length; i++) { - const s = steps[i]; - const last = i === steps.length - 1; - const prefix = last ? '\u2514' : '\u251C'; - const mark = s.status === 'failed' ? '\u2717 ' : ''; - W.write(`${indent}${prefix} ${mark}${s.name} (${elapsed2(s.start, s.stop)}s)\n`); - if (s.error && s.status === 'failed') { - W.write(`${indent} ${s.error}\n`); - } - if (s.steps.length) printSteps(W, s.steps, indent + ' '); - } -} - -function elapsed2(start, stop) { - return Math.round(((stop || Date.now()) - start) / 100) / 10; -} - -function interpolate(template, params) { - return String(template).replace(/\{(\w+)\}/g, (_, key) => - params[key] !== undefined ? String(params[key]) : `{${key}}`); -} - -function slugify(s) { - return String(s).trim() - .replace(/[\s/\\:*?"<>|]+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') - .slice(0, 60) || 'step'; -} - -function formatDuration(seconds) { - if (seconds < 60) return `${Math.round(seconds * 10) / 10}s`; - const m = Math.floor(seconds / 60); - const s = Math.round((seconds - m * 60) * 10) / 10; - return `${m}m ${s}s`; -} - -// ============================================================ -// Severity (Allure label policy) — constants live at module top. -// ============================================================ - -/** - * Validate config.severity (inverted map: severity → [tags]) at config load time. - * Returns: - * - tagToSeverity: Map (precomputed lookup for the resolver) - * - defaultSeverity: string (validated, defaults to 'normal') - * Throws (via die) on invalid keys, invalid default, or duplicate tag across buckets. - */ -function buildSeverityIndex(config) { - const tagToSeverity = new Map(); - const sev = config.severity || {}; - if (typeof sev !== 'object' || Array.isArray(sev)) { - die(`config.severity must be an object, got ${typeof sev}`); - } - for (const [level, tags] of Object.entries(sev)) { - if (!SEVERITY_LEVELS.includes(level)) { - die(`config.severity: unknown level "${level}". Allowed: ${SEVERITY_LEVELS.join('|')}`); - } - if (!Array.isArray(tags)) { - die(`config.severity.${level} must be an array of tag names, got ${typeof tags}`); - } - for (const tag of tags) { - if (tagToSeverity.has(tag)) { - die(`config.severity: tag "${tag}" listed under both "${tagToSeverity.get(tag)}" and "${level}" — pick one`); - } - tagToSeverity.set(tag, level); - } - } - const def = config.defaultSeverity || 'normal'; - if (!SEVERITY_LEVELS.includes(def)) { - die(`config.defaultSeverity: "${def}" is not a valid level. Allowed: ${SEVERITY_LEVELS.join('|')}`); - } - return { tagToSeverity, defaultSeverity: def }; -} - -/** - * Resolve a test's severity. Precedence: - * 1. explicit `export const severity` from the test module - * 2. max-rank severity found among tags (either standard severity name, or mapped via config) - * 3. defaultSeverity from config (or 'normal' if not set) - * Returns one of SEVERITY_LEVELS. - */ -function resolveSeverity(t, severityIndex) { - if (t.severity) { - if (!SEVERITY_LEVELS.includes(t.severity)) { - // Не валим тест — просто игнорируем некорректное значение, дефолтим. - return severityIndex.defaultSeverity; - } - return t.severity; - } - let best = null; - for (const tag of t.tags || []) { - let candidate = null; - if (SEVERITY_LEVELS.includes(tag)) candidate = tag; - else if (severityIndex.tagToSeverity.has(tag)) candidate = severityIndex.tagToSeverity.get(tag); - if (candidate && (best === null || SEVERITY_RANK[candidate] > SEVERITY_RANK[best])) { - best = candidate; - } - } - return best || severityIndex.defaultSeverity; -} - - -// ============================================================ -// assertions -// ============================================================ - -function createAssertions() { - class AssertionError extends Error { - constructor(msg, actual, expected) { - super(msg); - this.name = 'AssertionError'; - this.actual = actual; - this.expected = expected; - } - } - - return { - ok(value, msg) { - if (!value) throw new AssertionError(msg || `Expected truthy, got ${JSON.stringify(value)}`, value, true); - }, - equal(actual, expected, msg) { - if (actual !== expected) throw new AssertionError(msg || `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, actual, expected); - }, - notEqual(actual, expected, msg) { - if (actual === expected) throw new AssertionError(msg || `Expected not ${JSON.stringify(expected)}`, actual, expected); - }, - deepEqual(actual, expected, msg) { - const a = JSON.stringify(actual), b = JSON.stringify(expected); - if (a !== b) throw new AssertionError(msg || `Deep equal failed:\n actual: ${a}\n expected: ${b}`, actual, expected); - }, - includes(haystack, needle, msg) { - const h = Array.isArray(haystack) ? haystack : String(haystack); - if (!h.includes(needle)) throw new AssertionError(msg || `Expected ${JSON.stringify(h)} to include ${JSON.stringify(needle)}`, haystack, needle); - }, - match(string, regex, msg) { - if (!regex.test(string)) throw new AssertionError(msg || `Expected ${JSON.stringify(string)} to match ${regex}`, string, regex); - }, - async throws(fn, msg) { - try { await fn(); } catch { return; } - throw new AssertionError(msg || 'Expected function to throw'); - }, - // 1C-specific - formHasField(state, fieldName, msg) { - if (!state?.fields?.[fieldName]) throw new AssertionError(msg || `Field "${fieldName}" not found in form. Available: ${Object.keys(state?.fields || {}).join(', ')}`, null, fieldName); - }, - formTitle(state, expected, msg) { - if (!state?.title?.includes(expected)) throw new AssertionError(msg || `Form title "${state?.title}" does not contain "${expected}"`, state?.title, expected); - }, - tableHasRow(table, predicate, msg) { - const rows = table?.rows || []; - let found; - if (typeof predicate === 'function') { - found = rows.some(predicate); - } else { - found = rows.some(r => Object.entries(predicate).every(([k, v]) => r[k] === v)); - } - if (!found) throw new AssertionError(msg || `No row matching predicate in table (${rows.length} rows)`, null, predicate); - }, - tableRowCount(table, expected, msg) { - const actual = table?.rows?.length ?? 0; - if (actual !== expected) throw new AssertionError(msg || `Expected ${expected} rows, got ${actual}`, actual, expected); - }, - noErrors(state, msg) { - if (state?.errors) throw new AssertionError(msg || `Form has errors: ${JSON.stringify(state.errors)}`, state.errors, null); - }, - }; -} - - -// ============================================================ -// helpers -// ============================================================ - -function loadSession() { - if (!existsSync(SESSION_FILE)) { - die('No active session. Run: node src/run.mjs start '); - } - return JSON.parse(readFileSync(SESSION_FILE, 'utf-8')); -} - -function cleanup() { - try { unlinkSync(SESSION_FILE); } catch {} -} - -async function readBody(req) { - const chunks = []; - for await (const chunk of req) chunks.push(chunk); - return Buffer.concat(chunks).toString('utf-8'); -} - -async function readStdin() { - const chunks = []; - for await (const chunk of process.stdin) chunks.push(chunk); - return Buffer.concat(chunks).toString('utf-8'); -} - -function elapsed(t0) { - return Math.round((Date.now() - t0) / 100) / 10; -} - -function json(res, obj, status = 200) { - res.writeHead(status, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(obj, null, 2)); -} - -function out(obj) { - process.stdout.write(JSON.stringify(obj, null, 2) + '\n'); -} - -function die(msg) { - process.stderr.write(msg + '\n'); - process.exit(1); -} - -function usage() { - die(`Usage: node run.mjs [args] - -Commands: - start Launch browser and connect to 1C web client - run Autonomous: connect, execute script, disconnect - exec [options] Execute script (file path or - for stdin) - shot [file] Take screenshot (default: shot.png) - stop Logout and close browser - status Check session status - test [url] Run regression tests (*.test.mjs) - -Options for exec: - --no-record Skip video recording (record() becomes no-op) - -Global options (any command): - --no-preserve-clipboard Don't save/restore OS clipboard around action calls. - Default: on (env: WEB_TEST_PRESERVE_CLIPBOARD=0 to disable globally). - -Options for test: - --tags=smoke,crud Filter tests by tags - --grep=pattern Filter tests by name (regex) - --bail Stop on first failure - --retry=N Retry failed tests N times - --timeout=ms Per-test timeout (default: 30000) - --report=path Write JSON report to file - --report-dir=path Directory for screenshots and other artifacts - --screenshot=mode on-failure (default) | every-step | off - --format=fmt json (default) | allure | junit - --record Record video for each test (mp4 in report-dir) - -- Everything after \`--\` is forwarded to _hooks.mjs - prepare/cleanup as hookArgs (runner does not parse it). - Example: ... tests/web-test/ -- --rebuild-stand`); -} +#!/usr/bin/env node +// web-test run v1.17 — CLI entry-point (распилено по cli/) +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +/** + * CLI runner for 1C web client automation. + * + * Architecture: `start` launches browser + HTTP server in one process. + * `exec`, `shot`, `stop` send requests to the running server. + * + * Usage: + * node src/run.mjs start — launch browser, connect to 1C, serve requests + * node src/run.mjs run — autonomous: connect, execute script, disconnect + * node src/run.mjs exec — run script against existing session + * node src/run.mjs shot [file] — take screenshot + * node src/run.mjs stop — logout + close browser + * node src/run.mjs status — check session + * node src/run.mjs test [url] — run regression tests + * + * Внутренности живут в cli/: util, session, exec-context, server, + * commands/{start,run,exec,shot,stop,status,test}, test-runner/*. + */ +import * as browser from './browser.mjs'; +import { usage } from './cli/util.mjs'; +import { cmdStart } from './cli/commands/start.mjs'; +import { cmdRun } from './cli/commands/run.mjs'; +import { cmdExec } from './cli/commands/exec.mjs'; +import { cmdShot } from './cli/commands/shot.mjs'; +import { cmdStop } from './cli/commands/stop.mjs'; +import { cmdStatus } from './cli/commands/status.mjs'; +import { cmdTest } from './cli/commands/test.mjs'; + +const [,, cmd, ...rawArgs] = process.argv; +const flags = { + noRecord: rawArgs.includes('--no-record'), + execTimeoutMs: parseExecTimeoutMs(rawArgs), +}; +const args = rawArgs.filter(a => !a.startsWith('--')); + +// Clipboard preservation: default ON. Disabled by --no-preserve-clipboard CLI flag +// or WEB_TEST_PRESERVE_CLIPBOARD=0 env. cmdTest may further disable via config. +const preserveClipboard = !rawArgs.includes('--no-preserve-clipboard') + && process.env.WEB_TEST_PRESERVE_CLIPBOARD !== '0'; +browser.setPreserveClipboard(preserveClipboard); + +function parseExecTimeoutMs(argv) { + const DEFAULT_MS = 30 * 60 * 1000; + const flagMs = argv.find(a => a.startsWith('--timeout=')); + if (flagMs) return Math.max(1, Number(flagMs.slice('--timeout='.length))) || DEFAULT_MS; + const flagMin = argv.find(a => a.startsWith('--timeout-min=')); + if (flagMin) return Math.max(1, Number(flagMin.slice('--timeout-min='.length))) * 60 * 1000 || DEFAULT_MS; + const env = process.env.WEB_TEST_EXEC_TIMEOUT_MS; + if (env) return Math.max(1, Number(env)) || DEFAULT_MS; + return DEFAULT_MS; +} + +switch (cmd) { + case 'start': await cmdStart(args[0]); break; + case 'run': await cmdRun(args[0], args[1]); break; + case 'exec': await cmdExec(args[0], flags); break; + case 'shot': await cmdShot(args[0]); break; + case 'stop': await cmdStop(); break; + case 'status': cmdStatus(); break; + case 'test': await cmdTest(rawArgs); break; + default: usage(); +} From 85003782db108282371cc090a420caeb7d1df199 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 19:54:36 +0300 Subject: [PATCH 28/47] =?UTF-8?q?refactor(web-test):=20=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D1=87=D0=B5=D0=BD=D1=8B=20detect-new-form=20=D0=B8?= =?UTF-8?q?=20edit-state=20=D0=B8=D0=B7=20inline=20=D0=B2=20dom/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дедуплицированы 15 копий detect-new-form (13 в row-fill + 2 локальные обёртки в select-value), 6 копий INPUT-focused, 4 проверки calendar/ calculator popup, 1 INPUT-focused-inside-grid. Новое: - dom/forms.mjs: detectNewFormScript(prev, {strict}) — объединяет broad и strict варианты - dom/edit-state.mjs: isInputFocusedScript({allowTextarea}), isInputFocusedInGridScript, findOpenPopupScript - helpers.mjs: переписан detectNewForm на dom-script; добавлены тонкие обёртки isInputFocused, isInputFocusedInGrid, findOpenPopup Метрики row-fill: 1235 → 1065 LOC (−170), inline page.evaluate 47 → 20. Поведение идентично; точечный регресс зелёный (02/03/05/06/10/16). 04-selectvalue auto-history шаг — pre-existing baseline issue (state- driven, не связан с S1, воспроизводится на HEAD). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/dom.mjs | 9 +- .../web-test/scripts/dom/edit-state.mjs | 53 ++++ .claude/skills/web-test/scripts/dom/forms.mjs | 28 ++- .../web-test/scripts/engine/core/helpers.mjs | 49 ++-- .../scripts/engine/forms/select-value.mjs | 17 +- .../scripts/engine/table/row-fill.mjs | 226 +++--------------- 6 files changed, 154 insertions(+), 228 deletions(-) create mode 100644 .claude/skills/web-test/scripts/dom/edit-state.mjs diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index f0238cf1..a404cb18 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.8 — facade re-exporting injectable DOM scripts from dom/ +// web-test dom v1.9 — 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. @@ -15,8 +15,15 @@ export { findClickTargetScript, findFieldButtonScript, resolveFieldsScript, + detectNewFormScript, } from './dom/forms.mjs'; +export { + isInputFocusedScript, + isInputFocusedInGridScript, + findOpenPopupScript, +} from './dom/edit-state.mjs'; + export { getFormStateScript } from './dom/form-state.mjs'; export { diff --git a/.claude/skills/web-test/scripts/dom/edit-state.mjs b/.claude/skills/web-test/scripts/dom/edit-state.mjs new file mode 100644 index 00000000..d5666950 --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/edit-state.mjs @@ -0,0 +1,53 @@ +// web-test dom/edit-state v1.0 — focus and popup detection inside the 1C web client +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +/** + * Is the currently focused element an INPUT (optionally TEXTAREA too)? + * Returns boolean. + * + * @param {object} [opts] + * @param {boolean} [opts.allowTextarea=false] — also return true for TEXTAREA. + */ +export function isInputFocusedScript({ allowTextarea = false } = {}) { + const cond = allowTextarea + ? `f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'` + : `f.tagName === 'INPUT'`; + return `(() => { + const f = document.activeElement; + return !!(f && (${cond})); + })()`; +} + +/** + * Is the currently focused INPUT/TEXTAREA inside a `.grid` ancestor? + * Used to verify grid edit-mode (active cell editor). + * Returns boolean. + */ +export function isInputFocusedInGridScript() { + return `(() => { + const f = document.activeElement; + if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; + let n = f; + while (n) { + if (n.classList?.contains('grid')) return true; + n = n.parentElement; + } + return false; + })()`; +} + +/** + * Is a calculator (`.calculate`) or calendar (`.frameCalendar`) popup visible? + * Returns `'calculator' | 'calendar' | null`. + * + * For the "popup gone" check, callers use: `!await findOpenPopup()`. + */ +export function findOpenPopupScript() { + return `(() => { + const calc = document.querySelector('.calculate'); + if (calc && calc.offsetWidth > 0) return 'calculator'; + const cal = document.querySelector('.frameCalendar'); + if (cal && cal.offsetWidth > 0) return 'calendar'; + return null; + })()`; +} diff --git a/.claude/skills/web-test/scripts/dom/forms.mjs b/.claude/skills/web-test/scripts/dom/forms.mjs index fcbe9af0..6216e2a9 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.0 — form detection, content read, click-target/field-button resolution +// web-test dom/forms v1.1 — 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 } from './_shared.mjs'; @@ -396,3 +396,29 @@ export function resolveFieldsScript(formNum, fields) { return results; })()`; } + +/** + * Detect a new form opened above `prevFormNum`. Two modes: + * default (broad) — counts any visible `[id]` element; finds dialogs whose + * `a.press` buttons have empty IDs. Used by selectValue / fillTableRow. + * `{ strict: true }` — only counts visible interactive elements + * (`input.editInput[id], a.press[id]`); used by fillReferenceField. + * + * Returns the highest new form number or `null`. + */ +export function detectNewFormScript(prevFormNum, { strict = false } = {}) { + const selector = strict ? 'input.editInput[id], a.press[id]' : '[id]'; + const visibleCheck = strict + ? 'el.offsetWidth === 0' + : 'el.offsetWidth === 0 && el.offsetHeight === 0'; + return `(() => { + const forms = {}; + document.querySelectorAll(${JSON.stringify(selector)}).forEach(el => { + if (${visibleCheck}) return; + const m = el.id.match(/^form(\\d+)_/); + if (m) forms[m[1]] = true; + }); + const nums = Object.keys(forms).map(Number).filter(n => n > ${prevFormNum}); + return nums.length > 0 ? Math.max(...nums) : null; + })()`; +} diff --git a/.claude/skills/web-test/scripts/engine/core/helpers.mjs b/.claude/skills/web-test/scripts/engine/core/helpers.mjs index e7d626f8..98344511 100644 --- a/.claude/skills/web-test/scripts/engine/core/helpers.mjs +++ b/.claude/skills/web-test/scripts/engine/core/helpers.mjs @@ -1,10 +1,16 @@ -// web-test core/helpers v1.17 — private, cross-cutting helpers used by the +// web-test core/helpers v1.18 — private, cross-cutting helpers used by the // public action functions (clickElement/fillFields/selectValue/etc). // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page } from './state.mjs'; import { dismissPendingErrors, checkForErrors } from './errors.mjs'; import { getFormState } from '../forms/state.mjs'; +import { + detectNewFormScript, + isInputFocusedScript, + isInputFocusedInGridScript, + findOpenPopupScript, +} from '../../dom.mjs'; /** * page.click with the standard "intercepts pointer events" retry ladder: @@ -69,20 +75,33 @@ export async function findFieldInputId(formNum, fieldName) { * @returns {Promise} new form number or null */ export async function detectNewForm(prevFormNum, { strict = false } = {}) { - const selector = strict ? 'input.editInput[id], a.press[id]' : '[id]'; - const visibleCheck = strict - ? 'el.offsetWidth === 0' - : 'el.offsetWidth === 0 && el.offsetHeight === 0'; - return page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll(${JSON.stringify(selector)}).forEach(el => { - if (${visibleCheck}) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${prevFormNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + return page.evaluate(detectNewFormScript(prevFormNum, { strict })); +} + +/** + * Thin wrapper: is the currently focused element an INPUT (or TEXTAREA)? + * + * @param {object} [opts] + * @param {boolean} [opts.allowTextarea=false] + */ +export async function isInputFocused({ allowTextarea = false } = {}) { + return page.evaluate(isInputFocusedScript({ allowTextarea })); +} + +/** + * Thin wrapper: is the currently focused INPUT/TEXTAREA inside a `.grid`? + * Used to verify grid edit-mode. + */ +export async function isInputFocusedInGrid() { + return page.evaluate(isInputFocusedInGridScript()); +} + +/** + * Thin wrapper: is calculator (`.calculate`) or calendar (`.frameCalendar`) + * popup visible? Returns `'calculator' | 'calendar' | null`. + */ +export async function findOpenPopup() { + return page.evaluate(findOpenPopupScript()); } /** diff --git a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs index fa3c8ff4..6a82bcbc 100644 --- a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs @@ -14,8 +14,8 @@ import { highlight, unhighlight } from '../recording/highlight.mjs'; import { safeClick, findFieldInputId, readEdd, detectNewForm as helperDetectNewForm, -} from '../core/helpers.mjs'; -import { pasteText } from '../core/clipboard.mjs'; +} from '../core/helpers.mjs'; +import { pasteText } from '../core/clipboard.mjs'; import { getFormState } from './state.mjs'; /** @@ -757,18 +757,9 @@ export async function selectValue(fieldName, searchText, { type } = {}) { if (!isChecked) { await page.click(cbSel); await waitForStable(); } } - // Helper: detect selection form (form number > formNum) + // Helper: detect selection form (form number > formNum, strict mode) async function detectSelectionForm() { - return page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { - if (el.offsetWidth === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + return helperDetectNewForm(formNum, { strict: true }); } // detectNewForm is hoisted at the top of selectValue (see above). diff --git a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs index 30c7c18f..eda8bf24 100644 --- a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs +++ b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs @@ -13,13 +13,14 @@ import { highlight, unhighlight } from '../recording/highlight.mjs'; import { safeClick, findFieldInputId, detectNewForm as helperDetectNewForm, + isInputFocused, isInputFocusedInGrid, findOpenPopup, } from '../core/helpers.mjs'; import { clickElement } from '../core/click.mjs'; import { pickFromSelectionForm, isTypeDialog, pickFromTypeDialog, fillReferenceField, selectValue, -} from '../forms/select-value.mjs'; -import { pasteText } from '../core/clipboard.mjs'; +} from '../forms/select-value.mjs'; +import { pasteText } from '../core/clipboard.mjs'; import { getFormState } from '../forms/state.mjs'; /** @@ -199,16 +200,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { if (firstVal0 === '') { await page.waitForTimeout(500); // Check if click opened a selection form — close it first - let openedForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + let openedForm = await helperDetectNewForm(formNum); if (openedForm !== null) { await page.keyboard.press('Escape'); await page.waitForTimeout(500); @@ -216,16 +208,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { // No form opened — need to enter edit mode first (dblclick), then close any form that opens await page.mouse.dblclick(cellCoords.x, cellCoords.y); await page.waitForTimeout(500); - openedForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + openedForm = await helperDetectNewForm(formNum); if (openedForm !== null) { await page.keyboard.press('Escape'); await page.waitForTimeout(500); @@ -279,21 +262,9 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { let directEditForm = null; for (let dw = 0; dw < 4; dw++) { await page.waitForTimeout(150); - inEdit = await page.evaluate(`(() => { - const f = document.activeElement; - return f && f.tagName === 'INPUT'; - })()`); + inEdit = await isInputFocused(); if (inEdit) break; - directEditForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + directEditForm = await helperDetectNewForm(formNum); if (directEditForm !== null) break; } // Click didn't enter edit — try dblclick (works for flat grids) @@ -301,21 +272,9 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await page.mouse.dblclick(cellCoords.x, cellCoords.y); for (let dw = 0; dw < 4; dw++) { await page.waitForTimeout(150); - inEdit = await page.evaluate(`(() => { - const f = document.activeElement; - return f && f.tagName === 'INPUT'; - })()`); + inEdit = await isInputFocused(); if (inEdit) break; - directEditForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + directEditForm = await helperDetectNewForm(formNum); if (directEditForm !== null) break; } } @@ -324,21 +283,9 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await page.keyboard.press('F4'); for (let fw = 0; fw < 8; fw++) { await page.waitForTimeout(200); - inEdit = await page.evaluate(`(() => { - const f = document.activeElement; - return f && f.tagName === 'INPUT'; - })()`); + inEdit = await isInputFocused(); if (inEdit) break; - directEditForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + directEditForm = await helperDetectNewForm(formNum); if (directEditForm !== null) break; } } @@ -356,16 +303,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await page.keyboard.press('F4'); for (let fw = 0; fw < 8; fw++) { await page.waitForTimeout(200); - directEditForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + directEditForm = await helperDetectNewForm(formNum); if (directEditForm !== null) break; } // If F4 didn't open a selection form, fall through to Tab loop @@ -394,16 +332,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await pickFromTypeDialog(selForm, info.type); await waitForStable(selForm); // After type selection, detect the actual selection form - selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + selForm = await helperDetectNewForm(formNum); if (selForm === null) { return { field: key, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` }; } @@ -490,23 +419,9 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await page.mouse.dblclick(nextCoords.x, nextCoords.y); await page.waitForTimeout(300); // Check if dblclick entered INPUT mode (plain text/numeric field) — before F4 which may open calculator - const inInputAfterDblclick = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; - let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } - return false; - })()`); + const inInputAfterDblclick = await isInputFocusedInGrid(); // Also check if a selection form already appeared - let selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + let selForm = await helperDetectNewForm(formNum); if (selForm === null && inInputAfterDblclick) { // Plain text/numeric field — fill via clipboard paste await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] }); @@ -530,16 +445,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { if (attempt === 1) await page.keyboard.press('F4'); // F4 fallback for (let sw = 0; sw < 6; sw++) { await page.waitForTimeout(200); - selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + selForm = await helperDetectNewForm(formNum); if (selForm !== null) break; } } @@ -744,44 +650,20 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { let typeForm = null; for (let tw = 0; tw < 6; tw++) { await page.waitForTimeout(200); - typeForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + typeForm = await helperDetectNewForm(formNum); if (typeForm !== null) break; } if (typeForm !== null && await isTypeDialog(typeForm)) { await pickFromTypeDialog(typeForm, info.type); await waitForStable(typeForm); // After type selection, check if a selection form opened (ref types) - const selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + const selForm = await helperDetectNewForm(formNum); if (selForm === null) { // Primitive type — poll for calculator/calendar popup or settle on INPUT let hasPopup = null; for (let pw = 0; pw < 5; pw++) { await page.waitForTimeout(200); - hasPopup = await page.evaluate(`(() => { - const calc = document.querySelector('.calculate'); - if (calc && calc.offsetWidth > 0) return 'calculator'; - const cal = document.querySelector('.frameCalendar'); - if (cal && cal.offsetWidth > 0) return 'calendar'; - return null; - })()`); + hasPopup = await findOpenPopup(); if (hasPopup) break; } if (hasPopup) { @@ -789,21 +671,11 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { // Poll for popup to disappear for (let dw = 0; dw < 4; dw++) { await page.waitForTimeout(150); - const gone = await page.evaluate(`(() => { - const calc = document.querySelector('.calculate'); - if (calc && calc.offsetWidth > 0) return false; - const cal = document.querySelector('.frameCalendar'); - if (cal && cal.offsetWidth > 0) return false; - return true; - })()`); - if (gone) break; + if (!(await findOpenPopup())) break; } } // Ensure we are in an editable INPUT for this cell - const inInput = await page.evaluate(`(() => { - const f = document.activeElement; - return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); - })()`); + const inInput = await isInputFocused({ allowTextarea: true }); if (!inInput) { const cellRect = await page.evaluate(`(() => { const el = document.getElementById(${JSON.stringify(cell.id)}); @@ -816,11 +688,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { // Poll for INPUT focus for (let fw = 0; fw < 4; fw++) { await page.waitForTimeout(150); - const ok = await page.evaluate(`(() => { - const f = document.activeElement; - return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); - })()`); - if (ok) break; + if (await isInputFocused({ allowTextarea: true })) break; } } } @@ -982,16 +850,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { if (clickedShowAll) { await waitForStable(formNum); // Check if selection form opened - const selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('input.editInput[id], a.press[id]').forEach(el => { - if (el.offsetWidth === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + const selForm = await helperDetectNewForm(formNum, { strict: true }); if (selForm !== null) { const pickResult = await pickFromSelectionForm(selForm, matchedKey, text, formNum); @@ -1037,48 +896,23 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await pickFromTypeDialog(newForm, info.type); await waitForStable(newForm); // After type selection, the actual selection form should open - const selForm = await page.evaluate(`(() => { - const forms = {}; - document.querySelectorAll('[id]').forEach(el => { - if (el.offsetWidth === 0 && el.offsetHeight === 0) return; - const m = el.id.match(/^form(\\d+)_/); - if (m) forms[m[1]] = true; - }); - const nums = Object.keys(forms).map(Number).filter(n => n > ${formNum}); - return nums.length > 0 ? Math.max(...nums) : null; - })()`); + const selForm = await helperDetectNewForm(formNum); if (selForm === null) { // Primitive type — poll for calculator/calendar popup or settle on INPUT let hasPopup = null; for (let pw = 0; pw < 5; pw++) { await page.waitForTimeout(200); - hasPopup = await page.evaluate(`(() => { - const calc = document.querySelector('.calculate'); - if (calc && calc.offsetWidth > 0) return 'calculator'; - const cal = document.querySelector('.frameCalendar'); - if (cal && cal.offsetWidth > 0) return 'calendar'; - return null; - })()`); + hasPopup = await findOpenPopup(); if (hasPopup) break; } if (hasPopup) { await page.keyboard.press('Escape'); for (let dw = 0; dw < 4; dw++) { await page.waitForTimeout(150); - const gone = await page.evaluate(`(() => { - const calc = document.querySelector('.calculate'); - if (calc && calc.offsetWidth > 0) return false; - const cal = document.querySelector('.frameCalendar'); - if (cal && cal.offsetWidth > 0) return false; - return true; - })()`); - if (gone) break; + if (!(await findOpenPopup())) break; } } - const inInput = await page.evaluate(`(() => { - const f = document.activeElement; - return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); - })()`); + const inInput = await isInputFocused({ allowTextarea: true }); if (!inInput) { const cellRect = await page.evaluate(`(() => { const el = document.getElementById(${JSON.stringify(cell.id)}); @@ -1090,11 +924,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await page.mouse.dblclick(cellRect.x, cellRect.y); for (let fw = 0; fw < 4; fw++) { await page.waitForTimeout(150); - const ok = await page.evaluate(`(() => { - const f = document.activeElement; - return f && (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA'); - })()`); - if (ok) break; + if (await isInputFocused({ allowTextarea: true })) break; } } } From 7f7ab2f217dcf5be9a96cb0c12132e605f1257ea Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 20:16:23 +0300 Subject: [PATCH 29/47] =?UTF-8?q?refactor(web-test):=20=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D1=87=D0=B5=D0=BD=D1=8B=20DOM-=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D0=B8=D0=BF=D1=82=D1=8B=20filter.mjs=20=D0=B2=20dom/filter.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все 17 inline page.evaluate в engine/table/filter.mjs вынесены в именованные dom-генераторы. Engine-модуль стал чистым orchestrator-ом. Новое в dom/forms.mjs (shared с будущим S3 select-value): - findSearchInputScript(formNum) — поиск SearchString/ПоискаСтроки input - findNamedButtonScript(text) — кнопка a.press по innerText (Найти, OK) - findCompareTypeRadioScript(form, idx) — радио CompareType#N#radio - isFormVisibleScript(form) — есть ли видимые элементы form{N} Новое в dom/filter.mjs: - findFirstGridCellCoordsScript — координаты первой клетки грида - findColumnFirstCellCoordsScript — клетка по имени колонки (fuzzy header match с needDlb-fallback) - readFieldSelectorInfoScript — FieldSelector value + DLB coords - pickFieldInSelectorDropdownScript — выбор поля в FieldSelector DLB-edd - readFilterDialogInfoScript — Pattern id+value+isDate+isRef - findFilterBadgeCloseScript — × badge по имени поля - findFirstFilterBadgeCloseScript — × первого видимого badge (для clear-all) Попутно: добавлен импорт readSubmenuScript (был pre-existing broken import в Еще-fallback ветке Alt+F). Метрики filter.mjs: 390 → 256 LOC (−134, −34%), inline page.evaluate 17 → 0. Регресс 09-filter / 02-crud / 05-table — зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/dom.mjs | 16 +- .../skills/web-test/scripts/dom/filter.mjs | 187 +++++ .claude/skills/web-test/scripts/dom/forms.mjs | 63 +- .../web-test/scripts/engine/table/filter.mjs | 646 +++++++----------- 4 files changed, 520 insertions(+), 392 deletions(-) create mode 100644 .claude/skills/web-test/scripts/dom/filter.mjs diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index a404cb18..1bfa9f26 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.9 — facade re-exporting injectable DOM scripts from dom/ +// web-test dom v1.10 — 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. @@ -16,8 +16,22 @@ export { findFieldButtonScript, resolveFieldsScript, detectNewFormScript, + findSearchInputScript, + findNamedButtonScript, + findCompareTypeRadioScript, + isFormVisibleScript, } from './dom/forms.mjs'; +export { + findFirstGridCellCoordsScript, + findColumnFirstCellCoordsScript, + readFieldSelectorInfoScript, + pickFieldInSelectorDropdownScript, + readFilterDialogInfoScript, + findFilterBadgeCloseScript, + findFirstFilterBadgeCloseScript, +} from './dom/filter.mjs'; + export { isInputFocusedScript, isInputFocusedInGridScript, diff --git a/.claude/skills/web-test/scripts/dom/filter.mjs b/.claude/skills/web-test/scripts/dom/filter.mjs new file mode 100644 index 00000000..043642c3 --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/filter.mjs @@ -0,0 +1,187 @@ +// web-test dom/filter v1.0 — DOM scripts for filterList / unfilterList +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +/** + * Find the first grid cell on the form and return its center coords. + * Used as a fallback target for Alt+F when there's no search input. + * + * Returns `{ x, y } | null`. + */ +export function findFirstGridCellCoordsScript(formNum) { + return `(() => { + const p = 'form${formNum}_'; + const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0); + if (!grid) return null; + const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; + if (!rows.length) return null; + const cells = [...rows[0].querySelectorAll('.gridBox')]; + if (!cells.length) return null; + const r = cells[0].getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`; +} + +/** + * Find the grid cell of the first row in the column whose header text matches `field` + * (fuzzy: exact → startsWith → includes; normalizes ё/е and NBSP). + * + * If the column isn't in the grid, returns coords of the first cell + `needDlb: true` + * so the caller can use DLB to switch FieldSelector after opening the dialog. + * + * Returns: + * - `{ x, y, needDlb? } ` — coords to click (advanced search target) + * - `{ error }` — `'no_grid' | 'no_rows' | 'no_cells' | 'cell_not_found'` + */ +export function findColumnFirstCellCoordsScript(formNum, field) { + return `(() => { + const p = 'form${formNum}_'; + const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] + .find(g => g.offsetWidth > 0); + if (!grid) return { error: 'no_grid' }; + const targetField = ${JSON.stringify(field)}; + const headers = [...grid.querySelectorAll('.gridHead .gridBox')]; + let colIndex = -1; + let startsWithIdx = -1; + let includesIdx = -1; + for (let i = 0; i < headers.length; i++) { + const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' '); + if (!t) continue; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase()); + if (tl === fl) { colIndex = i; break; } + if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; } + else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; } + } + if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx; + const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; + if (!rows.length) return { error: 'no_rows' }; + if (colIndex < 0) { + const cells = [...rows[0].querySelectorAll('.gridBox')]; + if (!cells.length) return { error: 'no_cells' }; + const r = cells[0].getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true }; + } + const cells = [...rows[0].querySelectorAll('.gridBox')]; + if (colIndex >= cells.length) return { error: 'cell_not_found' }; + const r = cells[colIndex].getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`; +} + +/** + * Read FieldSelector input + its DLB button coords on the advanced search dialog. + * Returns `{ current, dlbX, dlbY }` (zero coords if DLB not visible). + */ +export function readFieldSelectorInfoScript(dialogForm) { + return `(() => { + const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; + const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); + const dlb = document.getElementById(p + 'FieldSelector_DLB'); + return { + current: fsInput?.value?.trim() || '', + dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0, + dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0 + }; + })()`; +} + +/** + * Pick a field name in the FieldSelector EDD dropdown (fuzzy: exact → includes, + * normalizes ё/е and NBSP). + * + * Returns: + * - `{ x, y, name }` — coords + matched name to click + * - `{ error, available? }` — `'no_dropdown'` or `'field_not_found'` with list of available names + */ +export function pickFieldInSelectorDropdownScript(field) { + return `(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' }; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const target = ny(${JSON.stringify(field.toLowerCase())}); + const items = [...edd.querySelectorAll('div')].filter(el => + el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n')); + const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target) + || items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target)); + if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) }; + const r = match.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() }; + })()`; +} + +/** + * Read advanced search dialog state — FieldSelector value, Pattern input id+value, + * and field type flags (isDate via iCalendB button, isRef via iDLB button on Pattern). + * + * Returns `{ fieldSelector, patternValue, patternId, isDate, isRef }`. + */ +export function readFilterDialogInfoScript(dialogForm) { + return `(() => { + const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; + const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); + const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); + const ptLabel = ptInput?.closest('label'); + const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : []; + const isDate = btns.some(c => c.includes('iCalendB')); + const isRef = !isDate && btns.some(c => c.includes('iDLB')); + return { + fieldSelector: fsInput?.value?.trim() || '', + patternValue: ptInput?.value?.trim() || '', + patternId: ptInput?.id || '', + isDate, + isRef + }; + })()`; +} + +/** + * Find the × close button on the filter badge whose title matches `field` + * (exact → includes; normalizes ё/е and NBSP). + * + * Returns: + * - `{ x, y, field }` — coords + actual field title from the badge + * - `{ error, available }` — `'not_found'` with list of available badge titles + */ +export function findFilterBadgeCloseScript(formNum, field) { + return `(() => { + const p = 'form${formNum}_'; + const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || ''; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const target = ny(${JSON.stringify(field.toLowerCase())}); + const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0); + for (const item of items) { + const titleEl = item.querySelector('.trainName'); + const title = ny(norm(titleEl?.innerText).toLowerCase()); + if (title === target || title.includes(target)) { + const close = item.querySelector('.trainClose'); + if (close) { + const r = close.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) }; + } + } + } + const available = items.map(item => norm(item.querySelector('.trainName')?.innerText)); + return { error: 'not_found', available }; + })()`; +} + +/** + * Find the × close button on the FIRST visible filter badge (for clear-all loop). + * Returns `{ x, y } | null`. + */ +export function findFirstFilterBadgeCloseScript(formNum) { + return `(() => { + const p = 'form${formNum}_'; + const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')] + .find(el => el.offsetWidth > 0); + if (!item) return null; + const close = item.querySelector('.trainClose'); + if (!close) return null; + const r = close.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`; +} diff --git a/.claude/skills/web-test/scripts/dom/forms.mjs b/.claude/skills/web-test/scripts/dom/forms.mjs index 6216e2a9..64a92f31 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.1 — form detection, content read, click-target/field-button resolution +// web-test dom/forms v1.2 — 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 } from './_shared.mjs'; @@ -422,3 +422,64 @@ export function detectNewFormScript(prevFormNum, { strict = false } = {}) { return nums.length > 0 ? Math.max(...nums) : null; })()`; } + +/** + * Find the search input on a list form (matches `SearchString` / `ПоискаСтроки` id). + * Returns `{ id, value } | null`. + */ +export function findSearchInputScript(formNum) { + return `(() => { + const p = 'form${formNum}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); + return el ? { id: el.id, value: el.value || '' } : null; + })()`; +} + +/** + * Find a visible `a.press` button by its exact innerText (after trim). + * Returns `{ x, y } | null` for `page.mouse.click(x, y)`. + * + * Used for modal dialog buttons (Найти, OK) where page.click may be blocked. + */ +export function findNamedButtonScript(buttonText) { + return `(() => { + const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); + const btn = btns.find(el => el.innerText?.trim() === ${JSON.stringify(buttonText)}); + if (!btn) return null; + const r = btn.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`; +} + +/** + * Find a CompareType radio button by index (1 = "contains", 2 = "exact", etc.) + * on a search/filter dialog. + * + * Returns: + * - `{ already: true }` — the group is disabled OR the radio is already selected + * - `{ x, y } | null` — coords to click, or null if radio not present + */ +export function findCompareTypeRadioScript(dialogForm, radioIndex) { + return `(() => { + const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; + const group = document.getElementById(p + 'CompareType'); + if (group && group.classList.contains('disabled')) return { already: true }; + const el = document.getElementById(p + 'CompareType#' + ${JSON.stringify(String(radioIndex))} + '#radio'); + if (!el || el.offsetWidth === 0) return null; + if (el.classList.contains('select')) return { already: true }; + const r = el.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`; +} + +/** + * Is any element of `form{dialogForm}_` currently visible? + * Used to poll dialog dismissal after Escape. + */ +export function isFormVisibleScript(dialogForm) { + return `(() => { + const p = 'form${dialogForm}_'; + return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); + })()`; +} diff --git a/.claude/skills/web-test/scripts/engine/table/filter.mjs b/.claude/skills/web-test/scripts/engine/table/filter.mjs index f73d4910..04a227ef 100644 --- a/.claude/skills/web-test/scripts/engine/table/filter.mjs +++ b/.claude/skills/web-test/scripts/engine/table/filter.mjs @@ -1,390 +1,256 @@ -// web-test table/filter v1.17 — filterList / unfilterList — simple search + advanced-column filter badges. -// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills - -import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs'; -import { detectFormScript, resolveGridScript } 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 { safeClick } from '../core/helpers.mjs'; -import { selectValue, fillReferenceField } from '../forms/select-value.mjs'; -import { pasteText } from '../core/clipboard.mjs'; -import { getFormState } from '../forms/state.mjs'; -import { clickElement } from '../core/click.mjs'; - -/** - * Filter the current list by field value, or search via search bar. - * - * Without field: simple search via the search bar (filters by all columns, no badge). - * With field: advanced search — clicks target column cell to auto-populate FieldSelector, - * opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge. - * Handles text, reference (with Tab autocomplete), and date fields automatically. - * Multiple filters can be chained by calling filterList multiple times. - * - * @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016") - * @param {object} [opts] - * @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата") - * @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact) - */ -export async function filterList(text, { field, exact } = {}) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('filterList: no form found'); - - if (!field) { - // --- Simple search: fill search input + Enter --- - const searchId = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); - return el ? el.id : null; - })()`); - - if (searchId) { - await page.click(`[id="${searchId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await pasteText(text); - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await waitForStable(formNum); - - const state = await getFormState(); - state.filtered = { type: 'search', text }; - return state; - } - - // No search input — Ctrl+F opens advanced search on such forms. - // Click first grid cell then fall through to advanced search path below. - const firstCell = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] - .find(g => g.offsetWidth > 0); - if (!grid) return null; - const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; - if (!rows.length) return null; - const cells = [...rows[0].querySelectorAll('.gridBox')]; - if (!cells.length) return null; - const r = cells[0].getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (!firstCell) throw new Error('filterList: no search input and no grid found on this form'); - await page.mouse.click(firstCell.x, firstCell.y); - await page.waitForTimeout(300); - field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected) - } - - // --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти --- - // Clicking a cell in the target column makes it active, so when Alt+F opens the - // advanced search dialog, FieldSelector is auto-populated with the correct field name. - // This avoids changing FieldSelector programmatically (which can cause errors). - const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim()); - - // 1. Click a cell in the target column to activate it (auto-populates FieldSelector). - // If the column isn't visible in the grid, click any cell and use DLB fallback later. - let needDlb = false; - const gridEl = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const grid = [...document.querySelectorAll('[id^="' + p + '"].grid, [id^="' + p + '"] .grid')] - .find(g => g.offsetWidth > 0); - if (!grid) return { error: 'no_grid' }; - const targetField = ${JSON.stringify(field)}; - const headers = [...grid.querySelectorAll('.gridHead .gridBox')]; - let colIndex = -1; - let startsWithIdx = -1; - let includesIdx = -1; - for (let i = 0; i < headers.length; i++) { - const t = headers[i].innerText?.trim().replace(/\\u00a0/g, ' '); - if (!t) continue; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const tl = ny(t.toLowerCase()), fl = ny(targetField.toLowerCase()); - if (tl === fl) { colIndex = i; break; } - if (startsWithIdx < 0 && tl.startsWith(fl)) { startsWithIdx = i; } - else if (includesIdx < 0 && tl.includes(fl)) { includesIdx = i; } - } - if (colIndex < 0) colIndex = startsWithIdx >= 0 ? startsWithIdx : includesIdx; - const rows = [...grid.querySelectorAll('.gridBody .gridLine')]; - if (!rows.length) return { error: 'no_rows' }; - if (colIndex < 0) { - // Column not in grid — click first cell of first row, will use DLB to change field - const cells = [...rows[0].querySelectorAll('.gridBox')]; - if (!cells.length) return { error: 'no_cells' }; - const r = cells[0].getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), needDlb: true }; - } - const cells = [...rows[0].querySelectorAll('.gridBox')]; - if (colIndex >= cells.length) return { error: 'cell_not_found' }; - const r = cells[colIndex].getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`); - needDlb = !!gridEl.needDlb; - await page.mouse.click(gridEl.x, gridEl.y); - await page.waitForTimeout(500); - - // 2. Open advanced search dialog via Alt+F (with fallback to Еще menu) - await page.keyboard.press('Alt+f'); - await page.waitForTimeout(2000); - - let dialogForm = await page.evaluate(detectFormScript()); - if (dialogForm === formNum) { - // Alt+F didn't open dialog — fallback to Еще → Расширенный поиск - await clickElement('Еще'); - await page.waitForTimeout(500); - const menu = await page.evaluate(readSubmenuScript()); - const searchItem = Array.isArray(menu) && menu.find(i => - i.name.replace(/\u00a0/g, ' ').toLowerCase().includes('расширенный поиск')); - if (!searchItem) { - await page.keyboard.press('Escape'); - throw new Error('filterList: advanced search dialog could not be opened'); - } - await page.mouse.click(searchItem.x, searchItem.y); - await page.waitForTimeout(2000); - dialogForm = await page.evaluate(detectFormScript()); - if (dialogForm === formNum) { - throw new Error('filterList: advanced search dialog did not open'); - } - } - - // 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown - // Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field) - if (needDlb && field) { - const fsInfo = await page.evaluate(`(() => { - const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; - const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); - const dlb = document.getElementById(p + 'FieldSelector_DLB'); - return { - current: fsInput?.value?.trim() || '', - dlbX: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().x + dlb.getBoundingClientRect().width / 2) : 0, - dlbY: dlb && dlb.offsetWidth > 0 ? Math.round(dlb.getBoundingClientRect().y + dlb.getBoundingClientRect().height / 2) : 0 - }; - })()`); - - if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) { - await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY); - await page.waitForTimeout(1500); - - const ddResult = await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return { error: 'no_dropdown' }; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const target = ny(${JSON.stringify(field.toLowerCase())}); - const items = [...edd.querySelectorAll('div')].filter(el => - el.offsetWidth > 0 && el.innerText?.trim() && !el.innerText.includes('\\n')); - const match = items.find(el => ny(el.innerText.trim().toLowerCase()) === target) - || items.find(el => ny(el.innerText.trim().toLowerCase()).includes(target)); - if (!match) return { error: 'field_not_found', available: items.map(el => el.innerText.trim()) }; - const r = match.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), name: match.innerText.trim() }; - })()`); - - if (ddResult.error) { - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`); - } - await page.mouse.click(ddResult.x, ddResult.y); - await page.waitForTimeout(3000); - } - } - - // 3. Read dialog state and fill Pattern - // Detect field type by Pattern's sibling buttons: - // - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value) - // - iDLB on Pattern → reference field (paste + Tab for autocomplete) - // - neither → plain text field (just paste) - const dialogInfo = await page.evaluate(`(() => { - const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; - const fsInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /FieldSelector/i.test(el.id)); - const ptInput = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); - const ptLabel = ptInput?.closest('label'); - const btns = ptLabel ? [...ptLabel.querySelectorAll('span.btn')].map(b => b.className) : []; - const isDate = btns.some(c => c.includes('iCalendB')); - const isRef = !isDate && btns.some(c => c.includes('iDLB')); - return { - fieldSelector: fsInput?.value?.trim() || '', - patternValue: ptInput?.value?.trim() || '', - patternId: ptInput?.id || '', - isDate, - isRef - }; - })()`); - - if (dialogInfo.isDate) { - // Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste) - if (isDateValue && dialogInfo.patternValue !== text.trim()) { - await page.click(`[id="${dialogInfo.patternId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Home'); - await page.waitForTimeout(100); - await page.keyboard.press('Shift+End'); - await page.waitForTimeout(100); - await pasteText(text); - await page.waitForTimeout(500); - } - } else { - // Text or reference field: fill Pattern via clipboard paste - await page.click(`[id="${dialogInfo.patternId}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await pasteText(text); - await page.waitForTimeout(300); - - if (dialogInfo.isRef) { - // Reference field: Tab triggers autocomplete to resolve text → reference value - await page.keyboard.press('Tab'); - await page.waitForTimeout(2000); - } - } - - // 3b. Switch CompareType if exact match requested (text fields only). - // Date/number: always exact, CompareType disabled. Reference: default exact (selects ref). - if (exact && !dialogInfo.isDate && !dialogInfo.isRef) { - const exactRadio = await page.evaluate(`(() => { - const p = 'form' + ${JSON.stringify(String(dialogForm))} + '_'; - // Check if CompareType group is disabled (dates, numbers) - const group = document.getElementById(p + 'CompareType'); - if (group && group.classList.contains('disabled')) return { already: true }; - const el = document.getElementById(p + 'CompareType#2#radio'); - if (!el || el.offsetWidth === 0) return null; - if (el.classList.contains('select')) return { already: true }; - const r = el.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (exactRadio && !exactRadio.already) { - await page.mouse.click(exactRadio.x, exactRadio.y); - await page.waitForTimeout(300); - } - } - - // 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked) - const findBtnCoords = await page.evaluate(`(() => { - const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); - const btn = btns.find(el => el.innerText?.trim() === 'Найти'); - if (!btn) return null; - const r = btn.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (findBtnCoords) { - await page.mouse.click(findBtnCoords.x, findBtnCoords.y); - } else { - await clickElement('Найти'); - } - await page.waitForTimeout(2000); - - // 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти). - // Check the specific dialog form — not generic modalSurface — to avoid closing parent modals - // (e.g. a selection form that opened this advanced search). - for (let attempt = 0; attempt < 3; attempt++) { - const dialogVisible = await page.evaluate(`(() => { - const p = 'form${dialogForm}_'; - return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); - })()`); - if (!dialogVisible) break; - await page.keyboard.press('Escape'); - await page.waitForTimeout(500); - } - await waitForStable(formNum); - - const state = await getFormState(); - state.filtered = { type: 'advanced', field, text, exact: !!exact }; - return state; -} - -/** - * Remove active filters/search from the current list. - * - * Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field). - * With field: clicks the × button on the specific filter badge (selective removal). - * - * @param {object} [opts] - * @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×) - */ -export async function unfilterList({ field } = {}) { - ensureConnected(); - await dismissPendingErrors(); - const formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error('unfilterList: no form found'); - - if (field) { - // --- Selective: click × on specific filter badge --- - const closeBtn = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const norm = s => s?.trim().replace(/\\u00a0/g, ' ').replace(/:$/, '').replace(/\\n/g, ' ') || ''; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const target = ny(${JSON.stringify(field.toLowerCase())}); - const items = [...document.querySelectorAll('[id^="' + p + '"].trainItem')].filter(el => el.offsetWidth > 0); - for (const item of items) { - const titleEl = item.querySelector('.trainName'); - const title = ny(norm(titleEl?.innerText).toLowerCase()); - if (title === target || title.includes(target)) { - const close = item.querySelector('.trainClose'); - if (close) { - const r = close.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), field: norm(titleEl?.innerText) }; - } - } - } - const available = items.map(item => norm(item.querySelector('.trainName')?.innerText)); - return { error: 'not_found', available }; - })()`); - - if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`); - await page.mouse.click(closeBtn.x, closeBtn.y); - await waitForStable(formNum); - - const state = await getFormState(); - state.unfiltered = { field: closeBtn.field }; - return state; - } - - // --- Clear ALL filters --- - - // 1. Remove all advanced filter badges (.trainItem × buttons) - for (let attempt = 0; attempt < 20; attempt++) { - const badge = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const item = [...document.querySelectorAll('[id^="' + p + '"].trainItem')] - .find(el => el.offsetWidth > 0); - if (!item) return null; - const close = item.querySelector('.trainClose'); - if (!close) return null; - const r = close.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (!badge) break; - await page.mouse.click(badge.x, badge.y); - await waitForStable(formNum); - } - - // 2. Cancel active search via Ctrl+Q - await page.keyboard.press('Control+q'); - await waitForStable(formNum); - - // 3. Clear simple search field if it has a value - const searchInfo = await page.evaluate(`(() => { - const p = 'form${formNum}_'; - const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); - return el ? { id: el.id, value: el.value || '' } : null; - })()`); - - if (searchInfo?.value) { - await page.click(`[id="${searchInfo.id}"]`); - await page.waitForTimeout(200); - await page.keyboard.press('Control+A'); - await page.keyboard.press('Delete'); - await page.keyboard.press('Enter'); - await waitForStable(formNum); - } - - const state = await getFormState(); - state.unfiltered = true; - return state; -} +// web-test table/filter v1.18 — filterList / unfilterList — simple search + advanced-column filter badges. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs'; +import { + detectFormScript, readSubmenuScript, + findSearchInputScript, findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript, + findFirstGridCellCoordsScript, findColumnFirstCellCoordsScript, + readFieldSelectorInfoScript, pickFieldInSelectorDropdownScript, + readFilterDialogInfoScript, findFilterBadgeCloseScript, findFirstFilterBadgeCloseScript, +} 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 { safeClick } from '../core/helpers.mjs'; +import { selectValue, fillReferenceField } from '../forms/select-value.mjs'; +import { pasteText } from '../core/clipboard.mjs'; +import { getFormState } from '../forms/state.mjs'; +import { clickElement } from '../core/click.mjs'; + +/** + * Filter the current list by field value, or search via search bar. + * + * Without field: simple search via the search bar (filters by all columns, no badge). + * With field: advanced search — clicks target column cell to auto-populate FieldSelector, + * opens dialog (Alt+F), fills Pattern, clicks Найти. Creates a real filter badge. + * Handles text, reference (with Tab autocomplete), and date fields automatically. + * Multiple filters can be chained by calling filterList multiple times. + * + * @param {string} text - Search text or date (e.g. "Мишка", "КП00", "10.03.2016") + * @param {object} [opts] + * @param {string} [opts.field] - Column name for advanced search (e.g. "Наименование", "Получатель", "Дата") + * @param {boolean} [opts.exact] - Exact match (text fields only; dates/numbers/refs always exact) + */ +export async function filterList(text, { field, exact } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('filterList: no form found'); + + if (!field) { + // --- Simple search: fill search input + Enter --- + const searchInfo = await page.evaluate(findSearchInputScript(formNum)); + + if (searchInfo) { + await page.click(`[id="${searchInfo.id}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await pasteText(text); + await page.waitForTimeout(300); + await page.keyboard.press('Enter'); + await waitForStable(formNum); + + const state = await getFormState(); + state.filtered = { type: 'search', text }; + return state; + } + + // No search input — Ctrl+F opens advanced search on such forms. + // Click first grid cell then fall through to advanced search path below. + const firstCell = await page.evaluate(findFirstGridCellCoordsScript(formNum)); + if (!firstCell) throw new Error('filterList: no search input and no grid found on this form'); + await page.mouse.click(firstCell.x, firstCell.y); + await page.waitForTimeout(300); + field = ''; // fall through to advanced search, skip DLB (empty field = keep auto-selected) + } + + // --- Advanced search: click target column cell → Alt+F → fill Pattern → Найти --- + // Clicking a cell in the target column makes it active, so when Alt+F opens the + // advanced search dialog, FieldSelector is auto-populated with the correct field name. + // This avoids changing FieldSelector programmatically (which can cause errors). + const isDateValue = /^\d{2}\.\d{2}\.\d{4}$/.test(text.trim()); + + // 1. Click a cell in the target column to activate it (auto-populates FieldSelector). + // If the column isn't visible in the grid, click any cell and use DLB fallback later. + let needDlb = false; + const gridEl = await page.evaluate(findColumnFirstCellCoordsScript(formNum, field)); + if (gridEl.error) throw new Error(`filterList: ${gridEl.error}`); + needDlb = !!gridEl.needDlb; + await page.mouse.click(gridEl.x, gridEl.y); + await page.waitForTimeout(500); + + // 2. Open advanced search dialog via Alt+F (with fallback to Еще menu) + await page.keyboard.press('Alt+f'); + await page.waitForTimeout(2000); + + let dialogForm = await page.evaluate(detectFormScript()); + if (dialogForm === formNum) { + // Alt+F didn't open dialog — fallback to Еще → Расширенный поиск + await clickElement('Еще'); + await page.waitForTimeout(500); + const menu = await page.evaluate(readSubmenuScript()); + const searchItem = Array.isArray(menu) && menu.find(i => + i.name.replace(/ /g, ' ').toLowerCase().includes('расширенный поиск')); + if (!searchItem) { + await page.keyboard.press('Escape'); + throw new Error('filterList: advanced search dialog could not be opened'); + } + await page.mouse.click(searchItem.x, searchItem.y); + await page.waitForTimeout(2000); + dialogForm = await page.evaluate(detectFormScript()); + if (dialogForm === formNum) { + throw new Error('filterList: advanced search dialog did not open'); + } + } + + // 2b. If column wasn't in the grid, change FieldSelector via DLB dropdown + // Skip DLB when field is empty (fallback from no-search-input path — keep auto-selected field) + if (needDlb && field) { + const fsInfo = await page.evaluate(readFieldSelectorInfoScript(dialogForm)); + + if (normYo(fsInfo.current.toLowerCase()) !== normYo(field.toLowerCase())) { + await page.mouse.click(fsInfo.dlbX, fsInfo.dlbY); + await page.waitForTimeout(1500); + + const ddResult = await page.evaluate(pickFieldInSelectorDropdownScript(field)); + + if (ddResult.error) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + throw new Error(`filterList: field "${field}" not found in FieldSelector. Available: ${ddResult.available?.join(', ') || 'none'}`); + } + await page.mouse.click(ddResult.x, ddResult.y); + await page.waitForTimeout(3000); + } + } + + // 3. Read dialog state and fill Pattern + // Detect field type by Pattern's sibling buttons: + // - iCalendB → date field (Home+Shift+End+Ctrl+V to replace date value) + // - iDLB on Pattern → reference field (paste + Tab for autocomplete) + // - neither → plain text field (just paste) + const dialogInfo = await page.evaluate(readFilterDialogInfoScript(dialogForm)); + + if (dialogInfo.isDate) { + // Date field: fill via Home → Shift+End (select all) → Ctrl+V (paste) + if (isDateValue && dialogInfo.patternValue !== text.trim()) { + await page.click(`[id="${dialogInfo.patternId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Home'); + await page.waitForTimeout(100); + await page.keyboard.press('Shift+End'); + await page.waitForTimeout(100); + await pasteText(text); + await page.waitForTimeout(500); + } + } else { + // Text or reference field: fill Pattern via clipboard paste + await page.click(`[id="${dialogInfo.patternId}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await pasteText(text); + await page.waitForTimeout(300); + + if (dialogInfo.isRef) { + // Reference field: Tab triggers autocomplete to resolve text → reference value + await page.keyboard.press('Tab'); + await page.waitForTimeout(2000); + } + } + + // 3b. Switch CompareType if exact match requested (text fields only). + // Date/number: always exact, CompareType disabled. Reference: default exact (selects ref). + if (exact && !dialogInfo.isDate && !dialogInfo.isRef) { + const exactRadio = await page.evaluate(findCompareTypeRadioScript(dialogForm, 2)); + if (exactRadio && !exactRadio.already) { + await page.mouse.click(exactRadio.x, exactRadio.y); + await page.waitForTimeout(300); + } + } + + // 4. Click "Найти" via mouse.click (dialog is modal — page.click may be blocked) + const findBtnCoords = await page.evaluate(findNamedButtonScript('Найти')); + if (findBtnCoords) { + await page.mouse.click(findBtnCoords.x, findBtnCoords.y); + } else { + await clickElement('Найти'); + } + await page.waitForTimeout(2000); + + // 5. Close advanced search dialog if it stayed open (some forms keep it open after Найти). + // Check the specific dialog form — not generic modalSurface — to avoid closing parent modals + // (e.g. a selection form that opened this advanced search). + for (let attempt = 0; attempt < 3; attempt++) { + const dialogVisible = await page.evaluate(isFormVisibleScript(dialogForm)); + if (!dialogVisible) break; + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + await waitForStable(formNum); + + const state = await getFormState(); + state.filtered = { type: 'advanced', field, text, exact: !!exact }; + return state; +} + +/** + * Remove active filters/search from the current list. + * + * Without field: clears ALL filters (Ctrl+Q for advanced search + clear search field). + * With field: clicks the × button on the specific filter badge (selective removal). + * + * @param {object} [opts] + * @param {string} [opts.field] - Remove only the filter for this field (clicks badge ×) + */ +export async function unfilterList({ field } = {}) { + ensureConnected(); + await dismissPendingErrors(); + const formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error('unfilterList: no form found'); + + if (field) { + // --- Selective: click × on specific filter badge --- + const closeBtn = await page.evaluate(findFilterBadgeCloseScript(formNum, field)); + + if (closeBtn?.error) throw new Error(`unfilterList: filter badge "${field}" not found. Available: ${closeBtn.available?.join(', ') || 'none'}`); + await page.mouse.click(closeBtn.x, closeBtn.y); + await waitForStable(formNum); + + const state = await getFormState(); + state.unfiltered = { field: closeBtn.field }; + return state; + } + + // --- Clear ALL filters --- + + // 1. Remove all advanced filter badges (.trainItem × buttons) + for (let attempt = 0; attempt < 20; attempt++) { + const badge = await page.evaluate(findFirstFilterBadgeCloseScript(formNum)); + if (!badge) break; + await page.mouse.click(badge.x, badge.y); + await waitForStable(formNum); + } + + // 2. Cancel active search via Ctrl+Q + await page.keyboard.press('Control+q'); + await waitForStable(formNum); + + // 3. Clear simple search field if it has a value + const searchInfo = await page.evaluate(findSearchInputScript(formNum)); + + if (searchInfo?.value) { + await page.click(`[id="${searchInfo.id}"]`); + await page.waitForTimeout(200); + await page.keyboard.press('Control+A'); + await page.keyboard.press('Delete'); + await page.keyboard.press('Enter'); + await waitForStable(formNum); + } + + const state = await getFormState(); + state.unfiltered = true; + return state; +} From 340142b0a2f1f2bad065be9099abff3f63e83c3d Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 20:38:01 +0300 Subject: [PATCH 30/47] =?UTF-8?q?refactor(web-test):=20=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D1=87=D0=B5=D0=BD=D1=8B=20DOM-=D1=81=D0=BA=D1=80?= =?UTF-8?q?=D0=B8=D0=BF=D1=82=D1=8B=20dialog/picker=20UI=20=D0=B8=D0=B7=20?= =?UTF-8?q?select-value?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Применены shared-функции из S2 (findSearchInputScript, findNamedButton, findCompareTypeRadio, isFormVisible). Добавлены новые для type-dialog и picker UI: - findPatternInputIdScript — Pattern input id (Alt+F dialog) - isTypeDialogScript — OK + ValueList + "Выбор типа" title - isNotInListCloudVisibleScript — "нет в списке" tooltip popup - findChildFormByButtonScript — поиск child-form по имени кнопки - readTypeDialogVisibleRowsScript — visible rows + fuzzy matches в ValueList select-value.mjs: 950 → 880 LOC (−70), inline page.evaluate 24 → 7 (планировали ≤8). Регресс 06/11/13 зелёный; полный регресс зелёный (Checkpoint-1 пройден). 04-selectvalue auto-history шаг — pre-existing test-isolation issue (см. S1). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/dom.mjs | 7 +- .claude/skills/web-test/scripts/dom/forms.mjs | 101 ++++++++++++++++- .../scripts/engine/forms/select-value.mjs | 104 +++--------------- 3 files changed, 123 insertions(+), 89 deletions(-) diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index 1bfa9f26..1da748c8 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.10 — facade re-exporting injectable DOM scripts from dom/ +// web-test dom v1.11 — 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. @@ -20,6 +20,11 @@ export { findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript, + findPatternInputIdScript, + isTypeDialogScript, + isNotInListCloudVisibleScript, + findChildFormByButtonScript, + readTypeDialogVisibleRowsScript, } from './dom/forms.mjs'; export { diff --git a/.claude/skills/web-test/scripts/dom/forms.mjs b/.claude/skills/web-test/scripts/dom/forms.mjs index 64a92f31..0f714f74 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.2 — form detection, content read, click-target/field-button resolution +// web-test dom/forms v1.3 — 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 } from './_shared.mjs'; @@ -483,3 +483,102 @@ export function isFormVisibleScript(dialogForm) { return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); })()`; } + +/** + * Find the Pattern input id on a search/filter dialog. Returns `id | null`. + */ +export function findPatternInputIdScript(dialogForm) { + return `(() => { + const p = 'form${dialogForm}_'; + const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] + .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); + return el ? el.id : null; + })()`; +} + +/** + * Is the given form a type selection dialog ("Выбор типа данных")? + * + * Detection signals (any one is sufficient): + * - `form{N}_OK` element exists (selection forms use "Выбрать", not "OK") + * - `form{N}_ValueList` grid exists (specific to type/value list dialogs) + * - window title contains "Выбор типа" on a visible `.toplineBoxTitle` + * + * Returns boolean. + */ +export function isTypeDialogScript(formNum) { + return `(() => { + const p = 'form' + ${formNum} + '_'; + const hasOK = !!document.getElementById(p + 'OK'); + const hasValueList = !!document.getElementById(p + 'ValueList'); + const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')] + .some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || '')); + return hasOK || hasValueList || hasTitle; + })()`; +} + +/** + * Is the "нет в списке" cloud popup visible? 1C shows it as a positioned div + * (absolute/fixed, high z-index) whose text contains "нет в списке". + * Returns boolean. + */ +export function isNotInListCloudVisibleScript() { + return `(() => { + const divs = document.querySelectorAll('div'); + for (const el of divs) { + if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; + const style = getComputedStyle(el); + if (style.position !== 'absolute' && style.position !== 'fixed') continue; + const z = parseInt(style.zIndex) || 0; + if (z < 100) continue; + if ((el.innerText || '').includes('нет в списке')) return true; + } + return false; + })()`; +} + +/** + * Find a child form opened above `prevFormNum` whose `form{N}_{buttonName}` button is visible. + * Used by type-dialog Ctrl+F flow to locate the "Найти" sub-dialog form number. + * Returns the form number or `null`. + */ +export function findChildFormByButtonScript(prevFormNum, buttonName, range = 20) { + return `(() => { + for (let n = ${prevFormNum} + 1; n < ${prevFormNum} + ${range}; n++) { + const btn = document.getElementById('form' + n + '_' + ${JSON.stringify(buttonName)}); + if (btn && btn.offsetWidth > 0) return n; + } + return null; + })()`; +} + +/** + * Read visible rows of a type-dialog ValueList grid and return rows that fuzzy-match `typeNorm`. + * + * `typeNorm` should already be lowercased, NBSP-normalized, ё→е normalized (use `normYo`). + * + * Returns `{ visible: string[], matches: Array<{ text, x, y }> }`. + */ +export function readTypeDialogVisibleRowsScript(formNum, typeNorm) { + return `(() => { + const grid = document.getElementById('form${formNum}_ValueList'); + if (!grid) return { visible: [], matches: [] }; + const body = grid.querySelector('.gridBody'); + if (!body) return { visible: [], matches: [] }; + const lines = body.querySelectorAll('.gridLine'); + const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim(); + const typeNorm = ${JSON.stringify(typeNorm)}; + const visible = []; + const matches = []; + for (const line of lines) { + const text = norm(line.innerText); + if (!text) continue; + visible.push(text); + if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) { + const r = line.getBoundingClientRect(); + matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); + } + } + return { visible, matches }; + })()`; +} diff --git a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs index 6a82bcbc..37fae823 100644 --- a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs @@ -1,4 +1,4 @@ -// web-test forms/select-value v1.16 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. +// web-test forms/select-value v1.17 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -7,6 +7,9 @@ import { import { detectFormScript, findFieldButtonScript, resolveFieldsScript, readSubmenuScript, checkErrorsScript, + findSearchInputScript, findNamedButtonScript, findCompareTypeRadioScript, isFormVisibleScript, + findPatternInputIdScript, isTypeDialogScript, isNotInListCloudVisibleScript, + findChildFormByButtonScript, readTypeDialogVisibleRowsScript, } from '../../dom.mjs'; import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; import { waitForStable, waitForCondition } from '../core/wait.mjs'; @@ -68,10 +71,7 @@ async function dblclickAndVerify(coords, selFormNum, fieldName) { await waitForStable(selFormNum); // Verify selection form closed - const stillOpen = await page.evaluate(`(() => { - const p = 'form${selFormNum}_'; - return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); - })()`); + const stillOpen = await page.evaluate(isFormVisibleScript(selFormNum)); if (stillOpen) { // Enter didn't select — item is likely a non-selectable group. // Don't Escape here — let the caller decide (may want to try another row). @@ -105,26 +105,14 @@ async function advancedSearchInline(formNum, text) { if (dialogForm === formNum || dialogForm === null) return; // Alt+F didn't open dialog // 2. Switch to "по части строки" (CompareType#1) - const radioClicked = await page.evaluate(`(() => { - const p = 'form${dialogForm}_'; - const el = document.getElementById(p + 'CompareType#1#radio'); - if (!el || el.offsetWidth === 0) return false; - if (el.classList.contains('select')) return true; // already selected - const r = el.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); - if (radioClicked && typeof radioClicked === 'object') { + const radioClicked = await page.evaluate(findCompareTypeRadioScript(dialogForm, 1)); + if (radioClicked && !radioClicked.already) { await page.mouse.click(radioClicked.x, radioClicked.y); await page.waitForTimeout(300); } // 3. Fill Pattern field via clipboard paste - const patternId = await page.evaluate(`(() => { - const p = 'form${dialogForm}_'; - const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Pattern/i.test(el.id)); - return el ? el.id : null; - })()`); + const patternId = await page.evaluate(findPatternInputIdScript(dialogForm)); if (!patternId) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); @@ -137,13 +125,7 @@ async function advancedSearchInline(formNum, text) { await page.waitForTimeout(300); // 4. Click "Найти" - const findBtn = await page.evaluate(`(() => { - const btns = [...document.querySelectorAll('a.press')].filter(el => el.offsetWidth > 0); - const btn = btns.find(el => el.innerText?.trim() === 'Найти'); - if (!btn) return null; - const r = btn.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); + const findBtn = await page.evaluate(findNamedButtonScript('Найти')); if (findBtn) { await page.mouse.click(findBtn.x, findBtn.y); await page.waitForTimeout(2000); @@ -151,10 +133,7 @@ async function advancedSearchInline(formNum, text) { // 5. Close advanced search dialog for (let attempt = 0; attempt < 3; attempt++) { - const dialogVisible = await page.evaluate(`(() => { - const p = 'form${dialogForm}_'; - return [...document.querySelectorAll('[id^="' + p + '"]')].some(el => el.offsetWidth > 0); - })()`); + const dialogVisible = await page.evaluate(isFormVisibleScript(dialogForm)); if (!dialogVisible) break; await page.keyboard.press('Escape'); await page.waitForTimeout(500); @@ -224,15 +203,10 @@ export async function pickFromSelectionForm(selFormNum, fieldName, search, origF // Step 3: Fallback — simple search via search input (for forms without Alt+F support) if (typeof search === 'string' && searchLower) { - const searchInputId = await page.evaluate(`(() => { - const p = 'form${selFormNum}_'; - const el = [...document.querySelectorAll('input.editInput[id^="' + p + '"]')] - .find(el => el.offsetWidth > 0 && /Строк[аи]Поиска|SearchString/i.test(el.id)); - return el ? el.id : null; - })()`); - if (searchInputId) { + const searchInputInfo = await page.evaluate(findSearchInputScript(selFormNum)); + if (searchInputInfo) { try { - await page.click(`[id="${searchInputId}"]`); + await page.click(`[id="${searchInputInfo.id}"]`); await page.waitForTimeout(200); await page.keyboard.press('Control+A'); await pasteText(searchText); @@ -278,14 +252,7 @@ export async function pickFromSelectionForm(selFormNum, fieldName, search, origF * - Window title contains "Выбор типа" (title attr on .toplineBoxTitle) */ export async function isTypeDialog(formNum) { - return page.evaluate(`(() => { - const p = 'form' + ${formNum} + '_'; - const hasOK = !!document.getElementById(p + 'OK'); - const hasValueList = !!document.getElementById(p + 'ValueList'); - const hasTitle = [...document.querySelectorAll('.toplineBoxTitle')] - .some(el => el.offsetWidth > 0 && /выбор типа/i.test(el.getAttribute('title') || '')); - return hasOK || hasValueList || hasTitle; - })()`); + return page.evaluate(isTypeDialogScript(formNum)); } /** @@ -317,27 +284,7 @@ export async function pickFromTypeDialog(formNum, typeName) { // Helper: read visible rows and find matching ones async function readVisibleRows() { - return page.evaluate(`(() => { - const grid = document.getElementById('form${formNum}_ValueList'); - if (!grid) return { visible: [], matches: [] }; - const body = grid.querySelector('.gridBody'); - if (!body) return { visible: [], matches: [] }; - const lines = body.querySelectorAll('.gridLine'); - const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim(); - const typeNorm = ${JSON.stringify(typeNorm)}; - const visible = []; - const matches = []; - for (const line of lines) { - const text = norm(line.innerText); - if (!text) continue; - visible.push(text); - if (text.toLowerCase().replace(/ё/gi, 'е').includes(typeNorm)) { - const r = line.getBoundingClientRect(); - matches.push({ text, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }); - } - } - return { visible, matches }; - })()`); + return page.evaluate(readTypeDialogVisibleRowsScript(formNum, typeNorm)); } // Step 1: Scan visible rows (fast path — no Ctrl+F needed for small lists) @@ -379,13 +326,7 @@ export async function pickFromTypeDialog(formNum, typeName) { await page.waitForTimeout(300); // Find the "Найти" dialog form number (it's > formNum) - const findFormNum = await page.evaluate(`(() => { - for (let n = ${formNum} + 1; n < ${formNum} + 20; n++) { - const btn = document.getElementById('form' + n + '_Find'); - if (btn && btn.offsetWidth > 0) return n; - } - return null; - })()`); + const findFormNum = await page.evaluate(findChildFormByButtonScript(formNum, 'Find')); if (findFormNum === null) { await page.keyboard.press('Escape'); @@ -455,18 +396,7 @@ export async function fillReferenceField(selector, fieldName, value, formNum) { // Helper: check for "not in list" cloud popup (1C shows positioned div with "нет в списке") async function checkNotInListCloud() { - return page.evaluate(`(() => { - const divs = document.querySelectorAll('div'); - for (const el of divs) { - if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; - const style = getComputedStyle(el); - if (style.position !== 'absolute' && style.position !== 'fixed') continue; - const z = parseInt(style.zIndex) || 0; - if (z < 100) continue; - if ((el.innerText || '').includes('нет в списке')) return true; - } - return false; - })()`); + return page.evaluate(isNotInListCloudVisibleScript()); } // 0. Dismiss any leftover error modal from a previous operation From 89efcad12520ea74d139ccaf687b85d7b24025b2 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 20:57:58 +0300 Subject: [PATCH 31/47] =?UTF-8?q?refactor(web-test):=20=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D1=87=D1=91=D0=BD=20EDD-=D0=B4=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=20=D0=B2=20dom/edd.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новое в dom/edd.mjs: - readEddScript — {visible, items:[{name,x,y}]} - isEddVisibleScript — boolean, лёгкая проверка - clickEddItemViaDispatchScript — клик по name через dispatchEvent (bypass div.surface); fuzzy с NBSP/ё/bracket-strip - clickShowAllInEddScript — "Показать все" в footer Wrappers в helpers.mjs: readEdd, isEddVisible, clickEddItemViaDispatch, clickShowAllInEdd. row-fill clickEddItem унифицирован с select-value вариантом (NBSP/ё normalization теперь работает и для табличных строк). Метрики: select-value 880 → 827 LOC (−53), row-fill 1065 → 1041 LOC (−24); evaluates row-fill 20 → 17, select-value 7 → 5. Полный регресс зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/dom.mjs | 9 +- .claude/skills/web-test/scripts/dom/edd.mjs | 108 ++++++++++++++++++ .../web-test/scripts/engine/core/helpers.mjs | 43 ++++--- .../scripts/engine/forms/select-value.mjs | 61 +--------- .../scripts/engine/table/row-fill.mjs | 34 +----- 5 files changed, 155 insertions(+), 100 deletions(-) create mode 100644 .claude/skills/web-test/scripts/dom/edd.mjs diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index 1da748c8..ea76975a 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.11 — facade re-exporting injectable DOM scripts from dom/ +// web-test dom v1.12 — 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. @@ -43,6 +43,13 @@ export { findOpenPopupScript, } from './dom/edit-state.mjs'; +export { + readEddScript, + isEddVisibleScript, + clickEddItemViaDispatchScript, + clickShowAllInEddScript, +} from './dom/edd.mjs'; + export { getFormStateScript } from './dom/form-state.mjs'; export { diff --git a/.claude/skills/web-test/scripts/dom/edd.mjs b/.claude/skills/web-test/scripts/dom/edd.mjs new file mode 100644 index 00000000..4d5639fe --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/edd.mjs @@ -0,0 +1,108 @@ +// web-test dom/edd v1.0 — DOM scripts for the #editDropDown autocomplete popup +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +/** + * Read the `#editDropDown` autocomplete popup. + * + * Returns `{ visible: false }` when EDD is absent/hidden, or + * `{ visible: true, items: [{ name, x, y }] }` with center coords suitable + * for `page.mouse.click(x, y)`. + * + * Note: `page.mouse.click` is often intercepted by `div.surface` overlays + * from DLB — prefer `clickEddItemViaDispatchScript` for those cases. + */ +export function readEddScript() { + return `(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return { visible: false }; + const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0); + return { + visible: true, + items: eddTexts.map(el => { + const r = el.getBoundingClientRect(); + return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 }; + }) + }; + })()`; +} + +/** + * Is the EDD popup currently visible? Returns boolean. + * Lighter than `readEddScript` when only presence matters. + */ +export function isEddVisibleScript() { + return `(() => { + const edd = document.getElementById('editDropDown'); + return !!(edd && edd.offsetWidth > 0); + })()`; +} + +/** + * Click an EDD item by name via `dispatchEvent` — bypasses `div.surface` + * overlays from DLB that intercept `page.mouse.click`. + * + * Matching is fuzzy: exact (with optional `(suffix)` strip) → includes, + * normalizes ё/е and NBSP. + * + * Returns the clicked item's innerText (trimmed), or `null` when no match. + */ +export function clickEddItemViaDispatchScript(itemName) { + return `(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return null; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const target = ny(${JSON.stringify(itemName.toLowerCase())}); + const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0); + function clickEl(el) { + const r = el.getBoundingClientRect(); + const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + el.dispatchEvent(new MouseEvent('mousedown', opts)); + el.dispatchEvent(new MouseEvent('mouseup', opts)); + el.dispatchEvent(new MouseEvent('click', opts)); + return el.innerText.trim(); + } + // Pass 1: exact match (prefer over partial) + for (const el of items) { + const t = ny((el.innerText?.trim() || '').toLowerCase()); + if (t === target) return clickEl(el); + const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, ''); + if (stripped === target) return clickEl(el); + } + // Pass 2: partial match + for (const el of items) { + const t = ny((el.innerText?.trim() || '').toLowerCase()); + if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el); + } + return null; + })()`; +} + +/** + * Click the "Показать все" / "Show all" link in the EDD footer via + * `dispatchEvent`. Tries `.eddBottom .hyperlink` first, then falls back + * to scanning for span/div/a with the literal text. + * + * Returns boolean — whether the link was found and clicked. + */ +export function clickShowAllInEddScript() { + return `(() => { + const edd = document.getElementById('editDropDown'); + if (!edd || edd.offsetWidth === 0) return false; + let el = edd.querySelector('.eddBottom .hyperlink'); + if (!el || el.offsetWidth === 0) { + const candidates = [...edd.querySelectorAll('span, div, a')] + .filter(e => e.offsetWidth > 0 && e.children.length === 0); + el = candidates.find(e => { + const t = (e.innerText?.trim() || '').toLowerCase(); + return t === 'показать все' || t === 'show all'; + }); + } + if (!el) return false; + const r = el.getBoundingClientRect(); + const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + el.dispatchEvent(new MouseEvent('mousedown', opts)); + el.dispatchEvent(new MouseEvent('mouseup', opts)); + el.dispatchEvent(new MouseEvent('click', opts)); + return true; + })()`; +} diff --git a/.claude/skills/web-test/scripts/engine/core/helpers.mjs b/.claude/skills/web-test/scripts/engine/core/helpers.mjs index 98344511..9f0aa46b 100644 --- a/.claude/skills/web-test/scripts/engine/core/helpers.mjs +++ b/.claude/skills/web-test/scripts/engine/core/helpers.mjs @@ -1,4 +1,4 @@ -// web-test core/helpers v1.18 — private, cross-cutting helpers used by the +// web-test core/helpers v1.19 — private, cross-cutting helpers used by the // public action functions (clickElement/fillFields/selectValue/etc). // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills @@ -10,6 +10,10 @@ import { isInputFocusedScript, isInputFocusedInGridScript, findOpenPopupScript, + readEddScript, + isEddVisibleScript, + clickEddItemViaDispatchScript, + clickShowAllInEddScript, } from '../../dom.mjs'; /** @@ -112,18 +116,31 @@ export async function findOpenPopup() { * @returns {Promise<{visible: boolean, items?: Array<{name:string, x:number, y:number}>}>} */ export async function readEdd() { - return await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return { visible: false }; - const eddTexts = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0); - return { - visible: true, - items: eddTexts.map(el => { - const r = el.getBoundingClientRect(); - return { name: el.innerText?.trim() || '', x: r.x + r.width / 2, y: r.y + r.height / 2 }; - }) - }; - })()`); + return page.evaluate(readEddScript()); +} + +/** + * Thin wrapper: is the EDD popup currently visible? + * Lighter than `readEdd` when only presence matters. + */ +export async function isEddVisible() { + return page.evaluate(isEddVisibleScript()); +} + +/** + * Click an EDD item by name via dispatchEvent (bypasses div.surface overlays). + * Returns the clicked item's innerText, or `null` if no match. + */ +export async function clickEddItemViaDispatch(itemName) { + return page.evaluate(clickEddItemViaDispatchScript(itemName)); +} + +/** + * Click the "Показать все" / "Show all" link in the EDD footer. + * Returns boolean. + */ +export async function clickShowAllInEdd() { + return page.evaluate(clickShowAllInEddScript()); } /** diff --git a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs index 37fae823..9dfab2d4 100644 --- a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs @@ -17,6 +17,7 @@ import { highlight, unhighlight } from '../recording/highlight.mjs'; import { safeClick, findFieldInputId, readEdd, detectNewForm as helperDetectNewForm, + clickEddItemViaDispatch, clickShowAllInEdd, } from '../core/helpers.mjs'; import { pasteText } from '../core/clipboard.mjs'; import { getFormState } from './state.mjs'; @@ -711,63 +712,9 @@ export async function selectValue(fieldName, searchText, { type } = {}) { return null; } - // Helper: click EDD item via evaluate (bypasses div.surface overlay from DLB) - // page.mouse.click() doesn't work here — surface intercepts pointer events. - // Dispatching mousedown directly on the element avoids this. - async function clickEddItem(itemName) { - return page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return null; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const target = ny(${JSON.stringify(itemName.toLowerCase())}); - const items = [...edd.querySelectorAll('.eddText')].filter(el => el.offsetWidth > 0); - function clickEl(el) { - const r = el.getBoundingClientRect(); - const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - el.dispatchEvent(new MouseEvent('mousedown', opts)); - el.dispatchEvent(new MouseEvent('mouseup', opts)); - el.dispatchEvent(new MouseEvent('click', opts)); - return el.innerText.trim(); - } - // Pass 1: exact match (prefer over partial) - for (const el of items) { - const t = ny((el.innerText?.trim() || '').toLowerCase()); - if (t === target) return clickEl(el); - const stripped = t.replace(/\\s*\\([^)]*\\)\\s*$/, ''); - if (stripped === target) return clickEl(el); - } - // Pass 2: partial match - for (const el of items) { - const t = ny((el.innerText?.trim() || '').toLowerCase()); - if (t.includes(target) || target.includes(t.replace(/\\s*\\([^)]*\\)\\s*$/, ''))) return clickEl(el); - } - return null; - })()`); - } - - // Helper: click "Показать все" in EDD footer via evaluate - async function clickShowAll() { - return page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return false; - let el = edd.querySelector('.eddBottom .hyperlink'); - if (!el || el.offsetWidth === 0) { - const candidates = [...edd.querySelectorAll('span, div, a')] - .filter(e => e.offsetWidth > 0 && e.children.length === 0); - el = candidates.find(e => { - const t = (e.innerText?.trim() || '').toLowerCase(); - return t === 'показать все' || t === 'show all'; - }); - } - if (!el) return false; - const r = el.getBoundingClientRect(); - const opts = { bubbles: true, cancelable: true, clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - el.dispatchEvent(new MouseEvent('mousedown', opts)); - el.dispatchEvent(new MouseEvent('mouseup', opts)); - el.dispatchEvent(new MouseEvent('click', opts)); - return true; - })()`); - } + // Locals → dom-scripts in helpers.mjs (see clickEddItemViaDispatch / clickShowAllInEdd) + const clickEddItem = clickEddItemViaDispatch; + const clickShowAll = clickShowAllInEdd; // 2. Click DLB (handle funcPanel / surface overlay intercept) const dlbSel = `[id="${btn.buttonId}"]`; diff --git a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs index eda8bf24..f1b8f1df 100644 --- a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs +++ b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs @@ -14,6 +14,7 @@ import { safeClick, findFieldInputId, detectNewForm as helperDetectNewForm, isInputFocused, isInputFocusedInGrid, findOpenPopup, + readEdd, isEddVisible, clickEddItemViaDispatch, } from '../core/helpers.mjs'; import { clickElement } from '../core/click.mjs'; import { @@ -427,11 +428,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await pasteText(info.value, { confirm: ['Control+a', 'Control+v'] }); await page.waitForTimeout(400); // Dismiss EDD autocomplete if it appeared - const hasEdd = await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - return edd && edd.offsetWidth > 0; - })()`); - if (hasEdd) { + if (await isEddVisible()) { await page.keyboard.press('Escape'); await page.waitForTimeout(200); } @@ -741,13 +738,8 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { } // Check for EDD autocomplete (indicates reference field) - const eddItems = await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd || edd.offsetWidth === 0) return null; - return [...edd.querySelectorAll('.eddText')] - .filter(el => el.offsetWidth > 0) - .map(el => el.innerText?.trim() || ''); - })()`); + const edd = await readEdd(); + const eddItems = edd.visible ? edd.items.map(i => i.name) : null; if (eddItems && eddItems.length > 0) { // Reference field with autocomplete — click best match @@ -763,23 +755,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { if (!pick) pick = realItems[0]; // Click EDD item via dispatchEvent (bypasses div.surface overlay) - const pickLower = pick.toLowerCase(); - await page.evaluate(`(() => { - const edd = document.getElementById('editDropDown'); - if (!edd) return; - for (const el of edd.querySelectorAll('.eddText')) { - if (el.offsetWidth === 0) continue; - if (el.innerText.trim().toLowerCase().includes(${JSON.stringify(pickLower)})) { - const r = el.getBoundingClientRect(); - const opts = { bubbles:true, cancelable:true, - clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - el.dispatchEvent(new MouseEvent('mousedown', opts)); - el.dispatchEvent(new MouseEvent('mouseup', opts)); - el.dispatchEvent(new MouseEvent('click', opts)); - return; - } - } - })()`); + await clickEddItemViaDispatch(pick); await waitForStable(); info.filled = true; results.push({ field: matchedKey, cell: cell.fullName, ok: true, From b08ee99521c981541a1680f994273e1806154bad Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 21:07:10 +0300 Subject: [PATCH 32/47] =?UTF-8?q?refactor(web-test):=20=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D1=87=D0=B5=D0=BD=D1=8B=20grid=20read-helpers=20+?= =?UTF-8?q?=20cloud-popup=20=D0=B2=20dom/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новое в dom/grid.mjs (все принимают опциональный gridSelector): - countGridRowsScript — кол-во .gridLine в body - isTreeGridScript — тип grid'а (есть .gridBoxTree) - findGridHeadCenterCoordsScript — центр .gridHead для commit-клика - getSelectedOrLastRowIndexScript — selected row index, fallback на последний Также: - isInputFocusedInGrid wrapper (S1) применён в add-row "ready" поллинге - isNotInListCloudVisibleScript (S3) применён вместо локального notInList - clickShowAllInNotInListCloudScript — новая в dom/forms.mjs (клик "Показать все" в "нет в списке" cloud popup через dispatchEvent) Метрики row-fill: 1041 → 971 LOC (−70), evaluates 17 → 10. Регресс 05/08/16/10 — зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/dom.mjs | 7 +- .claude/skills/web-test/scripts/dom/forms.mjs | 35 +++++++- .claude/skills/web-test/scripts/dom/grid.mjs | 74 ++++++++++++++- .../scripts/engine/table/row-fill.mjs | 90 +++---------------- 4 files changed, 123 insertions(+), 83 deletions(-) diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index ea76975a..df2698e4 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.12 — facade re-exporting injectable DOM scripts from dom/ +// web-test dom v1.13 — 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. @@ -23,6 +23,7 @@ export { findPatternInputIdScript, isTypeDialogScript, isNotInListCloudVisibleScript, + clickShowAllInNotInListCloudScript, findChildFormByButtonScript, readTypeDialogVisibleRowsScript, } from './dom/forms.mjs'; @@ -55,6 +56,10 @@ export { getFormStateScript } from './dom/form-state.mjs'; export { resolveGridScript, readTableScript, + countGridRowsScript, + isTreeGridScript, + findGridHeadCenterCoordsScript, + getSelectedOrLastRowIndexScript, } from './dom/grid.mjs'; export { diff --git a/.claude/skills/web-test/scripts/dom/forms.mjs b/.claude/skills/web-test/scripts/dom/forms.mjs index 0f714f74..70c4cfc8 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.3 — form detection, content read, click-target/field-button resolution +// web-test dom/forms v1.4 — 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 } from './_shared.mjs'; @@ -517,6 +517,39 @@ export function isTypeDialogScript(formNum) { })()`; } +/** + * Click the "Показать все" / "Show all" link inside the "нет в списке" + * cloud popup via `dispatchEvent`. Returns boolean — whether clicked. + */ +export function clickShowAllInNotInListCloudScript() { + return `(() => { + for (const el of document.querySelectorAll('div')) { + if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; + const s = getComputedStyle(el); + if (s.position !== 'absolute' && s.position !== 'fixed') continue; + if ((parseInt(s.zIndex) || 0) < 100) continue; + if (!(el.innerText || '').includes('нет в списке')) continue; + const links = [...el.querySelectorAll('a, span, div')] + .filter(e => e.offsetWidth > 0 && e.children.length === 0); + const showAll = links.find(e => { + const t = (e.innerText?.trim() || '').toLowerCase(); + return t === 'показать все' || t === 'show all'; + }); + if (showAll) { + const r = showAll.getBoundingClientRect(); + const opts = { bubbles:true, cancelable:true, + clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; + showAll.dispatchEvent(new MouseEvent('mousedown', opts)); + showAll.dispatchEvent(new MouseEvent('mouseup', opts)); + showAll.dispatchEvent(new MouseEvent('click', opts)); + return true; + } + return false; + } + return false; + })()`; +} + /** * Is the "нет в списке" cloud popup visible? 1C shows it as a positioned div * (absolute/fixed, high z-index) whose text contains "нет в списке". diff --git a/.claude/skills/web-test/scripts/dom/grid.mjs b/.claude/skills/web-test/scripts/dom/grid.mjs index 8c205ebf..959897f6 100644 --- a/.claude/skills/web-test/scripts/dom/grid.mjs +++ b/.claude/skills/web-test/scripts/dom/grid.mjs @@ -1,4 +1,4 @@ -// web-test dom/grid v1.0 — grid resolution + table reading +// web-test dom/grid v1.1 — grid resolution + table reading + edit-time helpers // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** @@ -247,3 +247,75 @@ export function readTableScript(formNum, { maxRows = 20, offset = 0, gridSelecto return result; })()`; } + +// ─── Edit-time grid helpers (for fillTableRow / row-fill) ──────────────────── +// +// All helpers below accept an optional `gridSelector`. When passed, they target +// that exact grid; when null/undefined they pick the LAST visible `.grid` on +// the page (this matches the implicit "current grid" used by row-fill). + +/** Inline JS fragment that resolves the target grid into `const grid`. */ +function gridResolver(gridSelector) { + return gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`; +} + +/** + * Count `.gridLine` rows in the body of the target grid. + * Returns the row count, or `0` when grid/body absent. + */ +export function countGridRowsScript(gridSelector) { + return `(() => { + const grid = ${gridResolver(gridSelector)}; + const body = grid?.querySelector('.gridBody'); + return body ? body.querySelectorAll('.gridLine').length : 0; + })()`; +} + +/** + * Is the target grid a tree grid? (presence of `.gridBoxTree`) + * Returns boolean. + */ +export function isTreeGridScript(gridSelector) { + return `(() => { + const grid = ${gridResolver(gridSelector)}; + return grid ? !!grid.querySelector('.gridBoxTree') : false; + })()`; +} + +/** + * Return center coords of the grid's `.gridHead` element. + * Used as a click target to commit a pending cell edit (clicking the header + * defocuses the input without selecting another row). + * + * Returns `{ x, y } | null`. + */ +export function findGridHeadCenterCoordsScript(gridSelector) { + return `(() => { + const grid = ${gridResolver(gridSelector)}; + if (!grid) return null; + const head = grid.querySelector('.gridHead'); + if (!head) return null; + const r = head.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`; +} + +/** + * Return the index of the currently selected row in the target grid, or + * fall back to the last row when nothing is selected. + * + * Returns row index, or `-1` when no rows. + */ +export function getSelectedOrLastRowIndexScript(gridSelector) { + return `(() => { + const grid = ${gridResolver(gridSelector)}; + if (!grid) return -1; + const body = grid.querySelector('.gridBody'); + if (!body) return -1; + const lines = [...body.querySelectorAll('.gridLine')]; + const sel = lines.findIndex(l => l.classList.contains('selected')); + return sel >= 0 ? sel : lines.length - 1; + })()`; +} diff --git a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs index f1b8f1df..9a935ef6 100644 --- a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs +++ b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs @@ -6,6 +6,9 @@ import { } from '../core/state.mjs'; import { detectFormScript, resolveGridScript, readTableScript, + countGridRowsScript, isTreeGridScript, findGridHeadCenterCoordsScript, + getSelectedOrLastRowIndexScript, + isNotInListCloudVisibleScript, clickShowAllInNotInListCloudScript, } from '../../dom.mjs'; import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; import { waitForStable, waitForCondition, startNetworkMonitor } from '../core/wait.mjs'; @@ -62,24 +65,12 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { let addedRowIdx = -1; if (add) { // Count rows before add — new row will be appended at this index - addedRowIdx = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - const body = grid?.querySelector('.gridBody'); - return body ? body.querySelectorAll('.gridLine').length : 0; - })()`); + addedRowIdx = await page.evaluate(countGridRowsScript(gridSelector)); await clickElement('Добавить', { table }); // Poll for edit mode (INPUT inside grid) instead of fixed 1000ms wait for (let aw = 0; aw < 6; aw++) { await page.waitForTimeout(150); - const ready = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; - let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } - return false; - })()`); - if (ready) break; + if (await isInputFocusedInGrid()) break; } } @@ -294,12 +285,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { // When click entered INPUT mode but no selection form yet — try F4 only for tree grids // (tree grid ref fields need F4 to open selection form; flat grids work via Tab-loop) if (inEdit && directEditForm === null) { - const isTreeGrid = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - return grid ? !!grid.querySelector('.gridBoxTree') : false; - })()`); + const isTreeGrid = await page.evaluate(isTreeGridScript(gridSelector)); if (isTreeGrid) { await page.keyboard.press('F4'); for (let fw = 0; fw < 8; fw++) { @@ -782,46 +768,11 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await page.waitForTimeout(1000); // Check for "нет в списке" cloud popup (reference field, value not found) - const notInList = await page.evaluate(`(() => { - for (const el of document.querySelectorAll('div')) { - if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; - const s = getComputedStyle(el); - if (s.position !== 'absolute' && s.position !== 'fixed') continue; - if ((parseInt(s.zIndex) || 0) < 100) continue; - if ((el.innerText || '').includes('нет в списке')) return true; - } - return false; - })()`); + const notInList = await page.evaluate(isNotInListCloudVisibleScript()); if (notInList) { // Cloud has "Показать все" link — try to open selection form via it - const clickedShowAll = await page.evaluate(`(() => { - for (const el of document.querySelectorAll('div')) { - if (el.offsetWidth === 0 || el.offsetHeight === 0) continue; - const s = getComputedStyle(el); - if (s.position !== 'absolute' && s.position !== 'fixed') continue; - if ((parseInt(s.zIndex) || 0) < 100) continue; - if (!(el.innerText || '').includes('нет в списке')) continue; - // Found the cloud — look for "Показать все" hyperlink inside - const links = [...el.querySelectorAll('a, span, div')] - .filter(e => e.offsetWidth > 0 && e.children.length === 0); - const showAll = links.find(e => { - const t = (e.innerText?.trim() || '').toLowerCase(); - return t === 'показать все' || t === 'show all'; - }); - if (showAll) { - const r = showAll.getBoundingClientRect(); - const opts = { bubbles:true, cancelable:true, - clientX: r.x + r.width/2, clientY: r.y + r.height/2 }; - showAll.dispatchEvent(new MouseEvent('mousedown', opts)); - showAll.dispatchEvent(new MouseEvent('mouseup', opts)); - showAll.dispatchEvent(new MouseEvent('click', opts)); - return true; - } - return false; - } - return false; - })()`); + const clickedShowAll = await page.evaluate(clickShowAllInNotInListCloudScript()); if (clickedShowAll) { await waitForStable(formNum); @@ -958,18 +909,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { // Clicking a different data row would re-enter edit mode on that row. // Without this commit click, the row stays in "uncommitted add" state // and a subsequent Escape (e.g. from closeForm) would cancel the entire row. - const commitTarget = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return null; - const head = grid.querySelector('.gridHead'); - if (head) { - const r = head.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - } - return null; - })()`); + const commitTarget = await page.evaluate(findGridHeadCenterCoordsScript(gridSelector)); if (commitTarget) { await page.mouse.click(commitTarget.x, commitTarget.y); await page.waitForTimeout(500); @@ -1001,17 +941,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { } if (Object.keys(checkboxFields).length > 0) { // Use row index: addedRowIdx (from add mode) or fallback to selected row - const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return -1; - const body = grid.querySelector('.gridBody'); - if (!body) return -1; - const lines = [...body.querySelectorAll('.gridLine')]; - const sel = lines.findIndex(l => l.classList.contains('selected')); - return sel >= 0 ? sel : lines.length - 1; - })()`) + const currentRow = addedRowIdx >= 0 ? addedRowIdx : (row != null ? row : await page.evaluate(getSelectedOrLastRowIndexScript(gridSelector)) ); if (currentRow >= 0) { const more = await fillTableRow(checkboxFields, { row: currentRow, table }); From b518b614bbeec95d5a6924eb10ac9f1b1b00b290 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 21:31:02 +0300 Subject: [PATCH 33/47] =?UTF-8?q?refactor(web-test):=20=D0=B8=D0=B7=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D1=87=D1=91=D0=BD=20grid-edit=20=D0=B4=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=B2=20dom/grid-edit.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тяжёлые DOM-блоки row-fill (15–60 строк) вынесены в 8 именованных функций dom/grid-edit.mjs. row-fill стал плоским orchestrator-ом. Новое в dom/grid-edit.mjs: - sortFieldKeysByColindexScript — сортировка keys по colindex (Tab-нав) - findCellCoordsByFieldsScript — клетка по first matching header (multi) - findNextCellCoordsByKeyScript — клетка по single key + no-space fuzzy - findCheckboxAtPointScript — checkbox info по координатам elementFromPoint - findRowCommitClickCoordsScript — клик OTHER row для commit-edit - getGridEditCheckScript — { inEdit, tag?, hint? } диагностика - readActiveGridCellScript — активная клетка (id, fullName, headerText) - getElementCenterCoordsByIdScript — центр по id (дедуп 2 копий) Метрики row-fill: 971 → 793 LOC (−178, S6 alone), inline page.evaluate 10 → 1 (значительно ниже плановой цели ≤10). Все табличные suite зелёные (05/06/08/10/16), полный регресс зелёный (Checkpoint-2). Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/dom.mjs | 13 +- .../skills/web-test/scripts/dom/grid-edit.mjs | 280 ++++++++++++++++++ .../scripts/engine/table/row-fill.mjs | 208 +------------ 3 files changed, 307 insertions(+), 194 deletions(-) create mode 100644 .claude/skills/web-test/scripts/dom/grid-edit.mjs diff --git a/.claude/skills/web-test/scripts/dom.mjs b/.claude/skills/web-test/scripts/dom.mjs index df2698e4..1db2747c 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.13 — facade re-exporting injectable DOM scripts from dom/ +// web-test dom v1.14 — 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. @@ -62,6 +62,17 @@ export { getSelectedOrLastRowIndexScript, } from './dom/grid.mjs'; +export { + sortFieldKeysByColindexScript, + findCellCoordsByFieldsScript, + findNextCellCoordsByKeyScript, + findCheckboxAtPointScript, + findRowCommitClickCoordsScript, + getGridEditCheckScript, + readActiveGridCellScript, + getElementCenterCoordsByIdScript, +} from './dom/grid-edit.mjs'; + export { readSectionsScript, readTabsScript, diff --git a/.claude/skills/web-test/scripts/dom/grid-edit.mjs b/.claude/skills/web-test/scripts/dom/grid-edit.mjs new file mode 100644 index 00000000..c1bfe1a8 --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/grid-edit.mjs @@ -0,0 +1,280 @@ +// web-test dom/grid-edit v1.0 — DOM scripts for row-fill (grid edit-time operations) +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// All helpers below accept an optional `gridSelector`. When passed, they target +// that exact grid; when null/undefined they pick the LAST visible `.grid` on +// the page (this matches the implicit "current grid" used by row-fill). + +/** Inline JS fragment that resolves the target grid into `const grid`. */ +function gridResolver(gridSelector) { + return gridSelector + ? `document.querySelector(${JSON.stringify(gridSelector)})` + : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`; +} + +/** + * Read the grid's column header texts paired with their `colindex` attribute, + * fuzzy-match `fieldKeys` (lowercased) against them, and return the keys in + * left-to-right colindex order. + * + * Keys that don't match a column get colindex `999` (pushed to the end); + * caller is expected to preserve their original relative order. + * + * Returns `string[] | null` (null when no grid or no head). + */ +export function sortFieldKeysByColindexScript(gridSelector, fieldKeys) { + return `(() => { + const grid = ${gridResolver(gridSelector)}; + if (!grid) return null; + const head = grid.querySelector('.gridHead'); + if (!head) return null; + const headLine = head.querySelector('.gridLine') || head; + const cols = []; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase(); + const ci = parseInt(box.getAttribute('colindex') || '-1'); + if (t) cols.push({ text: t, colindex: ci }); + }); + const keys = ${JSON.stringify(fieldKeys)}; + const mapped = keys.map(k => { + const exact = cols.find(c => c.text === k); + if (exact) return { key: k, colindex: exact.colindex }; + const inc = cols.find(c => c.text.includes(k) || k.includes(c.text)); + return { key: k, colindex: inc ? inc.colindex : 999 }; + }); + mapped.sort((a, b) => a.colindex - b.colindex); + return mapped.map(m => m.key); + })()`; +} + +/** + * Resolve cell coords for row `row` by matching the first column whose header + * fuzzy-matches any of `fieldKeys` (lowercased). Falls back to the second + * visible (non-`.gridBoxComp`) box when no header matches. + * + * Returns one of: + * - `{ x, y, currentText }` — coords + cell text + * - `{ error: 'no_grid' | 'no_grid_body' | 'no_cell' }` + * - `{ error: 'row_out_of_range', total }` + */ +export function findCellCoordsByFieldsScript(gridSelector, row, fieldKeys) { + return `(() => { + const grid = ${gridResolver(gridSelector)}; + if (!grid) return { error: 'no_grid' }; + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + if (!head || !body) return { error: 'no_grid_body' }; + + // Read column headers to find target colindex + const headLine = head.querySelector('.gridLine') || head; + const cols = []; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const t = box.querySelector('.gridBoxText'); + const ci = box.getAttribute('colindex'); + cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); + }); + + const keys = ${JSON.stringify(fieldKeys)}; + let targetColindex = null; + for (const key of keys) { + const exact = cols.find(c => c.text === key); + if (exact) { targetColindex = exact.colindex; break; } + const inc = cols.find(c => c.text.includes(key) || key.includes(c.text)); + if (inc) { targetColindex = inc.colindex; break; } + } + + const rows = [...body.querySelectorAll('.gridLine')]; + if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; + const line = rows[${row}]; + + // Find body cell by colindex (reliable across merged headers) + let box = null; + if (targetColindex != null) { + box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); + } + // Fallback: second visible box (skip checkbox/N column) + if (!box) { + const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + box = boxes.length > 1 ? boxes[1] : boxes[0]; + } + if (!box) return { error: 'no_cell' }; + box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + const cell = box.querySelector('.gridBoxText') || box; + const r = cell.getBoundingClientRect(); + const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; + })()`; +} + +/** + * Like `findCellCoordsByFieldsScript` but for a SINGLE key, with extra + * "no-space/no-dash" fuzzy fallback (e.g. "Группа Контрагентов" header matches + * key "ГруппаКонтрагентов"). + * + * Returns `{ x, y, currentText } | null`. + */ +export function findNextCellCoordsByKeyScript(gridSelector, row, key) { + return `(() => { + const grid = ${gridResolver(gridSelector)}; + if (!grid) return null; + const head = grid.querySelector('.gridHead'); + const body = grid.querySelector('.gridBody'); + if (!head || !body) return null; + const headLine = head.querySelector('.gridLine') || head; + const cols = []; + [...headLine.children].forEach(box => { + if (box.offsetWidth === 0) return; + const t = box.querySelector('.gridBoxText'); + const ci = box.getAttribute('colindex'); + cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); + }); + const kl = ${JSON.stringify(key.toLowerCase())}; + const klNoSpace = kl.replace(/[\\s\\-]+/g, ''); + let targetColindex = null; + const exact = cols.find(c => c.text === kl); + if (exact) targetColindex = exact.colindex; + else { + const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text) + || c.text.includes(klNoSpace) || klNoSpace.includes(c.text)); + if (inc) targetColindex = inc.colindex; + } + if (targetColindex == null) return null; + const rows = [...body.querySelectorAll('.gridLine')]; + if (${row} >= rows.length) return null; + const line = rows[${row}]; + const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); + if (!box) return null; + box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + const cell = box.querySelector('.gridBoxText') || box; + const r = cell.getBoundingClientRect(); + const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; + })()`; +} + +/** + * Inspect the element at point `(x, y)`. If it's inside a `.gridBox` containing + * a `.checkbox`, return `{ checked, x, y }` (coords of the checkbox center for + * direct click). + * + * Returns `null` when there's no cell, or the cell isn't a checkbox cell. + */ +export function findCheckboxAtPointScript(x, y) { + return `(() => { + const el = document.elementFromPoint(${x}, ${y}); + const cell = el?.closest('.gridBox'); + if (!cell) return null; + const chk = cell.querySelector('.checkbox'); + if (!chk) return null; + const r = chk.getBoundingClientRect(); + return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }; + })()`; +} + +/** + * Find center coords of the first VISIBLE non-`.gridBoxComp` cell on a row + * OTHER than `row` (used to commit an edit by clicking off the edited row — + * Escape would cancel in tree grids). + * + * For `row === 0`, targets row 1; otherwise targets row 0. + * + * Returns `{ x, y } | null` (null when there's no other row). + */ +export function findRowCommitClickCoordsScript(gridSelector, row) { + return `(() => { + const grid = ${gridResolver(gridSelector)}; + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const rows = [...body.querySelectorAll('.gridLine')]; + const otherIdx = ${row} === 0 ? 1 : 0; + const other = rows[otherIdx]; + if (!other) return null; + const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0]; + if (!box) return null; + const r = box.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; + })()`; +} + +/** + * Diagnostic: are we in grid edit mode (active INPUT inside `.grid` or + * `.gridContent`)? Returns an OBJECT (not a boolean) suitable for diagnostics: + * - `{ inEdit: true }` — good + * - `{ inEdit: false, tag: 'DIV' }` — active element wasn't INPUT + * - `{ inEdit: false, hint: 'input not inside grid' }` — input but no grid ancestor + */ +export function getGridEditCheckScript() { + return `(() => { + const f = document.activeElement; + if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName }; + let node = f; + while (node) { + if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true }; + node = node.parentElement; + } + return { inEdit: false, hint: 'input not inside grid' }; + })()`; +} + +/** + * Read the currently focused element if it's an editable grid cell (INPUT or + * TEXTAREA inside `.grid` / `.gridContent`). Resolves the header text by + * matching x-overlap of the input's bounding rect against header boxes. + * + * Returns one of: + * - `{ tag: 'INPUT', id, fullName, headerText }` — editable cell + * - `{ tag: 'DIV' | 'BODY' | ... }` — focused but not an editable cell + * - `{ tag: 'none' }` — nothing focused + * + * `fullName` strips both `form{N}_` prefix and `_i{M}` suffix. + */ +export function readActiveGridCellScript() { + return `(() => { + const f = document.activeElement; + if (!f) return { tag: 'none' }; + if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') { + const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })(); + if (inGrid) { + let headerText = ''; + let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement; + if (grid) { + const fr = f.getBoundingClientRect(); + const head = grid.querySelector('.gridHead'); + const hl = head?.querySelector('.gridLine') || head; + if (hl) for (const h of hl.children) { + if (h.offsetWidth === 0) continue; + const hr = h.getBoundingClientRect(); + if (fr.x >= hr.x && fr.x < hr.x + hr.width) { + const t = h.querySelector('.gridBoxText'); + headerText = (t || h).innerText?.trim() || ''; + break; + } + } + } + return { + tag: 'INPUT', id: f.id, + fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''), + headerText + }; + } + } + return { tag: f.tagName || 'none' }; + })()`; +} + +/** + * Return center coords of the element with the given id. + * Returns `{ x, y } | null`. + */ +export function getElementCenterCoordsByIdScript(elementId) { + return `(() => { + const el = document.getElementById(${JSON.stringify(elementId)}); + if (!el) return null; + const r = el.getBoundingClientRect(); + return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; + })()`; +} diff --git a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs index 9a935ef6..262967d8 100644 --- a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs +++ b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs @@ -9,6 +9,10 @@ import { countGridRowsScript, isTreeGridScript, findGridHeadCenterCoordsScript, getSelectedOrLastRowIndexScript, isNotInListCloudVisibleScript, clickShowAllInNotInListCloudScript, + sortFieldKeysByColindexScript, findCellCoordsByFieldsScript, + findNextCellCoordsByKeyScript, findCheckboxAtPointScript, + findRowCommitClickCoordsScript, getGridEditCheckScript, + readActiveGridCellScript, getElementCenterCoordsByIdScript, } from '../../dom.mjs'; import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; import { waitForStable, waitForCondition, startNetworkMonitor } from '../core/wait.mjs'; @@ -77,31 +81,8 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { // 2b. Enter edit mode on existing row by dblclick if (row != null) { // Sort fields by colindex (leftmost first) so Tab traversal covers all fields left-to-right - const sortedKeys = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return null; - const head = grid.querySelector('.gridHead'); - if (!head) return null; - const headLine = head.querySelector('.gridLine') || head; - const cols = []; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = ((box.querySelector('.gridBoxText') || box).innerText?.trim() || '').toLowerCase(); - const ci = parseInt(box.getAttribute('colindex') || '-1'); - if (t) cols.push({ text: t, colindex: ci }); - }); - const keys = ${JSON.stringify(Object.keys(fields).map(k => k.toLowerCase()))}; - const mapped = keys.map(k => { - const exact = cols.find(c => c.text === k); - if (exact) return { key: k, colindex: exact.colindex }; - const inc = cols.find(c => c.text.includes(k) || k.includes(c.text)); - return { key: k, colindex: inc ? inc.colindex : 999 }; - }); - mapped.sort((a, b) => a.colindex - b.colindex); - return mapped.map(m => m.key); - })()`); + const sortedKeys = await page.evaluate( + sortFieldKeysByColindexScript(gridSelector, Object.keys(fields).map(k => k.toLowerCase()))); if (sortedKeys) { // Rebuild fields in sorted order const sortedFields = {}; @@ -116,57 +97,8 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { fields = sortedFields; } - const fieldKeys = JSON.stringify(Object.keys(fields).map(k => k.toLowerCase())); - const cellCoords = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return { error: 'no_grid' }; - const head = grid.querySelector('.gridHead'); - const body = grid.querySelector('.gridBody'); - if (!head || !body) return { error: 'no_grid_body' }; - - // Read column headers to find target colindex - const headLine = head.querySelector('.gridLine') || head; - const cols = []; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = box.querySelector('.gridBoxText'); - const ci = box.getAttribute('colindex'); - cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); - }); - - const keys = ${fieldKeys}; - let targetColindex = null; - for (const key of keys) { - const exact = cols.find(c => c.text === key); - if (exact) { targetColindex = exact.colindex; break; } - const inc = cols.find(c => c.text.includes(key) || key.includes(c.text)); - if (inc) { targetColindex = inc.colindex; break; } - } - - const rows = [...body.querySelectorAll('.gridLine')]; - if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; - const line = rows[${row}]; - - // Find body cell by colindex (reliable across merged headers) - let box = null; - if (targetColindex != null) { - box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); - } - // Fallback: second visible box (skip checkbox/N column) - if (!box) { - const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); - box = boxes.length > 1 ? boxes[1] : boxes[0]; - } - if (!box) return { error: 'no_cell' }; - // Scroll into view if off-screen - box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - const cell = box.querySelector('.gridBoxText') || box; - const r = cell.getBoundingClientRect(); - const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; - })()`); + const cellCoords = await page.evaluate( + findCellCoordsByFieldsScript(gridSelector, row, Object.keys(fields).map(k => k.toLowerCase()))); if (cellCoords.error) throw new Error(`fillTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); @@ -222,15 +154,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { } // Check if clicked cell is a checkbox (toggle-on-click, no edit mode) - const checkboxInfo = await page.evaluate(`(() => { - const el = document.elementFromPoint(${cellCoords.x}, ${cellCoords.y}); - const cell = el?.closest('.gridBox'); - if (!cell) return null; - const chk = cell.querySelector('.checkbox'); - if (!chk) return null; - const r = chk.getBoundingClientRect(); - return { checked: chk.classList.contains('select'), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }; - })()`); + const checkboxInfo = await page.evaluate(findCheckboxAtPointScript(cellCoords.x, cellCoords.y)); if (checkboxInfo !== null) { // Checkbox cell found — click directly on the checkbox icon (not cell center) const desired = ['true', 'да', '1', 'yes'].includes(String(firstVal0).toLowerCase().trim()); @@ -353,44 +277,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { for (const [key, info] of pending) { if (info.filled) continue; // Find column for this key and dblclick on it - const nextCoords = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return null; - const head = grid.querySelector('.gridHead'); - const body = grid.querySelector('.gridBody'); - if (!head || !body) return null; - const headLine = head.querySelector('.gridLine') || head; - const cols = []; - [...headLine.children].forEach(box => { - if (box.offsetWidth === 0) return; - const t = box.querySelector('.gridBoxText'); - const ci = box.getAttribute('colindex'); - cols.push({ colindex: ci, text: ((t || box).innerText?.trim() || '').toLowerCase() }); - }); - const kl = ${JSON.stringify(key.toLowerCase())}; - const klNoSpace = kl.replace(/[\\s\\-]+/g, ''); - let targetColindex = null; - const exact = cols.find(c => c.text === kl); - if (exact) targetColindex = exact.colindex; - else { - const inc = cols.find(c => c.text.includes(kl) || kl.includes(c.text) - || c.text.includes(klNoSpace) || klNoSpace.includes(c.text)); - if (inc) targetColindex = inc.colindex; - } - if (targetColindex == null) return null; - const rows = [...body.querySelectorAll('.gridLine')]; - if (${row} >= rows.length) return null; - const line = rows[${row}]; - const box = [...line.children].find(b => b.offsetWidth > 0 && b.getAttribute('colindex') === targetColindex); - if (!box) return null; - box.scrollIntoView({ block: 'nearest', inline: 'nearest' }); - const cell = box.querySelector('.gridBoxText') || box; - const r = cell.getBoundingClientRect(); - const currentText = (cell.innerText?.trim() || '').replace(/\\u00a0/g, ' '); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), currentText }; - })()`); + const nextCoords = await page.evaluate(findNextCellCoordsByKeyScript(gridSelector, row, key)); if (!nextCoords) { info.filled = true; results.push({ field: key, error: 'column_not_found', message: `Column for "${key}" not found` }); @@ -444,23 +331,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { } // Commit the edit: click on a different row (Escape cancels in tree grids). // Find the first visible row that is NOT the edited row and click it. - const commitCoords = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return null; - const body = grid.querySelector('.gridBody'); - if (!body) return null; - const rows = [...body.querySelectorAll('.gridLine')]; - const otherIdx = ${row} === 0 ? 1 : 0; - const other = rows[otherIdx]; - if (!other) return null; - const visBoxes = [...other.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); - const box = visBoxes.length > 1 ? visBoxes[1] : visBoxes[0]; - if (!box) return null; - const r = box.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2) }; - })()`); + const commitCoords = await page.evaluate(findRowCommitClickCoordsScript(gridSelector, row)); if (commitCoords) { await page.mouse.click(commitCoords.x, commitCoords.y); } else { @@ -473,16 +344,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`); } else { // No row specified — verify we're in grid edit mode (active INPUT inside a .grid or .gridContent) - const editCheck = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || f.tagName !== 'INPUT') return { inEdit: false, tag: f?.tagName }; - let node = f; - while (node) { - if (node.classList?.contains('grid') || node.classList?.contains('gridContent')) return { inEdit: true }; - node = node.parentElement; - } - return { inEdit: false, hint: 'input not inside grid' }; - })()`); + const editCheck = await page.evaluate(getGridEditCheckScript()); if (!editCheck.inEdit) { throw new Error('fillTableRow: not in grid edit mode. Use add:true or click a cell first.'); @@ -513,37 +375,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { for (let iter = 0; iter < MAX_ITER; iter++) { // Read focused element (INPUT or TEXTAREA inside grid = editable cell) - const cell = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f) return { tag: 'none' }; - if (f.tagName === 'INPUT' || f.tagName === 'TEXTAREA') { - const inGrid = (() => { let n = f; while (n) { if (n.classList?.contains('grid') || n.classList?.contains('gridContent')) return true; n = n.parentElement; } return false; })(); - if (inGrid) { - let headerText = ''; - let grid = f; while (grid && !grid.classList?.contains('grid')) grid = grid.parentElement; - if (grid) { - const fr = f.getBoundingClientRect(); - const head = grid.querySelector('.gridHead'); - const hl = head?.querySelector('.gridLine') || head; - if (hl) for (const h of hl.children) { - if (h.offsetWidth === 0) continue; - const hr = h.getBoundingClientRect(); - if (fr.x >= hr.x && fr.x < hr.x + hr.width) { - const t = h.querySelector('.gridBoxText'); - headerText = (t || h).innerText?.trim() || ''; - break; - } - } - } - return { - tag: 'INPUT', id: f.id, - fullName: f.id.replace(/^form\\d+_/, '').replace(/_i\\d+$/, ''), - headerText - }; - } - } - return { tag: f.tagName || 'none' }; - })()`); + const cell = await page.evaluate(readActiveGridCellScript()); if (cell.tag !== 'INPUT' || !cell.fullName) { // Not in an editable grid cell — Tab past (ERP has DIV focus between cells) @@ -660,12 +492,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { // Ensure we are in an editable INPUT for this cell const inInput = await isInputFocused({ allowTextarea: true }); if (!inInput) { - const cellRect = await page.evaluate(`(() => { - const el = document.getElementById(${JSON.stringify(cell.id)}); - if (!el) return null; - const r = el.getBoundingClientRect(); - return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; - })()`); + const cellRect = await page.evaluate(getElementCenterCoordsByIdScript(cell.id)); if (cellRect) { await page.mouse.dblclick(cellRect.x, cellRect.y); // Poll for INPUT focus @@ -841,12 +668,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { } const inInput = await isInputFocused({ allowTextarea: true }); if (!inInput) { - const cellRect = await page.evaluate(`(() => { - const el = document.getElementById(${JSON.stringify(cell.id)}); - if (!el) return null; - const r = el.getBoundingClientRect(); - return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; - })()`); + const cellRect = await page.evaluate(getElementCenterCoordsByIdScript(cell.id)); if (cellRect) { await page.mouse.dblclick(cellRect.x, cellRect.y); for (let fw = 0; fw < 4; fw++) { From 8fd5544abd77eb0129fc1b022ff98626089d8c5f Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 26 May 2026 21:32:09 +0300 Subject: [PATCH 34/47] =?UTF-8?q?refactor(web-test):=20bump=20browser.mjs?= =?UTF-8?q?=20=D0=B4=D0=BE=20v1.18=20(=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20S1=E2=80=93S6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Завершён рефакторинг §0.5 п.5 родительского плана (вынос inline DOM в dom/). Итоговые метрики: | Файл | До (LOC, evals) | После (LOC, evals) | |-------------------|-----------------|--------------------| | row-fill.mjs | 1235 / 47 | 793 / 1 | | select-value.mjs | 959 / 25 | 827 / 5 | | filter.mjs | 390 / 17 | 256 / 0 | | Σ engine hot | 2584 / 89 | 1876 / 6 | Снижение LOC −708 (−27%), inline page.evaluate −83 (−93%). dom/ расширился с 7 до 11 файлов: новые edd.mjs, edit-state.mjs, filter.mjs, grid-edit.mjs; расширены forms.mjs (+16 функций) и grid.mjs (+4 функции). Engine-модули стали orchestrator-ами. Публичный API browser.mjs — 56 экспортов, без изменений. Полный регресс зелёный после каждого этапа S1–S6. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index a96841cd..19019a54 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -1,4 +1,4 @@ -// web-test browser v1.17 — engine facade: re-exports the public API from engine/* +// web-test browser v1.18 — engine facade: re-exports the public API from engine/* // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** * Public API of the web-test engine. Pure re-export facade — no logic here. From 280df54fa628f8eafdf6472c31504e008758e013 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 12:15:02 +0300 Subject: [PATCH 35/47] =?UTF-8?q?refactor(web-test):=20returnFormState=20?= =?UTF-8?q?=D0=B2=20click.mjs=20(10=20=D0=B2=D0=B5=D1=82=D0=BE=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фикс тихих багов R1/R2 — каждая ветка clickElement теперь подмешивает state.errors через хелпер returnFormState (engine/core/helpers.mjs). До правки ветки confirmation, submenuArrow, gridGroup/gridTreeNode (toggle+default), gridRow (click/dblclick), submenu (pre+post-wait) возвращали state без checkForErrors → exec-wrapper не throw'ал на soft validation errors (balloon/modal). Phase 1 / C1 из плана upload/returnFormState-audit.md. Точечный регресс зелёный (02-crud, 05-table, 08-hierarchy, 13-misc, 16-tree-form). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web-test/scripts/engine/core/click.mjs | 74 ++++++++----------- 1 file changed, 32 insertions(+), 42 deletions(-) diff --git a/.claude/skills/web-test/scripts/engine/core/click.mjs b/.claude/skills/web-test/scripts/engine/core/click.mjs index 7370d1f2..2e17e604 100644 --- a/.claude/skills/web-test/scripts/engine/core/click.mjs +++ b/.claude/skills/web-test/scripts/engine/core/click.mjs @@ -1,4 +1,4 @@ -// web-test core/click v1.17 — clickElement dispatcher: spreadsheet cells, submenus, grid groups/trees, buttons/links, tabs. +// web-test core/click v1.18 — clickElement dispatcher: spreadsheet cells, submenus, grid groups/trees, buttons/links, tabs. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -10,11 +10,11 @@ import { import { dismissPendingErrors, checkForErrors, fetchErrorStack } from './errors.mjs'; import { waitForStable, startNetworkMonitor } from './wait.mjs'; import { highlight, unhighlight } from '../recording/highlight.mjs'; -import { safeClick } from './helpers.mjs'; +import { safeClick, returnFormState } from './helpers.mjs'; import { getGridToggleIcon, shouldClickToggle } from '../table/grid-toggle.mjs'; import { clickSpreadsheetCell, findSpreadsheetCellByText, -} from '../spreadsheet/spreadsheet.mjs'; +} from '../spreadsheet/spreadsheet.mjs'; import { getFormState } from '../forms/state.mjs'; /** Click a button/hyperlink/tab on the current form. Use {dblclick: true} to double-click (open items from lists). @@ -50,9 +50,7 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi if (btnResult?.error) throw new Error(`clickElement: "${text}" not found among confirmation buttons. Available: ${btnResult.available?.join(', ') || 'none'}`); await page.mouse.click(btnResult.x, btnResult.y); await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'confirmation', name: btnResult.name }; - return state; + return returnFormState({ clicked: { kind: 'confirmation', name: btnResult.name } }); } // Check if there's an open popup — if so, try to click inside it @@ -73,13 +71,12 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi } await page.waitForTimeout(ACTION_WAIT); const nestedItems = await page.evaluate(readSubmenuScript()); - const state = await getFormState(); - state.clicked = { kind: 'submenuArrow', name: found.name }; + const extras = { clicked: { kind: 'submenuArrow', name: found.name } }; if (Array.isArray(nestedItems)) { - state.submenu = nestedItems.map(i => i.name); - state.hint = 'Call web_click again with a submenu item name to select it'; + extras.submenu = nestedItems.map(i => i.name); + extras.hint = 'Call web_click again with a submenu item name to select it'; } - return state; + return returnFormState(extras); } // Regular submenu/dropdown items — trusted events required. // Use mouse.click(x,y) when in viewport; use :visible selector for clipped items @@ -180,17 +177,15 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi } } await waitForStable(formNum); - const state = await getFormState(); - state.clicked = { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }; - state.hint = shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.'; - return state; + return returnFormState({ + clicked: { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }, + hint: shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.', + }); } // Default: dblclick to enter group / go up to parent await modDblClick(target.x, target.y); await waitForStable(formNum); - const state = await getFormState(); - state.clicked = { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) }; - return state; + return returnFormState({ clicked: { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) } }); } if (target.kind === 'gridTreeNode') { if (expand != null || toggle) { @@ -210,32 +205,28 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi } } await waitForStable(formNum); - const state = await getFormState(); - state.clicked = { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }; - state.hint = shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.'; - return state; + return returnFormState({ + clicked: { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }, + hint: shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.', + }); } // Default: select row (click text, no expand/collapse) await modClick(target.x, target.y); await waitForStable(formNum); - const state = await getFormState(); - state.clicked = { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) }; - state.hint = 'Row selected. Use { expand: true } to expand/collapse.'; - return state; + return returnFormState({ + clicked: { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) }, + hint: 'Row selected. Use { expand: true } to expand/collapse.', + }); } if (target.kind === 'gridRow') { if (dblclick) { await modDblClick(target.x, target.y); await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'gridRow', name: target.name, dblclick: true, ...(modifier ? { modifier } : {}) }; - return state; + return returnFormState({ clicked: { kind: 'gridRow', name: target.name, dblclick: true, ...(modifier ? { modifier } : {}) } }); } await modClick(target.x, target.y); await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'gridRow', name: target.name, ...(modifier ? { modifier } : {}) }; - return state; + return returnFormState({ clicked: { kind: 'gridRow', name: target.name, ...(modifier ? { modifier } : {}) } }); } // Start CDP network monitor BEFORE the click for buttons — @@ -257,13 +248,12 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi if (target.kind === 'submenu') { await page.waitForTimeout(ACTION_WAIT); const submenuItems = await page.evaluate(readSubmenuScript()); - const state = await getFormState(); - state.clicked = { kind: 'submenu', name: target.name }; + const extras = { clicked: { kind: 'submenu', name: target.name } }; if (Array.isArray(submenuItems)) { - state.submenu = submenuItems.map(i => i.name); - state.hint = 'Call web_click again with a submenu item name to select it'; + extras.submenu = submenuItems.map(i => i.name); + extras.hint = 'Call web_click again with a submenu item name to select it'; } - return state; + return returnFormState(extras); } await waitForStable(formNum); @@ -271,11 +261,11 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi // Check if the click opened a popup/submenu (split buttons like "Создать на основании") const openedPopup = await page.evaluate(readSubmenuScript()); if (Array.isArray(openedPopup) && openedPopup.length > 0) { - const state = await getFormState(); - state.clicked = { kind: 'submenu', name: target.name }; - state.submenu = openedPopup.map(i => i.name); - state.hint = 'Call web_click again with a submenu item name to select it'; - return state; + return returnFormState({ + clicked: { kind: 'submenu', name: target.name }, + submenu: openedPopup.map(i => i.name), + hint: 'Call web_click again with a submenu item name to select it', + }); } // For buttons that trigger server-side operations (post, write, etc.), From a381fca0a1ddc725a1887f6dbc2245900a22eee2 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 12:21:00 +0300 Subject: [PATCH 36/47] =?UTF-8?q?refactor(web-test):=20returnFormState=20?= =?UTF-8?q?=D0=B2=20close.mjs=20+=20filter.mjs=20(7=20=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closeForm: platform-dialogs, save=true/false, final-escape — теперь подмешивают state.errors через returnFormState. Ветка save=undefined (hint-return) осознанно оставлена без errors (юзер ещё не принял решение). filterList: simple search, advanced search — закрывают R1/R2. unfilterList: selective (field) + clear-all — аналогично. Phase 1 / C2 из плана upload/returnFormState-audit.md. Точечный регресс зелёный (02-crud, 06-document, 09-filter). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web-test/scripts/engine/forms/close.mjs | 16 ++++++--------- .../web-test/scripts/engine/table/filter.mjs | 20 ++++++------------- 2 files changed, 12 insertions(+), 24 deletions(-) diff --git a/.claude/skills/web-test/scripts/engine/forms/close.mjs b/.claude/skills/web-test/scripts/engine/forms/close.mjs index cd9990e3..9adc9dc0 100644 --- a/.claude/skills/web-test/scripts/engine/forms/close.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/close.mjs @@ -1,10 +1,11 @@ -// web-test forms/close v1.17 — Close current form via Escape, handle save-changes confirmation. +// web-test forms/close v1.18 — 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 { returnFormState } from '../core/helpers.mjs'; import { getFormState } from './state.mjs'; /** @@ -23,10 +24,7 @@ export async function closeForm({ save } = {}) { if (pd.length) { await closePlatformDialogs(); await page.waitForTimeout(300); - const state = await getFormState(); - state.closed = true; - state.closedPlatformDialogs = pd; - return state; + return returnFormState({ closed: true, closedPlatformDialogs: pd }); } const beforeForm = await page.evaluate(detectFormScript()); await page.keyboard.press('Escape'); @@ -47,14 +45,12 @@ export async function closeForm({ save } = {}) { break; } } - const afterState = await getFormState(); - afterState.closed = afterState.form !== beforeForm; - return afterState; + const afterForm = await page.evaluate(detectFormScript()); + return returnFormState({ closed: afterForm !== beforeForm }); } state.confirmation = err.confirmation; state.hint = 'Confirmation dialog shown. Click "Да" to confirm or "Нет" to cancel'; return state; } - state.closed = state.form !== beforeForm; - return state; + return returnFormState({ closed: state.form !== beforeForm }); } diff --git a/.claude/skills/web-test/scripts/engine/table/filter.mjs b/.claude/skills/web-test/scripts/engine/table/filter.mjs index 04a227ef..2f11e35f 100644 --- a/.claude/skills/web-test/scripts/engine/table/filter.mjs +++ b/.claude/skills/web-test/scripts/engine/table/filter.mjs @@ -1,4 +1,4 @@ -// web-test table/filter v1.18 — filterList / unfilterList — simple search + advanced-column filter badges. +// web-test table/filter v1.19 — filterList / unfilterList — simple search + advanced-column filter badges. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, ensureConnected, normYo, highlightMode, ACTION_WAIT } from '../core/state.mjs'; @@ -12,7 +12,7 @@ import { import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; import { waitForStable, waitForCondition } from '../core/wait.mjs'; import { highlight, unhighlight } from '../recording/highlight.mjs'; -import { safeClick } from '../core/helpers.mjs'; +import { safeClick, returnFormState } from '../core/helpers.mjs'; import { selectValue, fillReferenceField } from '../forms/select-value.mjs'; import { pasteText } from '../core/clipboard.mjs'; import { getFormState } from '../forms/state.mjs'; @@ -51,9 +51,7 @@ export async function filterList(text, { field, exact } = {}) { await page.keyboard.press('Enter'); await waitForStable(formNum); - const state = await getFormState(); - state.filtered = { type: 'search', text }; - return state; + return returnFormState({ filtered: { type: 'search', text } }); } // No search input — Ctrl+F opens advanced search on such forms. @@ -191,9 +189,7 @@ export async function filterList(text, { field, exact } = {}) { } await waitForStable(formNum); - const state = await getFormState(); - state.filtered = { type: 'advanced', field, text, exact: !!exact }; - return state; + return returnFormState({ filtered: { type: 'advanced', field, text, exact: !!exact } }); } /** @@ -219,9 +215,7 @@ export async function unfilterList({ field } = {}) { await page.mouse.click(closeBtn.x, closeBtn.y); await waitForStable(formNum); - const state = await getFormState(); - state.unfiltered = { field: closeBtn.field }; - return state; + return returnFormState({ unfiltered: { field: closeBtn.field } }); } // --- Clear ALL filters --- @@ -250,7 +244,5 @@ export async function unfilterList({ field } = {}) { await waitForStable(formNum); } - const state = await getFormState(); - state.unfiltered = true; - return state; + return returnFormState({ unfiltered: true }); } From 707033e25b6c577bb0e1c2d135f1ff79d0e3829d Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 12:41:13 +0300 Subject: [PATCH 37/47] =?UTF-8?q?refactor(web-test):=20returnFormState=20?= =?UTF-8?q?=D0=B2=20nav=20+=20grid=20+=20spreadsheet=20+=20selectValue=20(?= =?UTF-8?q?7=20=D0=B2=D0=B5=D1=82=D0=BE=D0=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navigation.mjs: navigateSection (sections+commands ветка), switchTab, openFile (оба success-сайта). Закрывает R1/R2 для навигационных action-функций. grid.mjs: deleteTableRow — заодно унификация shape с SKILL.md ("→ form state" плоский). До этого код возвращал {deleted, ..., form: formData} в нарушение документации; теперь плоский state с extras. JSDoc обновлён. spreadsheet.mjs: clickSpreadsheetCell. select-value.mjs: ветка clear-success selectValue (search=null, method='clear'). Phase 1 / C3 (final) из плана upload/returnFormState-audit.md. Полный регресс 19/19 зелёный. Известный pre-existing test-isolation issue в одиночном прогоне 04-selectvalue (auto-history) — описан в backlog §0.8 #4 родительского плана. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web-test/scripts/engine/forms/select-value.mjs | 7 +++---- .../web-test/scripts/engine/nav/navigation.mjs | 14 ++++++-------- .../scripts/engine/spreadsheet/spreadsheet.mjs | 7 +++---- .../skills/web-test/scripts/engine/table/grid.mjs | 9 ++++----- 4 files changed, 16 insertions(+), 21 deletions(-) diff --git a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs index 9dfab2d4..e906b902 100644 --- a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs @@ -1,4 +1,4 @@ -// web-test forms/select-value v1.17 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. +// web-test forms/select-value v1.18 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -17,7 +17,7 @@ import { highlight, unhighlight } from '../recording/highlight.mjs'; import { safeClick, findFieldInputId, readEdd, detectNewForm as helperDetectNewForm, - clickEddItemViaDispatch, clickShowAllInEdd, + clickEddItemViaDispatch, clickShowAllInEdd, returnFormState, } from '../core/helpers.mjs'; import { pasteText } from '../core/clipboard.mjs'; import { getFormState } from './state.mjs'; @@ -617,8 +617,7 @@ export async function selectValue(fieldName, searchText, { type } = {}) { await waitForStable(); } if (highlightMode) try { await unhighlight(); } catch {} - const formData = await getFormState(); - return { ...formData, selected: { field: fieldName, search: null, method: 'clear' } }; + return returnFormState({ selected: { field: fieldName, search: null, method: 'clear' } }); } // === COMPOSITE TYPE HANDLING === diff --git a/.claude/skills/web-test/scripts/engine/nav/navigation.mjs b/.claude/skills/web-test/scripts/engine/nav/navigation.mjs index 08790f8c..b832f6f1 100644 --- a/.claude/skills/web-test/scripts/engine/nav/navigation.mjs +++ b/.claude/skills/web-test/scripts/engine/nav/navigation.mjs @@ -1,4 +1,4 @@ -// web-test nav/navigation v1.16 — Section navigation, openCommand, switchTab, navigateLink (Shift+F11), openFile. +// web-test nav/navigation v1.17 — Section navigation, openCommand, switchTab, navigateLink (Shift+F11), openFile. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -12,9 +12,9 @@ import { 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'; +import { returnFormState } from '../core/helpers.mjs'; // Static import — ESM cycle that resolves at call time. -import { pasteText } from '../core/clipboard.mjs'; +import { pasteText } from '../core/clipboard.mjs'; import { getFormState } from '../forms/state.mjs'; /** @@ -60,7 +60,7 @@ export async function navigateSection(name) { sections: ${readSectionsScript()}, commands: ${readCommandsScript()} })`); - return { navigated: result, sections, commands }; + return returnFormState({ navigated: result, sections, commands }); } /** Read commands of the current section. */ @@ -88,7 +88,7 @@ export async function switchTab(name) { 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(); + return returnFormState(); } // English → Russian metadata type mapping for e1cib navigation links @@ -206,9 +206,7 @@ export async function openFile(filePath) { } } // It's the real EPF form - const state = await getFormState(); - state.opened = { file: absPath, attempt: attempt + 1 }; - return state; + return returnFormState({ opened: { file: absPath, attempt: attempt + 1 } }); } // Form didn't appear — retry continue; diff --git a/.claude/skills/web-test/scripts/engine/spreadsheet/spreadsheet.mjs b/.claude/skills/web-test/scripts/engine/spreadsheet/spreadsheet.mjs index 9743170d..2ee1aedc 100644 --- a/.claude/skills/web-test/scripts/engine/spreadsheet/spreadsheet.mjs +++ b/.claude/skills/web-test/scripts/engine/spreadsheet/spreadsheet.mjs @@ -1,10 +1,11 @@ -// web-test spreadsheet v1.17 — readSpreadsheet + helpers for SpreadsheetDocument (отчёты, печатные формы). +// web-test spreadsheet v1.18 — readSpreadsheet + helpers for SpreadsheetDocument (отчёты, печатные формы). // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page, ensureConnected } from '../core/state.mjs'; import { detectFormScript } from '../../dom.mjs'; import { waitForStable } from '../core/wait.mjs'; import { getFormState } from '../forms/state.mjs'; +import { returnFormState } from '../core/helpers.mjs'; // --- Spreadsheet helpers (shared by readSpreadsheet and clickElement) --- @@ -442,9 +443,7 @@ export async function clickSpreadsheetCell(target, { dblclick: dbl, modifier } = if (modKey) await page.keyboard.up(modKey); await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) }; - return state; + return returnFormState({ clicked: { kind: 'spreadsheetCell', row: target.row, column: colName, ...(dbl ? { dblclick: true } : {}) } }); } /** diff --git a/.claude/skills/web-test/scripts/engine/table/grid.mjs b/.claude/skills/web-test/scripts/engine/table/grid.mjs index 8d7788f5..55bf457c 100644 --- a/.claude/skills/web-test/scripts/engine/table/grid.mjs +++ b/.claude/skills/web-test/scripts/engine/table/grid.mjs @@ -1,4 +1,4 @@ -// web-test table/grid v1.17 — Form-grid operations: read table rows, fill rows, delete rows. +// web-test table/grid v1.18 — Form-grid operations: read table rows, fill rows, delete rows. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills // // "Grid" в терминах 1С — таблица на форме (.gridLine/.gridBody/.grid в DOM): @@ -10,7 +10,7 @@ import { detectFormScript, readTableScript, resolveGridScript } from '../../dom. import { dismissPendingErrors } from '../core/errors.mjs'; import { waitForStable } from '../core/wait.mjs'; import { clickElement } from '../core/click.mjs'; -import { getFormState } from '../forms/state.mjs'; +import { returnFormState } from '../core/helpers.mjs'; /** Read structured table data with pagination. Returns columns, rows, total count. */ export async function readTable({ maxRows = 20, offset = 0, table } = {}) { @@ -33,7 +33,7 @@ export async function readTable({ maxRows = 20, offset = 0, table } = {}) { * @param {number} row - 0-based row index to delete * @param {Object} [options] * @param {string} [options.tab] - Switch to this form tab before operating - * @returns {{ deleted, rowsBefore, rowsAfter, form }} + * @returns {object} form state with { deleted, rowsBefore, rowsAfter } */ export async function deleteTableRow(row, { tab, table } = {}) { ensureConnected(); @@ -98,6 +98,5 @@ export async function deleteTableRow(row, { tab, table } = {}) { return body ? body.querySelectorAll('.gridLine').length : 0; })()`); - const formData = await getFormState(); - return { deleted: row, rowsBefore, rowsAfter, form: formData }; + return returnFormState({ deleted: row, rowsBefore, rowsAfter }); } From 486890c388a6b5945de17bf6b4d6c109764b6654 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 14:03:00 +0300 Subject: [PATCH 38/47] =?UTF-8?q?test(web-test):=20=D1=81=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B0=D1=82=D1=8C=2004-selectvalue=20auto-history=20?= =?UTF-8?q?=D0=B4=D0=B5=D1=82=D0=B5=D1=80=D0=BC=D0=B8=D0=BD=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step auto-history полагался на наполненную UserChoiceHistory от предыдущих тестов (06-document и т.д.) — в одиночном прогоне history для 'ООО Юг' пустая, typeahead не активировался, method=form вместо ожидаемого dropdown. History в 1С — per-value: первый выбор значения через form наполняет историю, второй выбор того же значения идёт через typeahead-dropdown. Добавлен warm-up: selectValue('Менеджер', 'ООО Юг') → clear → второй selectValue того же значения (уже из истории). Закрывает §0.8 #4 родительского плана. Регресс одиночный + полный 19/19 зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/web-test/04-selectvalue.test.mjs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/web-test/04-selectvalue.test.mjs b/tests/web-test/04-selectvalue.test.mjs index 7ac476a8..7c5798a8 100644 --- a/tests/web-test/04-selectvalue.test.mjs +++ b/tests/web-test/04-selectvalue.test.mjs @@ -44,10 +44,20 @@ export default async function({ navigateSection, openCommand, clickElement, sele // Контрагент: 'DontUse' → typeahead-dropdown подавлен → selectValue идёт в form // Менеджер: 'Auto' (дефолт) → typeahead активен → selectValue остаётся в dropdown // Шаг подтверждает, что флаг управляет path внутри selectValue. + // + // history наполняется per-value при выборе. Делаем warm-up через form, чтобы + // second pick шёл из истории — иначе isolation-прогон зависит от того, + // выбирали ли 'ООО Юг' в предыдущих тестах (06-document и т.д.). await navigateSection('Склад'); await openCommand('Приходная накладная'); await clickElement('Создать'); + // Warm-up: первый выбор может пойти через form (если history пустая). + // Не делаем assertions — только наполняем историю. + await selectValue('Менеджер', 'ООО Юг'); + await selectValue('Менеджер', ''); // clear, оставляем форму открытой + + // Второй выбор того же значения — должен взяться из history через typeahead. const r = await selectValue('Менеджер', 'ООО Юг'); log(`Менеджер (Auto): method=${r.selected?.method}`); assert.equal(r.selected?.method, 'dropdown', From f554ef459981a028fa903d7f9d948e7b35e3f0b2 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 14:15:51 +0300 Subject: [PATCH 39/47] =?UTF-8?q?refactor(web-test):=20=D0=B2=D1=8B=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=B8=20error-stack=20scraping=20=D0=B2=20do?= =?UTF-8?q?m/errors-stack.mjs=20(S8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 5 inline page.evaluate блоков (~50 LOC) из fetchStackViaReport (path-1 OpenReport flow платформенных исключений) → новый dom/errors-stack.mjs с 5 экспортами: getOpenReportCoordsScript, isErrorDetailLinkVisibleScript, readLargestVisibleTextareaScript, clickTopCloudOkButtonScript, clickReportCloseButtonScript. Engine-сторона fetchStackViaReport теперь читается как чистый оркестратор 6 шагов без длинных DOM-строк. Поведение 1:1. DOM-extraction iter 2 / S8 из плана. Точечный регресс 14-errors-stack зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web-test/scripts/dom/errors-stack.mjs | 65 +++++++++++++++++++ .../web-test/scripts/engine/core/errors.mjs | 53 ++++----------- 2 files changed, 76 insertions(+), 42 deletions(-) create mode 100644 .claude/skills/web-test/scripts/dom/errors-stack.mjs diff --git a/.claude/skills/web-test/scripts/dom/errors-stack.mjs b/.claude/skills/web-test/scripts/dom/errors-stack.mjs new file mode 100644 index 00000000..d3dd6477 --- /dev/null +++ b/.claude/skills/web-test/scripts/dom/errors-stack.mjs @@ -0,0 +1,65 @@ +// web-test dom/errors-stack v1.0 — DOM scripts for fetching error stack via OpenReport link. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Path-1 flow for platform exceptions: click "Сформировать отчет об ошибке" link, +// open detailed error dialog, read textarea, close cleanup dialogs. + +/** Find OpenReport link coordinates on the error modal for given formNum. */ +export function getOpenReportCoordsScript(formNum) { + return `(() => { + const el = document.getElementById('form${formNum}_OpenReport#text'); + if (!el || el.offsetWidth <= 2) return null; + const rect = el.getBoundingClientRect(); + return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; + })()`; +} + +/** Check whether the "подробный текст ошибки" link is visible (signals report dialog ready). */ +export function isErrorDetailLinkVisibleScript() { + return `(() => { + const links = document.querySelectorAll('a, [class*="hyper"], span'); + for (const el of links) { + if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true; + } + return false; + })()`; +} + +/** Read the largest visible non-empty textarea — contains the detailed error stack. */ +export function readLargestVisibleTextareaScript() { + return `(() => { + let best = null; + document.querySelectorAll('textarea').forEach(ta => { + if (ta.offsetWidth > 0 && ta.value.length > 0) { + if (!best || ta.value.length > best.value.length) best = ta; + } + }); + return best?.value || null; + })()`; +} + +/** Click the OK button in the topmost cloud window (closes "Подробный текст ошибки"). */ +export function clickTopCloudOkButtonScript() { + return `(() => { + const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')] + .filter(w => w.offsetWidth > 0) + .sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0')); + for (const w of psWins) { + const ok = w.querySelector('button.webBtn, .pressDefault'); + if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; } + } + return false; + })()`; +} + +/** Click the × CloseButton in the topmost visible cloud window (closes "Отчет об ошибке"). */ +export function clickReportCloseButtonScript() { + return `(() => { + const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')] + .filter(w => w.offsetWidth > 0); + for (const w of psWins) { + const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]'); + if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; } + } + })()`; +} diff --git a/.claude/skills/web-test/scripts/engine/core/errors.mjs b/.claude/skills/web-test/scripts/engine/core/errors.mjs index d3945da9..d2b06224 100644 --- a/.claude/skills/web-test/scripts/engine/core/errors.mjs +++ b/.claude/skills/web-test/scripts/engine/core/errors.mjs @@ -1,8 +1,13 @@ -// web-test core/errors v1.17 — Error/modal/platform-dialog handling: dismiss, detect, fetch stack from 1C UI. +// web-test core/errors v1.18 — Error/modal/platform-dialog handling: dismiss, detect, fetch stack from 1C UI. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { page } from './state.mjs'; import { checkErrorsScript } from '../../dom.mjs'; +import { + getOpenReportCoordsScript, isErrorDetailLinkVisibleScript, + readLargestVisibleTextareaScript, clickTopCloudOkButtonScript, + clickReportCloseButtonScript, +} from '../../dom/errors-stack.mjs'; import { waitForStable } from './wait.mjs'; /** @@ -205,12 +210,7 @@ export async function fetchErrorStack(formNum, hasReport) { */ async function fetchStackViaReport(formNum) { // 1. Get coordinates of the OpenReport link and click via mouse (modalSurface blocks JS clicks) - const coords = await page.evaluate((fn) => { - const el = document.getElementById('form' + fn + '_OpenReport#text'); - if (!el || el.offsetWidth <= 2) return null; - const rect = el.getBoundingClientRect(); - return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; - }, formNum); + const coords = await page.evaluate(getOpenReportCoordsScript(formNum)); if (!coords) return null; await page.mouse.click(coords.x, coords.y); @@ -219,13 +219,7 @@ async function fetchStackViaReport(formNum) { let found = false; for (let i = 0; i < 20; i++) { await page.waitForTimeout(500); - found = await page.evaluate(() => { - const links = document.querySelectorAll('a, [class*="hyper"], span'); - for (const el of links) { - if (el.offsetWidth > 0 && el.textContent.includes('подробный текст ошибки')) return true; - } - return false; - }); + found = await page.evaluate(isErrorDetailLinkVisibleScript()); if (found) break; } if (!found) return null; @@ -235,42 +229,17 @@ async function fetchStackViaReport(formNum) { await page.waitForTimeout(2000); // 4. Read the textarea with detailed error text (find the largest visible textarea) - const raw = await page.evaluate(() => { - let best = null; - document.querySelectorAll('textarea').forEach(ta => { - if (ta.offsetWidth > 0 && ta.value.length > 0) { - if (!best || ta.value.length > best.value.length) best = ta; - } - }); - return best?.value || null; - }); + const raw = await page.evaluate(readLargestVisibleTextareaScript()); // 5. Close "Подробный текст ошибки" dialog (click its OK button) try { - const okBtn = await page.evaluate(() => { - // Find the OK button in the topmost small cloud window - const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')] - .filter(w => w.offsetWidth > 0) - .sort((a, b) => parseInt(b.style?.zIndex || '0') - parseInt(a.style?.zIndex || '0')); - for (const w of psWins) { - const ok = w.querySelector('button.webBtn, .pressDefault'); - if (ok && ok.textContent.trim() === 'OK') { ok.click(); return true; } - } - return false; - }); + await page.evaluate(clickTopCloudOkButtonScript()); await page.waitForTimeout(300); } catch {} // 6. Close "Отчет об ошибке" dialog (click its × close button) try { - await page.evaluate(() => { - const psWins = [...document.querySelectorAll('[id^="ps"][id$="win"]')] - .filter(w => w.offsetWidth > 0); - for (const w of psWins) { - const closeBtn = w.querySelector('[id$="_cmd_CloseButton"]'); - if (closeBtn && closeBtn.offsetWidth > 0) { closeBtn.click(); break; } - } - }); + await page.evaluate(clickReportCloseButtonScript()); await page.waitForTimeout(300); } catch {} From 8f0d3937b4c4685abcbf990ef4abf9e3811aa4b6 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 14:18:48 +0300 Subject: [PATCH 40/47] =?UTF-8?q?refactor(web-test):=20scanGridRows=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=BD=D0=B5=D1=81=D0=B5=D0=BD=20=D0=B2=20dom/grid.?= =?UTF-8?q?mjs=20(S9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 25 LOC inline page.evaluate в forms/select-value.mjs:30 → новый экспорт scanGridRowsScript(formNum, searchLower) в dom/grid.mjs (рядом с readTableScript, getSelectedOrLastRowIndexScript). Engine-сторона — тонкая обёртка одной строкой. DOM-extraction iter 2 / S9 из плана. Точечный регресс 04-selectvalue зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/dom/grid.mjs | 38 ++++++++++++++++++- .../scripts/engine/forms/select-value.mjs | 29 ++------------ 2 files changed, 40 insertions(+), 27 deletions(-) diff --git a/.claude/skills/web-test/scripts/dom/grid.mjs b/.claude/skills/web-test/scripts/dom/grid.mjs index 959897f6..82c66806 100644 --- a/.claude/skills/web-test/scripts/dom/grid.mjs +++ b/.claude/skills/web-test/scripts/dom/grid.mjs @@ -1,4 +1,4 @@ -// web-test dom/grid v1.1 — grid resolution + table reading + edit-time helpers +// web-test dom/grid v1.2 — grid resolution + table reading + edit-time helpers // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** @@ -319,3 +319,39 @@ export function getSelectedOrLastRowIndexScript(gridSelector) { return sel >= 0 ? sel : lines.length - 1; })()`; } + +/** + * Scan a form's grid for a row matching `searchLower` (case- and ё-insensitive, + * NBSP-normalised). Match order: exact → startsWith → includes. + * + * When `searchLower` is empty, returns coords of the first row (fallback). + * + * Returns `{ rowCount, x, y, isGroup } | { rowCount: 0 } | null`. + */ +export function scanGridRowsScript(formNum, searchLower) { + return `(() => { + const p = 'form${formNum}_'; + const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); + if (!grid) return null; + const body = grid.querySelector('.gridBody'); + if (!body) return null; + const lines = [...body.querySelectorAll('.gridLine')]; + if (!lines.length) return { rowCount: 0 }; + const searchLower = ${JSON.stringify(searchLower || '')}; + let sel = null; + if (searchLower) { + const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е'); + const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) })); + sel = rowData.find(r => r.text === searchLower)?.el + || rowData.find(r => r.text.startsWith(searchLower))?.el + || rowData.find(r => r.text.includes(searchLower))?.el; + } else { + sel = lines[0]; // empty search → first row + } + if (!sel) return null; + const imgBox = sel.querySelector('.gridBoxImg'); + const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false; + const r = sel.getBoundingClientRect(); + return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup }; + })()`; +} diff --git a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs index e906b902..78c38731 100644 --- a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs @@ -1,4 +1,4 @@ -// web-test forms/select-value v1.18 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. +// web-test forms/select-value v1.19 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -11,6 +11,7 @@ import { findPatternInputIdScript, isTypeDialogScript, isNotInListCloudVisibleScript, findChildFormByButtonScript, readTypeDialogVisibleRowsScript, } from '../../dom.mjs'; +import { scanGridRowsScript } from '../../dom/grid.mjs'; import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; import { waitForStable, waitForCondition } from '../core/wait.mjs'; import { highlight, unhighlight } from '../recording/highlight.mjs'; @@ -28,31 +29,7 @@ import { getFormState } from './state.mjs'; * When searchLower is empty, returns coords of the first row (fallback). */ async function scanGridRows(formNum, searchLower) { - return page.evaluate(`(() => { - const p = 'form${formNum}_'; - const grid = document.querySelector('[id^="' + p + '"].grid, [id^="' + p + '"] .grid'); - if (!grid) return null; - const body = grid.querySelector('.gridBody'); - if (!body) return null; - const lines = [...body.querySelectorAll('.gridLine')]; - if (!lines.length) return { rowCount: 0 }; - const searchLower = ${JSON.stringify(searchLower || '')}; - let sel = null; - if (searchLower) { - const norm = s => (s || '').replace(/\\u00a0/g, ' ').trim().toLowerCase().replace(/ё/gi, 'е'); - const rowData = lines.map(l => ({ el: l, text: norm(l.innerText) })); - sel = rowData.find(r => r.text === searchLower)?.el - || rowData.find(r => r.text.startsWith(searchLower))?.el - || rowData.find(r => r.text.includes(searchLower))?.el; - } else { - sel = lines[0]; // empty search → first row - } - if (!sel) return null; - const imgBox = sel.querySelector('.gridBoxImg'); - const isGroup = imgBox ? !!imgBox.querySelector('.gridListH') : false; - const r = sel.getBoundingClientRect(); - return { rowCount: lines.length, x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), isGroup }; - })()`); + return page.evaluate(scanGridRowsScript(formNum, searchLower)); } /** From 961f27afb0b4b4978001762a52d80af9c07edc8c Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 14:33:21 +0300 Subject: [PATCH 41/47] refactor(web-test): deleteTableRow coords + reuse countGridRows (S10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 19 LOC inline cellCoords + 8 LOC inline row-count в engine/table/grid.mjs deleteTableRow → новый findDeleteRowCoordsScript в dom/grid.mjs + переиспользован существующий countGridRowsScript (dom/grid.mjs:268, добавлен в S5). Engine-сторона deleteTableRow становится чистым оркестратором: resolve grid → get coords → click → Delete → count rows. DOM-extraction iter 2 / S10 из плана. Точечный регресс 05-table зелёный + полный регресс 19/19 зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/scripts/dom/grid.mjs | 28 +++++++++++++++- .../web-test/scripts/engine/table/grid.mjs | 32 +++---------------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/.claude/skills/web-test/scripts/dom/grid.mjs b/.claude/skills/web-test/scripts/dom/grid.mjs index 82c66806..28bc9eec 100644 --- a/.claude/skills/web-test/scripts/dom/grid.mjs +++ b/.claude/skills/web-test/scripts/dom/grid.mjs @@ -1,4 +1,4 @@ -// web-test dom/grid v1.2 — grid resolution + table reading + edit-time helpers +// web-test dom/grid v1.3 — grid resolution + table reading + edit-time helpers // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills /** @@ -261,6 +261,32 @@ function gridResolver(gridSelector) { : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`; } +/** + * Find center coords of a target row for click-select (used by deleteTableRow). + * Picks the second visible gridBox container in the row (skips row-number/checkbox col). + * + * Returns `{ x, y, total } | { error: 'no_grid'|'no_grid_body'|'row_out_of_range'|'no_cell', total? }`. + */ +export function findDeleteRowCoordsScript(gridSelector, row) { + return `(() => { + const grid = ${gridResolver(gridSelector)}; + if (!grid) return { error: 'no_grid' }; + const body = grid.querySelector('.gridBody'); + if (!body) return { error: 'no_grid_body' }; + const rows = [...body.querySelectorAll('.gridLine')]; + if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; + const line = rows[${row}]; + // Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes + const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); + // Skip first column (row number / checkbox) — pick second visible box + const box = boxes.length > 1 ? boxes[1] : boxes[0]; + if (!box) return { error: 'no_cell' }; + const cell = box.querySelector('.gridBoxText') || box; + const r = cell.getBoundingClientRect(); + return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length }; + })()`; +} + /** * Count `.gridLine` rows in the body of the target grid. * Returns the row count, or `0` when grid/body absent. diff --git a/.claude/skills/web-test/scripts/engine/table/grid.mjs b/.claude/skills/web-test/scripts/engine/table/grid.mjs index 55bf457c..70601d5e 100644 --- a/.claude/skills/web-test/scripts/engine/table/grid.mjs +++ b/.claude/skills/web-test/scripts/engine/table/grid.mjs @@ -1,4 +1,4 @@ -// web-test table/grid v1.18 — Form-grid operations: read table rows, fill rows, delete rows. +// web-test table/grid v1.19 — Form-grid operations: read table rows, fill rows, delete rows. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills // // "Grid" в терминах 1С — таблица на форме (.gridLine/.gridBody/.grid в DOM): @@ -7,6 +7,7 @@ import { page, ensureConnected } from '../core/state.mjs'; import { detectFormScript, readTableScript, resolveGridScript } from '../../dom.mjs'; +import { findDeleteRowCoordsScript, countGridRowsScript } from '../../dom/grid.mjs'; import { dismissPendingErrors } from '../core/errors.mjs'; import { waitForStable } from '../core/wait.mjs'; import { clickElement } from '../core/click.mjs'; @@ -56,25 +57,7 @@ export async function deleteTableRow(row, { tab, table } = {}) { } // 2. Find the target row and click to select it - const cellCoords = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return { error: 'no_grid' }; - const body = grid.querySelector('.gridBody'); - if (!body) return { error: 'no_grid_body' }; - const rows = [...body.querySelectorAll('.gridLine')]; - if (${row} >= rows.length) return { error: 'row_out_of_range', total: rows.length }; - const line = rows[${row}]; - // Use visible gridBox containers (not gridBoxText) to avoid clicking checkboxes - const boxes = [...line.children].filter(b => b.offsetWidth > 0 && !b.classList.contains('gridBoxComp')); - // Skip first column (row number / checkbox) — pick second visible box - const box = boxes.length > 1 ? boxes[1] : boxes[0]; - if (!box) return { error: 'no_cell' }; - const cell = box.querySelector('.gridBoxText') || box; - const r = cell.getBoundingClientRect(); - return { x: Math.round(r.x + r.width / 2), y: Math.round(r.y + r.height / 2), total: rows.length }; - })()`); + const cellCoords = await page.evaluate(findDeleteRowCoordsScript(gridSelector, row)); if (cellCoords.error) throw new Error(`deleteTableRow: ${cellCoords.error}${cellCoords.total ? ' (total rows: ' + cellCoords.total + ')' : ''}`); @@ -89,14 +72,7 @@ export async function deleteTableRow(row, { tab, table } = {}) { await waitForStable(); // 4. Count rows after deletion - const rowsAfter = await page.evaluate(`(() => { - const grid = ${gridSelector - ? `document.querySelector(${JSON.stringify(gridSelector)})` - : `(() => { const grids = [...document.querySelectorAll('.grid')].filter(el => el.offsetWidth > 0); return grids[grids.length - 1]; })()`}; - if (!grid) return 0; - const body = grid.querySelector('.gridBody'); - return body ? body.querySelectorAll('.gridLine').length : 0; - })()`); + const rowsAfter = await page.evaluate(countGridRowsScript(gridSelector)); return returnFormState({ deleted: row, rowsBefore, rowsAfter }); } From 6e093517309226bee38e543a1a9eaeaec376f8aa Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 15:09:24 +0300 Subject: [PATCH 42/47] =?UTF-8?q?refactor(web-test):=20returnFormState=20i?= =?UTF-8?q?dem=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF=20(Phase=202,=205=20=D1=81?= =?UTF-8?q?=D0=B0=D0=B9=D1=82=D0=BE=D0=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дедуп 5 идемпотентных хвостов action-функций — паттерн getFormState + state.X = ... + checkForErrors + if(err) state.errors = err + return сводится к одному returnFormState(extras). Поведение identical. - core/click.mjs: ветка popupItem (1 сайт) - forms/select-value.mjs: openFormAndPick helper, DLB dropdown match, DLB dropdown no-search-first, selection-form direct 3B (4 сайта) Аудит upload/returnFormState-audit.md обещал 13 idem, но при разборе: - click.mjs:final (custom confirmation/hint), close.mjs save=undefined (R3 предупреждение), 6 сайтов fillReferenceField (приватный shape {field, ok, method, value}, не form-state) — осознанно НЕ конвертированы. Реальный Phase 2 — 5 сайтов. Полный регресс 19/19 зелёный (после rebuild стенда — старый стенд накопил 22 Контрагента вместо 4 за прошлые прогоны, не связано с Phase 2). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web-test/scripts/engine/core/click.mjs | 8 ++--- .../scripts/engine/forms/select-value.mjs | 36 ++++++------------- 2 files changed, 13 insertions(+), 31 deletions(-) diff --git a/.claude/skills/web-test/scripts/engine/core/click.mjs b/.claude/skills/web-test/scripts/engine/core/click.mjs index 2e17e604..3fee9659 100644 --- a/.claude/skills/web-test/scripts/engine/core/click.mjs +++ b/.claude/skills/web-test/scripts/engine/core/click.mjs @@ -1,4 +1,4 @@ -// web-test core/click v1.18 — clickElement dispatcher: spreadsheet cells, submenus, grid groups/trees, buttons/links, tabs. +// web-test core/click v1.19 — clickElement dispatcher: spreadsheet cells, submenus, grid groups/trees, buttons/links, tabs. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -90,11 +90,7 @@ export async function clickElement(text, { dblclick, table, toggle, expand, modi await page.mouse.click(found.x, found.y); } await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'popupItem', name: found.name }; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; + return returnFormState({ clicked: { kind: 'popupItem', name: found.name } }); } // No match in popup — fall through to form elements } diff --git a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs index 78c38731..953b25cf 100644 --- a/.claude/skills/web-test/scripts/engine/forms/select-value.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/select-value.mjs @@ -1,4 +1,4 @@ -// web-test forms/select-value v1.19 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. +// web-test forms/select-value v1.20 — Reference & composite-type value selection: selectValue, fillReferenceField, selection/type-dialog pickers. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -677,13 +677,10 @@ export async function selectValue(fieldName, searchText, { type } = {}) { const selFormNum = await detectSelectionForm(); if (selFormNum !== null) { const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; - if (pickResult.error) state.selected.error = pickResult.error; - if (pickResult.message) state.selected.message = pickResult.message; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; + const selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; + if (pickResult.error) selected.error = pickResult.error; + if (pickResult.message) selected.message = pickResult.message; + return returnFormState({ selected }); } return null; } @@ -717,11 +714,7 @@ export async function selectValue(fieldName, searchText, { type } = {}) { // Click via evaluate to bypass div.surface overlay await clickEddItem(match.name); await waitForStable(); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText, method: 'dropdown' }; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; + return returnFormState({ selected: { field: btn.fieldName, search: searchText, method: 'dropdown' } }); } // No match in dropdown — try "Показать все" to open selection form @@ -755,11 +748,7 @@ export async function selectValue(fieldName, searchText, { type } = {}) { if (regularItems.length > 0) { await clickEddItem(regularItems[0].name); await waitForStable(); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: null, picked: regularItems[0].name, method: 'dropdown' }; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; + return returnFormState({ selected: { field: btn.fieldName, search: null, picked: regularItems[0].name, method: 'dropdown' } }); } } @@ -773,13 +762,10 @@ export async function selectValue(fieldName, searchText, { type } = {}) { throw new Error(`selectValue: field "${btn.fieldName}" opened a type selection dialog — this is a composite-type field. Specify the type: selectValue('${btn.fieldName}', '${searchText || ''}', { type: 'ИмяТипа' })`); } const pickResult = await pickFromSelectionForm(selFormNum, btn.fieldName, searchText || '', formNum); - const state = await getFormState(); - state.selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; - if (pickResult.error) state.selected.error = pickResult.error; - if (pickResult.message) state.selected.message = pickResult.message; - const err = await checkForErrors(); - if (err) state.errors = err; - return state; + const selected = { field: btn.fieldName, search: searchText || null, method: 'form' }; + if (pickResult.error) selected.error = pickResult.error; + if (pickResult.message) selected.message = pickResult.message; + return returnFormState({ selected }); } // 3C. Neither popup nor form — try F4 as last resort From 07353c416e65632fc6617ae28a152ab3cf140370 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 16:27:46 +0300 Subject: [PATCH 43/47] =?UTF-8?q?refactor(web-test):=20=D1=83=D0=BD=D0=B8?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D1=8F=20shape=20fillFields?= =?UTF-8?q?=20+=20fillTableRow=20(Phase=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Все action-функции теперь возвращают плоский form state с extras — закрыта последняя аномалия API. Раньше: - fillFields → {filled, form} (вложенный, документировано в SKILL.md) - fillTableRow → 3 разных shape в 5 ветках (array | {filled, form} | {filled, notFilled, form}), при этом документация заявляла плоский — код её игнорировал Теперь обе функции используют returnFormState({filled, notFilled?}) — тот же паттерн что у всех action-функций после Phase 1+2 (clickElement, selectValue, closeForm, filterList и т.д.). Что закрывает: 1. Тихий баг в production-клиенте C:\WS\projects\titan\tests\helpers\query.mjs на res.filled?.find() — array-ветки fillTableRow возвращали [{...}] без .filled → ошибки заполнения параметров запросов молча пропускались. R1/R2-аналог. 2. Костыли r.filled || r в tests/web-test/05-table.test.mjs (2 места) — убраны, поскольку polymorphism устранён. 3. Расхождение код ↔ документация в fillTableRow. 4. Внутренний polymorphism в row-fill.mjs: убраны два `if (Array.isArray(more))` костыля в рекурсивных вызовах самого fillTableRow. Файлы: - engine/forms/fill.mjs v1.17 → v1.18 (1 ветка → returnFormState) - engine/table/row-fill.mjs v1.17 → v1.18 (5 веток + 2 рекурсии) - tests/web-test/05-table.test.mjs (r.filled || r → r.filled) - .claude/skills/web-test/SKILL.md (сигнатуры fillFields/fillTableRow + общая ремарка про плоский return shape в начале раздела Actions) - docs/web-test-guide.md (строки fillFields/fillTableRow/navigateSection; общая ремарка в начале раздела «Действия») В тестах ни один кейс не обращался к .form.X, blast radius нулевой. Точечный регресс (03/05/06/07/10/16) и полный регресс 19/19 — зелёные. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/SKILL.md | 10 ++++-- .../web-test/scripts/engine/forms/fill.mjs | 13 ++++---- .../scripts/engine/table/row-fill.mjs | 33 +++++++------------ docs/web-test-guide.md | 8 +++-- tests/web-test/05-table.test.mjs | 4 +-- 5 files changed, 32 insertions(+), 36 deletions(-) diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index b07c90a1..8324dc8d 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -217,6 +217,8 @@ Sections + all open tabs. ### Actions +**Return shape convention.** All action functions return a **flat form state** (same shape as `getFormState()`) with action-specific extras: `clicked`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`. Errors always sit at the top level under `.errors` (when present) — the exec-wrapper automatically throws on `.errors.modal` / `.errors.balloon`. + #### `clickElement(text, { dblclick?, table?, expand?, modifier? })` → form state Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match). @@ -267,7 +269,7 @@ Click button, hyperlink, tab, navigation panel link, or grid row (fuzzy match). await clickElement('150 000', { dblclick: true }); // finds cell by text in report ``` -#### `fillFields({ name: value })` → `{ filled, form }` +#### `fillFields({ name: value })` → form state with `filled` Fill form fields by label (fuzzy match). Auto-detects field type. | Value | Field type | Method | @@ -286,7 +288,7 @@ await fillFields({ }); ``` -Returns `{ filled: [{ field, ok, value, method }], form: {...} }`. +Returns form state with `filled: [{ field, ok, value, method }]`. Method is one of: `'clear'` | `'toggle'` | `'radio'` | `'paste'` | `'dropdown'` | `'form'` | `'typeahead'` #### `selectValue(field, search, opts?)` → form state with `selected` @@ -310,9 +312,11 @@ await selectValue('Документ', '0000-000601', { type: 'Реализаци Also supports DCS labels — auto-enables the paired checkbox. -#### `fillTableRow(fields, opts)` → form state +#### `fillTableRow(fields, opts)` → form state with `filled` (+ optional `notFilled`) Fill table row cells via Tab navigation. Value is a plain string, `{ value, type }` for composite-type cells, or `''`/`null` to clear (Shift+F4). +Returns form state with `filled: [{ field, ok, method, value }]`. If some requested fields weren't reached (Tab loop couldn't find them), `notFilled: [...]` lists their names. + | Option | Description | |--------|-------------| | `tab` | Switch to tab before filling | diff --git a/.claude/skills/web-test/scripts/engine/forms/fill.mjs b/.claude/skills/web-test/scripts/engine/forms/fill.mjs index 1bb1deaf..bada6f2c 100644 --- a/.claude/skills/web-test/scripts/engine/forms/fill.mjs +++ b/.claude/skills/web-test/scripts/engine/forms/fill.mjs @@ -1,11 +1,11 @@ -// web-test forms/fill v1.17 — Fill form fields by name (text/checkbox/date/dropdown/reference). Delegates references to selectValue / fillReferenceField. +// web-test forms/fill v1.18 — 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, + detectFormScript, resolveFieldsScript, } from '../../dom.mjs'; import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; import { waitForStable, startNetworkMonitor } from '../core/wait.mjs'; @@ -13,9 +13,9 @@ import { highlight, unhighlight } from '../recording/highlight.mjs'; import { fillReferenceField, selectValue, pickFromSelectionForm, isTypeDialog, pickFromTypeDialog, -} from './select-value.mjs'; -import { pasteText } from '../core/clipboard.mjs'; -import { getFormState } from './state.mjs'; +} from './select-value.mjs'; +import { pasteText } from '../core/clipboard.mjs'; +import { returnFormState } from '../core/helpers.mjs'; /** Fill fields on the current form via Playwright page.fill(). Returns fill results + updated form. */ export async function fillFields(fields) { @@ -132,13 +132,12 @@ export async function fillFields(fields) { 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 }; + return returnFormState({ filled: results }); } /** Convenience alias: fill a single field. Same as fillFields({ name: value }). */ diff --git a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs index 262967d8..8f0ca11e 100644 --- a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs +++ b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs @@ -1,4 +1,4 @@ -// web-test table/row-fill v1.17 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. +// web-test table/row-fill v1.18 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -18,7 +18,7 @@ import { dismissPendingErrors, checkForErrors } from '../core/errors.mjs'; import { waitForStable, waitForCondition, startNetworkMonitor } from '../core/wait.mjs'; import { highlight, unhighlight } from '../recording/highlight.mjs'; import { - safeClick, findFieldInputId, + safeClick, findFieldInputId, returnFormState, detectNewForm as helperDetectNewForm, isInputFocused, isInputFocusedInGrid, findOpenPopup, readEdd, isEddVisible, clickEddItemViaDispatch, @@ -29,7 +29,6 @@ import { fillReferenceField, selectValue, } from '../forms/select-value.mjs'; import { pasteText } from '../core/clipboard.mjs'; -import { getFormState } from '../forms/state.mjs'; /** * Fill cells in the current table row via Tab navigation. @@ -112,7 +111,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { cellCoords.currentText.toLowerCase().includes(firstVal0.toLowerCase())) { firstFieldSkipped = true; if (Object.keys(fields).length === 1) { - return [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }]; + return returnFormState({ filled: [{ field: firstKey0, ok: true, method: 'skip', value: cellCoords.currentText }] }); } } @@ -146,11 +145,9 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { delete remaining[firstKey0]; if (Object.keys(remaining).length > 0) { const more = await fillTableRow(remaining, { row, table }); - if (Array.isArray(more)) results.push(...more); - else if (more?.filled) results.push(...more.filled); + results.push(...more.filled); } - const formData = await getFormState(); - return { filled: results, form: formData }; + return returnFormState({ filled: results }); } // Check if clicked cell is a checkbox (toggle-on-click, no edit mode) @@ -169,9 +166,9 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { delete remaining[firstKey0]; if (Object.keys(remaining).length > 0) { const more = await fillTableRow(remaining, { row, table }); - results.push(...more); + results.push(...more.filled); } - return results; + return returnFormState({ filled: results }); } let inEdit = false; @@ -338,7 +335,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await page.keyboard.press('Escape'); } await waitForStable(formNum); - return results; + return returnFormState({ filled: results }); } if (!inEdit) throw new Error(`fillTableRow: click on row ${row} did not enter edit mode`); @@ -767,11 +764,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { ); if (currentRow >= 0) { const more = await fillTableRow(checkboxFields, { row: currentRow, table }); - if (Array.isArray(more)) { - results.push(...more); - } else if (more?.filled) { - results.push(...more.filled); - } + results.push(...more.filled); for (const key of Object.keys(checkboxFields)) { const idx = notFilled.indexOf(key); if (idx >= 0) notFilled.splice(idx, 1); @@ -780,11 +773,9 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { } } - const formData = await getFormState(); - const result = { filled: results }; - if (notFilled.length > 0) result.notFilled = notFilled; - result.form = formData; - return result; + const extras = { filled: results }; + if (notFilled.length > 0) extras.notFilled = notFilled; + return returnFormState(extras); } catch (e) { if (e.message.startsWith('fillTableRow:')) throw e; diff --git a/docs/web-test-guide.md b/docs/web-test-guide.md index e0bd2433..2d6b71de 100644 --- a/docs/web-test-guide.md +++ b/docs/web-test-guide.md @@ -218,7 +218,7 @@ console.log('Расшифровка:', JSON.stringify(drilldown.rows)); | Функция | Описание | Возвращает | |---------|----------|------------| -| `navigateSection(name)` | Перейти в раздел (fuzzy match) | `{ sections, commands }` | +| `navigateSection(name)` | Перейти в раздел (fuzzy match) | form state с `navigated`, `sections`, `commands` | | `openCommand(name)` | Открыть команду из панели функций | form state | | `navigateLink(path)` | Открыть по пути метаданных (`Документ.ЗаказКлиента`) | form state | | `openFile(path)` | Открыть внешнюю обработку/отчёт (EPF/ERF) через «Файл → Открыть» | form state | @@ -286,12 +286,14 @@ await clickElement('150 000', { dblclick: true }); // найдёт ячейку ### Действия +Все action-функции возвращают **плоский form state** (как `getFormState()`) с action-specific extras (`clicked`, `selected`, `filled`, `notFilled`, `closed`, `opened`, `navigated`, `deleted`, `filtered`, `unfiltered`). Errors всегда на верхнем уровне `.errors` — exec-wrapper автоматически throw'ает на soft validation errors (`modal`/`balloon`). + | Функция | Описание | Возвращает | |---------|----------|------------| | `clickElement(text, {dblclick?, modifier?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия, `{modifier: 'ctrl'\|'shift'}` для мультиселекции. Первый аргумент может быть `{row, column}` для клика по ячейке SpreadsheetDocument (см. выше) | form state или `{ submenu }` | -| `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры). Пустое значение (`''`/`null`) = очистка | `{ filled: [{field, ok, method}], form }` | +| `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры). Пустое значение (`''`/`null`) = очистка | form state с `filled` | | `selectValue(field, search, opts?)` | Выбрать из справочника. search: текст, `{поле: значение}` или `''`/`null` для очистки. `{ type }` для составного типа | form state с `selected` | -| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка, `{ value, type }` для составного типа, `''`/`null` для очистки | form state | +| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка, `{ value, type }` для составного типа, `''`/`null` для очистки | form state с `filled` (+ `notFilled?`) | | `deleteTableRow(row, {tab?})` | Удалить строку по индексу | form state | | `closeForm({save?})` | Закрыть форму. `save: false` = "Нет", `save: true` = "Да". Возвращает `closed: true/false` | form state с `closed` | | `filterList(text, {field?, exact?})` | Фильтр списка. Без field = все колонки, с field = расширенный поиск | form state | diff --git a/tests/web-test/05-table.test.mjs b/tests/web-test/05-table.test.mjs index 3285c5e7..8368cfa2 100644 --- a/tests/web-test/05-table.test.mjs +++ b/tests/web-test/05-table.test.mjs @@ -53,7 +53,7 @@ export default async function({ navigateSection, openCommand, clickElement, fill { 'Согласовано': true }, { table: 'Товары', row: 1 } ); - log(`checkbox result: ${JSON.stringify(r.filled || r)}`); + log(`checkbox result: ${JSON.stringify(r.filled)}`); const t = await readTable({ table: 'Товары' }); log(`row 1 Согласовано='${t.rows[1]['Согласовано']}'`); assert.equal(t.rows[1]['Согласовано'], 'true', 'Согласовано должно стать true'); @@ -65,7 +65,7 @@ export default async function({ navigateSection, openCommand, clickElement, fill { 'Номенклатура': '' }, { table: 'Товары', row: 0 } ); - log(`clear result: ${JSON.stringify(r.filled || r)}`); + log(`clear result: ${JSON.stringify(r.filled)}`); const t = await readTable({ table: 'Товары' }); log(`row 0 Номенклатура after clear='${t.rows[0]['Номенклатура']}'`); assert.equal(t.rows[0]['Номенклатура'], '', 'Номенклатура должна быть очищена (Shift+F4)'); From a9949ff5fed88a750e910d1ebff9c1ea1736227a Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 17:09:59 +0300 Subject: [PATCH 44/47] =?UTF-8?q?refactor(web-test):=20uniform=20ok:true/f?= =?UTF-8?q?alse=20=D0=B2=20filled-items=20+=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=82=20fillTableRow=20(Phase=204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше per-item shape в filled[] был heterogeneous: - success: {field, ok: true, method, value} - failure: {field, error, message} ← без ok! Естественная проверка `item.ok === false` молча промахивалась (latent bug в production-клиенте Titan C:\WS\projects\titan\tests\helpers\query.mjs:69). И документация утверждала «все функции throw'ают на ошибке» (guide.md:352), что для fillTableRow было неправдой. Что изменено: - engine/table/row-fill.mjs v1.18 → v1.19: 15 error-pushes теперь включают ok: false. item.ok — единый дискриминатор success/failure. - SKILL.md: fillFields раздел уточнён («throws on per-field failure — если вернулся, всё заполнено»). fillTableRow раздел: документирует контракт (НЕ throws на per-field), перечисляет error-коды и recovery hints (composite_type → retry с {value, type}, column_not_found → проверить readTable, и т.д.). - docs/web-test-guide.md: строка 352 нюансирована (fillTableRow исключение из «все throw'ают»); строка 296 (таблица) уточнена. Контракт обеих функций теперь сознательно различается и явно описан: - fillFields = fail-fast (throws на любую ошибку, удобно для fill-and-go) - fillTableRow = partial-recovery (errors в filled[] как ok:false, модель может retry'нуть селективно отдельную ячейку) Бонус: query.mjs в Titan'е теперь работает корректно без правки клиентского кода — cell.ok === false наконец-то дискриминирует error-items. Полный регресс 19/19 зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/skills/web-test/SKILL.md | 7 ++-- .../scripts/engine/table/row-fill.mjs | 32 +++++++++---------- docs/web-test-guide.md | 7 ++-- 3 files changed, 25 insertions(+), 21 deletions(-) diff --git a/.claude/skills/web-test/SKILL.md b/.claude/skills/web-test/SKILL.md index 8324dc8d..5d34f789 100644 --- a/.claude/skills/web-test/SKILL.md +++ b/.claude/skills/web-test/SKILL.md @@ -288,8 +288,7 @@ await fillFields({ }); ``` -Returns form state with `filled: [{ field, ok, value, method }]`. -Method is one of: `'clear'` | `'toggle'` | `'radio'` | `'paste'` | `'dropdown'` | `'form'` | `'typeahead'` +Returns form state with `filled: [{ field, ok: true, value, method }]` (method: `clear`|`toggle`|`radio`|`paste`|`dropdown`|`form`|`typeahead`). **Throws on any per-field failure** with a detailed message listing problematic fields and available options — if the call returned, all fields were filled, no per-item check needed. #### `selectValue(field, search, opts?)` → form state with `selected` Select a value from reference field via dropdown or selection form. More reliable than `fillFields` for reference fields that need exact selection from a catalog. Pass empty `search` (`''` or `null`) to clear the field (Shift+F4). @@ -315,7 +314,9 @@ Also supports DCS labels — auto-enables the paired checkbox. #### `fillTableRow(fields, opts)` → form state with `filled` (+ optional `notFilled`) Fill table row cells via Tab navigation. Value is a plain string, `{ value, type }` for composite-type cells, or `''`/`null` to clear (Shift+F4). -Returns form state with `filled: [{ field, ok, method, value }]`. If some requested fields weren't reached (Tab loop couldn't find them), `notFilled: [...]` lists their names. +Returns form state with `filled: [{ field, ok, ...}]`. Items are `{ field, ok: true, method, value }` on success (method: `direct`|`paste`|`dropdown`|`form`|`type-direct`|`skip`|`clear`|`toggle`) or `{ field, ok: false, error, message }` on per-field failure. Unmatched fields → `notFilled: [...]`. + +**Unlike `fillFields`, `fillTableRow` does NOT throw on per-field failures** — errors appear as `ok: false` items in `filled[]` so the caller can react selectively (e.g. retry one cell while the rest of the row stays filled). Check via `r.filled.filter(f => !f.ok)`. Error codes: `composite_type`/`type_required`/`type_dialog_failed` (retry with `{value, type}`); `column_not_found` (check column name via `readTable`); `no_selection_form`/`no_selection_after_type` (retry or fall back to `selectValue`); `not_found`/`no_match`/`ambiguous` (refine search text); `still_open` (picked a group — pick a leaf row). Soft validation errors from 1C (`balloon`, `modal`) still throw via the exec-wrapper. | Option | Description | |--------|-------------| diff --git a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs index 8f0ca11e..3ce7ed78 100644 --- a/.claude/skills/web-test/scripts/engine/table/row-fill.mjs +++ b/.claude/skills/web-test/scripts/engine/table/row-fill.mjs @@ -1,4 +1,4 @@ -// web-test table/row-fill v1.18 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. +// web-test table/row-fill v1.19 — fillTableRow — заполнение строки табличной части/списка через Tab-навигацию и попутный выбор значений. // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills import { @@ -242,17 +242,17 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { // After type selection, detect the actual selection form selForm = await helperDetectNewForm(formNum); if (selForm === null) { - return { field: key, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` }; + return { field: key, ok: false, error: 'no_selection_after_type', message: `Type selected but no selection form opened for "${key}"` }; } } else { // No type specified — close type dialog and report error await page.keyboard.press('Escape'); await page.waitForTimeout(300); - return { field: key, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` }; + return { field: key, ok: false, error: 'composite_type', message: `Composite type field "${key}" requires {value, type}` }; } } const pr = await pickFromSelectionForm(selForm, key, info.value, formNum); - return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, error: pr.error, message: pr.message }; + return pr.ok ? { field: key, ok: true, method: 'form' } : { field: key, ok: false, error: pr.error, message: pr.message }; } // First field: selection form is already open from the dblclick above @@ -277,7 +277,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { const nextCoords = await page.evaluate(findNextCellCoordsByKeyScript(gridSelector, row, key)); if (!nextCoords) { info.filled = true; - results.push({ field: key, error: 'column_not_found', message: `Column for "${key}" not found` }); + results.push({ field: key, ok: false, error: 'column_not_found', message: `Column for "${key}" not found` }); continue; } // Skip if cell already contains the desired value @@ -319,7 +319,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { } if (selForm === null) { info.filled = true; - results.push({ field: key, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` }); + results.push({ field: key, ok: false, error: 'no_selection_form', message: `Dblclick on "${key}" did not open selection form` }); continue; } const pr = await directEditPick(selForm, key, info); @@ -511,7 +511,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { info.filled = true; results.push(pickResult.ok ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type } - : { field: matchedKey, cell: cell.fullName, + : { field: matchedKey, cell: cell.fullName, ok: false, error: pickResult.error, message: pickResult.message }); continue; } @@ -521,7 +521,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await page.waitForTimeout(300); } info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, + results.push({ field: matchedKey, cell: cell.fullName, ok: false, error: 'type_dialog_failed', message: `Cell "${matchedKey}": F4 did not open type dialog for type "${info.type}"` }); await page.keyboard.press('Tab'); @@ -539,7 +539,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { if (!inputAfterPaste && text) { // No type specified — can't fill this composite-type cell info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, + results.push({ field: matchedKey, cell: cell.fullName, ok: false, error: 'type_required', message: `Cell "${matchedKey}" rejected text input (composite-type). Use { value: '...', type: 'Тип' } syntax` }); await page.keyboard.press('Tab'); @@ -575,7 +575,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await page.keyboard.press('Escape'); await page.waitForTimeout(300); info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, + results.push({ field: matchedKey, cell: cell.fullName, ok: false, error: 'not_found', message: `No match for "${text}"` }); } @@ -611,16 +611,16 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { continue; } // Not found in selection form — fall through to clear + skip - results.push({ field: matchedKey, cell: cell.fullName, + results.push({ field: matchedKey, cell: cell.fullName, ok: false, error: pickResult.error, message: pickResult.message }); } else { info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, + results.push({ field: matchedKey, cell: cell.fullName, ok: false, error: 'not_found', message: `Value "${text}" not in list` }); } } else { info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, + results.push({ field: matchedKey, cell: cell.fullName, ok: false, error: 'not_found', message: `Value "${text}" not in list` }); } @@ -686,7 +686,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { info.filled = true; results.push(pickResult.ok ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form', type: info.type } - : { field: matchedKey, cell: cell.fullName, + : { field: matchedKey, cell: cell.fullName, ok: false, error: pickResult.error, message: pickResult.message }); continue; } else { @@ -699,7 +699,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { await page.keyboard.press('Tab'); await page.waitForTimeout(500); info.filled = true; - results.push({ field: matchedKey, cell: cell.fullName, + results.push({ field: matchedKey, cell: cell.fullName, ok: false, error: 'type_required', message: `Cell "${matchedKey}" opened a type selection dialog. Use { value: '...', type: 'Тип' } syntax` }); continue; @@ -710,7 +710,7 @@ export async function fillTableRow(fields, { tab, add, row, table } = {}) { info.filled = true; results.push(pickResult.ok ? { field: matchedKey, cell: cell.fullName, ok: true, method: 'form' } - : { field: matchedKey, cell: cell.fullName, + : { field: matchedKey, cell: cell.fullName, ok: false, error: pickResult.error, message: pickResult.message }); continue; } diff --git a/docs/web-test-guide.md b/docs/web-test-guide.md index 2d6b71de..74cb3bf9 100644 --- a/docs/web-test-guide.md +++ b/docs/web-test-guide.md @@ -293,7 +293,7 @@ await clickElement('150 000', { dblclick: true }); // найдёт ячейку | `clickElement(text, {dblclick?, modifier?})` | Клик по кнопке/ссылке/строке. `{dblclick: true}` для открытия, `{modifier: 'ctrl'\|'shift'}` для мультиселекции. Первый аргумент может быть `{row, column}` для клика по ячейке SpreadsheetDocument (см. выше) | form state или `{ submenu }` | | `fillFields({name: value})` | Заполнить поля (текст, чекбокс, радио, ссылки, DCS-фильтры). Пустое значение (`''`/`null`) = очистка | form state с `filled` | | `selectValue(field, search, opts?)` | Выбрать из справочника. search: текст, `{поле: значение}` или `''`/`null` для очистки. `{ type }` для составного типа | form state с `selected` | -| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка, `{ value, type }` для составного типа, `''`/`null` для очистки | form state с `filled` (+ `notFilled?`) | +| `fillTableRow(fields, {tab?, add?, row?})` | Заполнить строку. Значение: строка, `{ value, type }` для составного типа, `''`/`null` для очистки | form state с `filled` (per-field ошибки как items `ok: false`, см. ниже) + `notFilled?` | | `deleteTableRow(row, {tab?})` | Удалить строку по индексу | form state | | `closeForm({save?})` | Закрыть форму. `save: false` = "Нет", `save: true` = "Да". Возвращает `closed: true/false` | form state с `closed` | | `filterList(text, {field?, exact?})` | Фильтр списка. Без field = все колонки, с field = расширенный поиск | form state | @@ -349,13 +349,16 @@ await page.keyboard.press('F8'); // пример: создать новый э ## Типичные ошибки -Все функции бросают исключение при ошибке (не возвращают `{ error }`). Сценарий прерывается на проблемном шаге с информативным сообщением. В интерактиве — `try/catch` для обработки. +Большинство функций бросают исключение при ошибке. Сценарий прерывается на проблемном шаге с информативным сообщением. В интерактиве — `try/catch` для обработки. + +**Исключение — `fillTableRow`**: на per-field ошибках не throws, а возвращает их в `filled[]` как items с `ok: false` (`{ field, ok: false, error: 'code', message: '...' }`). Это позволяет частичное восстановление: например при `error: 'composite_type'` модель может retry'нуть конкретную ячейку с `{ value, type }` синтаксисом, не перезаполняя всю строку. Проверка — `r.filled.filter(f => !f.ok)`. Жёсткие ошибки (нет формы, table не найдена) и soft validation errors от 1С (balloon/modal) — всё равно throws. | Проблема | Решение | |----------|---------| | `no form found` — форма не открыта | Добавьте `await wait(2)` после навигации | | `not found. Available: ...` — элемент не найден | Проверьте имя через `getFormState()`, используйте вариант из Available | | `fillFields: N of M field(s) failed` | Текст ошибки содержит список проблемных полей и доступные варианты | +| `fillTableRow` вернул item с `ok: false` | См. поле `error` — `composite_type` → retry с `{value, type}`; `column_not_found` → проверьте имя поля через `readTable`; `not_found` → уточните значение поиска | | Пустой `readSpreadsheet()` | Увеличьте `await wait(N)` перед чтением | ## Особенности From 60151c801ffb6cfc28351318f65cd5611408a9f0 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 18:00:48 +0300 Subject: [PATCH 45/47] =?UTF-8?q?refactor(web-test):=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D0=BB=20clickElement=20=D0=BF=D0=BE=20=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B0=D0=BC=20(Phase=205,=20=C2=A710)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core/click.mjs (307 LOC) распилен на тонкий dispatcher (~105 LOC) + 3 доменных handler-файла. Закрывает §10 родительского плана (отклонение на этапе C.10 — «split — наследие плана»). Структура: - core/click.mjs (~105 LOC) — dispatcher: ensureConnected, spreadsheet-cell spec, highlight, confirmation/popup interception, findTarget, dispatch по target.kind - core/helpers.mjs +modifierClick(x, y, modifier, {dbl?}) — общий mouse-click helper с поддержкой Ctrl/Shift модификаторов - forms/click-popup.mjs (~90 LOC) — clickConfirmationButton + tryClickPopupItem (popup/confirmation внутри формы — форменный контекст, не навигация) - forms/click-form.mjs (~107 LOC) — clickFormTarget: button/tab/submenu + netMonitor lifecycle + post-click submenu detection + confirmation hint propagation - table/click-row.mjs (~95 LOC) — clickGridGroupTarget, clickGridTreeNodeTarget, clickGridRowTarget с переиспользованием modifierClick и существующих getGridToggleIcon/shouldClickToggle Контракт dispatcher → handler: (target, ctx) где ctx = {formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector}. Handler возвращает returnFormState({clicked, ...}). Граф зависимостей остаётся деревом: - core/click.mjs → table/click-row, forms/click-popup, forms/click-form, spreadsheet - table/{filter,grid,row-fill}.mjs → core/click.mjs (другие action-функции) - handler-модули → helpers, wait, grid-toggle (НЕ click.mjs) Поведение clickElement 1:1, публичный API без изменений. netMonitor переехал внутрь clickFormTarget со своим try/finally. Confirmation hint propagation (тот сайт что Phase 2 НЕ конвертировал) переехал в clickFormTarget — естественное место. Точечный регресс 7/7 (02-crud, 05-table, 08-hierarchy, 13-misc, 16-tree-form, 11-report, 01-navigation) + полный 19/19 зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../web-test/scripts/engine/core/click.mjs | 412 +++++------------- .../web-test/scripts/engine/core/helpers.mjs | 15 +- .../scripts/engine/forms/click-form.mjs | 107 +++++ .../scripts/engine/forms/click-popup.mjs | 90 ++++ .../scripts/engine/table/click-row.mjs | 95 ++++ 5 files changed, 411 insertions(+), 308 deletions(-) create mode 100644 .claude/skills/web-test/scripts/engine/forms/click-form.mjs create mode 100644 .claude/skills/web-test/scripts/engine/forms/click-popup.mjs create mode 100644 .claude/skills/web-test/scripts/engine/table/click-row.mjs diff --git a/.claude/skills/web-test/scripts/engine/core/click.mjs b/.claude/skills/web-test/scripts/engine/core/click.mjs index 3fee9659..149c403b 100644 --- a/.claude/skills/web-test/scripts/engine/core/click.mjs +++ b/.claude/skills/web-test/scripts/engine/core/click.mjs @@ -1,307 +1,105 @@ -// web-test core/click v1.19 — clickElement dispatcher: spreadsheet cells, submenus, grid groups/trees, buttons/links, tabs. -// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills - -import { - page, ensureConnected, ACTION_WAIT, highlightMode, normYo, -} from './state.mjs'; -import { - detectFormScript, findClickTargetScript, resolveGridScript, readSubmenuScript, -} from '../../dom.mjs'; -import { dismissPendingErrors, checkForErrors, fetchErrorStack } from './errors.mjs'; -import { waitForStable, startNetworkMonitor } from './wait.mjs'; -import { highlight, unhighlight } from '../recording/highlight.mjs'; -import { safeClick, returnFormState } from './helpers.mjs'; -import { getGridToggleIcon, shouldClickToggle } from '../table/grid-toggle.mjs'; -import { - clickSpreadsheetCell, findSpreadsheetCellByText, -} from '../spreadsheet/spreadsheet.mjs'; -import { getFormState } from '../forms/state.mjs'; - -/** 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. */ -export async function clickElement(text, { dblclick, table, toggle, expand, modifier, timeout } = {}) { - ensureConnected(); - // Dispatch to spreadsheet cell handler when first arg is { row, column } - if (typeof text === 'object' && text !== null && text.column != null) { - await dismissPendingErrors(); - return clickSpreadsheetCell(text, { dblclick, modifier }); - } - await dismissPendingErrors(); - if (highlightMode) try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {} - let netMonitor = null; - try { - - // First check if there's a confirmation dialog — click matching button - const pending = await checkForErrors(); - if (pending?.confirmation) { - const btnResult = await page.evaluate(`(() => { - const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || ''; - const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); - const target = ny(${JSON.stringify(text.toLowerCase())}); - const btns = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0); - let best = btns.find(el => ny(norm(el.innerText).toLowerCase()) === target); - if (!best) best = btns.find(el => ny(norm(el.innerText).toLowerCase()).includes(target)); - if (best) { - const r = best.getBoundingClientRect(); - return { name: norm(best.innerText), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }; - } - return { error: 'not_found', available: btns.map(el => norm(el.innerText)).filter(Boolean) }; - })()`); - if (btnResult?.error) throw new Error(`clickElement: "${text}" not found among confirmation buttons. Available: ${btnResult.available?.join(', ') || 'none'}`); - await page.mouse.click(btnResult.x, btnResult.y); - await waitForStable(); - return returnFormState({ clicked: { kind: 'confirmation', name: btnResult.name } }); - } - - // Check if there's an open popup — if so, try to click inside it - const popupItems = await page.evaluate(readSubmenuScript()); - if (Array.isArray(popupItems) && popupItems.length > 0) { - const target = normYo(text.toLowerCase()); - let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target); - if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target)); - if (found) { - // submenuArrow items (group headers like "Создать", "Печать") — hover to expand nested submenu - if (found.kind === 'submenuArrow') { - // page.hover(selector) is more reliable than page.mouse.move(x,y) — - // some submenu groups don't expand with plain mouse.move - if (found.id) { - await page.hover(`[id="${found.id}"]`); - } else { - await page.mouse.move(found.x, found.y); - } - await page.waitForTimeout(ACTION_WAIT); - const nestedItems = await page.evaluate(readSubmenuScript()); - const extras = { clicked: { kind: 'submenuArrow', name: found.name } }; - if (Array.isArray(nestedItems)) { - extras.submenu = nestedItems.map(i => i.name); - extras.hint = 'Call web_click again with a submenu item name to select it'; - } - return returnFormState(extras); - } - // Regular submenu/dropdown items — trusted events required. - // Use mouse.click(x,y) when in viewport; use :visible selector for clipped items - // (same ID can exist hidden in parent cloud AND visible in nested cloud). - const vpHeight = await page.evaluate('window.innerHeight'); - if (found.x && found.y && found.y > 0 && found.y < vpHeight) { - await page.mouse.click(found.x, found.y); - } else if (found.id) { - await page.click(`[id="${found.id}"]:visible`); - } else if (found.x && found.y) { - await page.mouse.click(found.x, found.y); - } - await waitForStable(); - return returnFormState({ clicked: { kind: 'popupItem', name: found.name } }); - } - // No match in popup — fall through to form elements - } - - let formNum = await page.evaluate(detectFormScript()); - if (formNum === null) throw new Error(`clickElement: no form found`); - - // Pre-resolve grid when table is specified - let gridSelector; - if (table) { - const resolved = await page.evaluate(resolveGridScript(formNum, table)); - if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); - gridSelector = resolved.gridSelector; - } - - // Find the target element ID - let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector })); - - // Retry: if not found, a modal form may still be loading (e.g. after F4). - // Wait up to 2s for a new form to appear and re-detect. - if (target?.error) { - for (let retry = 0; retry < 4; retry++) { - await page.waitForTimeout(500); - const newForm = await page.evaluate(detectFormScript()); - if (newForm !== null && newForm !== formNum) { - formNum = newForm; - target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector })); - if (!target?.error) break; - } - } - } - // Fallback: search spreadsheet iframes for text match before giving up - if (target?.error) { - const ssCell = await findSpreadsheetCellByText(formNum, text); - if (ssCell) { - const cx = ssCell.box.x + ssCell.box.width / 2; - const cy = ssCell.box.y + ssCell.box.height / 2; - const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; - if (modKey) await page.keyboard.down(modKey); - if (dblclick) await page.mouse.dblclick(cx, cy); - else await page.mouse.click(cx, cy); - if (modKey) await page.keyboard.up(modKey); - await waitForStable(); - const state = await getFormState(); - state.clicked = { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) }; - return state; - } - throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`); - } - - // Helper: click with optional modifier key (Ctrl/Shift for multi-select) - const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; - async function modClick(x, y) { - if (modKey) await page.keyboard.down(modKey); - await page.mouse.click(x, y); - if (modKey) await page.keyboard.up(modKey); - } - async function modDblClick(x, y) { - if (modKey) await page.keyboard.down(modKey); - await page.mouse.dblclick(x, y); - if (modKey) await page.keyboard.up(modKey); - } - - // Grid row targets — use coordinate click (single or double) - if (target.kind === 'gridGroup' || target.kind === 'gridParent') { - if (expand != null || toggle) { - // Expand/collapse group in hierarchy mode — click the triangle icon (.gridListH/.gridListV). - // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click. - const levelIconInfo = await getGridToggleIcon(target, formNum, { - iconSelector: '.gridListH, .gridListV', - isExpandedExpr: "icon.classList.contains('gridListV')", - }); - const shouldClick = shouldClickToggle(levelIconInfo, expand, toggle); - if (shouldClick) { - if (levelIconInfo) { - await modClick(levelIconInfo.x, levelIconInfo.y); - } else { - // Fallback: dblclick (standard hierarchy navigation) - await modDblClick(target.x, target.y); - } - } - await waitForStable(formNum); - return returnFormState({ - clicked: { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }, - hint: shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.', - }); - } - // Default: dblclick to enter group / go up to parent - await modDblClick(target.x, target.y); - await waitForStable(formNum); - return returnFormState({ clicked: { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) } }); - } - if (target.kind === 'gridTreeNode') { - if (expand != null || toggle) { - // Expand/collapse tree node — click the tree icon [tree="true"]. - // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click. - const treeIconInfo = await getGridToggleIcon(target, formNum, { - iconSelector: '.gridBoxImg [tree="true"]', - isExpandedExpr: '(icon.style.backgroundImage || "").includes("gx=0")', - }); - const shouldClick = shouldClickToggle(treeIconInfo, expand, toggle); - if (shouldClick) { - if (treeIconInfo) { - await modClick(treeIconInfo.x, treeIconInfo.y); - } else { - // Fallback: dblclick on row (works for trees without clickable +/- icons) - await modDblClick(target.x, target.y); - } - } - await waitForStable(formNum); - return returnFormState({ - clicked: { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }, - hint: shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.', - }); - } - // Default: select row (click text, no expand/collapse) - await modClick(target.x, target.y); - await waitForStable(formNum); - return returnFormState({ - clicked: { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) }, - hint: 'Row selected. Use { expand: true } to expand/collapse.', - }); - } - if (target.kind === 'gridRow') { - if (dblclick) { - await modDblClick(target.x, target.y); - await waitForStable(); - return returnFormState({ clicked: { kind: 'gridRow', name: target.name, dblclick: true, ...(modifier ? { modifier } : {}) } }); - } - await modClick(target.x, target.y); - await waitForStable(); - return returnFormState({ clicked: { kind: 'gridRow', name: target.name, ...(modifier ? { modifier } : {}) } }); - } - - // Start CDP network monitor BEFORE the click for buttons — - // so we capture all server requests triggered by the click. - if (target.kind === 'button') { - try { netMonitor = await startNetworkMonitor(); } catch {} - } - - // Tabs without ID — use coordinate click to avoid global [data-content] ambiguity - if (target.kind === 'tab' && !target.id && target.x && target.y) { - await page.mouse.click(target.x, target.y); - } else { - const selector = `[id="${target.id}"]`; - // Use Playwright click for proper mousedown/mouseup events - await safeClick(selector, { timeout: 5000 }); - } - - // If submenu button — read popup items and return them as hints - if (target.kind === 'submenu') { - await page.waitForTimeout(ACTION_WAIT); - const submenuItems = await page.evaluate(readSubmenuScript()); - const extras = { clicked: { kind: 'submenu', name: target.name } }; - if (Array.isArray(submenuItems)) { - extras.submenu = submenuItems.map(i => i.name); - extras.hint = 'Call web_click again with a submenu item name to select it'; - } - return returnFormState(extras); - } - - await waitForStable(formNum); - - // Check if the click opened a popup/submenu (split buttons like "Создать на основании") - const openedPopup = await page.evaluate(readSubmenuScript()); - if (Array.isArray(openedPopup) && openedPopup.length > 0) { - return returnFormState({ - clicked: { kind: 'submenu', name: target.name }, - submenu: openedPopup.map(i => i.name), - hint: 'Call web_click again with a submenu item name to select it', - }); - } - - // For buttons that trigger server-side operations (post, write, etc.), - // the DOM may stabilize BEFORE the server response arrives. - // Use waitForSelector to detect error modal — this doesn't block the JS event loop. - // Skip for grid edit mode (e.g. "Добавить" row) — no server round-trip expected. - if (target.kind === 'button') { - const postForm = await page.evaluate(detectFormScript()); - if (postForm === formNum) { - const inGridEdit = await page.evaluate(`(() => { - const f = document.activeElement; - if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; - let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } - return false; - })()`); - if (!inGridEdit && netMonitor) { - // Form didn't change — server might still be processing. - // CDP monitor was started before click — wait for all requests to complete - // (300ms debounce) or for a modal/balloon/confirm to appear. - await netMonitor.waitDone(timeout); - await waitForStable(); - } - } - } - - // Form may have changed — re-detect - const state = await getFormState(); - state.clicked = { kind: target.kind, name: target.name }; - const err = await checkForErrors(); - if (err) { - state.errors = err; - if (err.confirmation) { - state.confirmation = err.confirmation; - state.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond'; - } - } - return state; - - } finally { - if (netMonitor) try { await netMonitor.cleanup(); } catch {} - if (highlightMode) try { await unhighlight(); } catch {} - } -} +// web-test core/click v1.20 — clickElement dispatcher: routes to spreadsheet / popup / grid-row / form-element handlers by target kind. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills + +import { page, ensureConnected, highlightMode } from './state.mjs'; +import { + detectFormScript, findClickTargetScript, resolveGridScript, + readSubmenuScript, +} from '../../dom.mjs'; +import { dismissPendingErrors, checkForErrors } from './errors.mjs'; +import { waitForStable } from './wait.mjs'; +import { highlight, unhighlight } from '../recording/highlight.mjs'; +import { modifierClick, returnFormState } from './helpers.mjs'; +import { + clickGridGroupTarget, clickGridTreeNodeTarget, clickGridRowTarget, +} from '../table/click-row.mjs'; +import { + clickConfirmationButton, tryClickPopupItem, +} from '../forms/click-popup.mjs'; +import { clickFormTarget } from '../forms/click-form.mjs'; +import { + clickSpreadsheetCell, findSpreadsheetCellByText, +} from '../spreadsheet/spreadsheet.mjs'; + +/** 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. */ +export async function clickElement(text, { dblclick, table, toggle, expand, modifier, timeout } = {}) { + ensureConnected(); + + // Dispatch to spreadsheet cell handler when first arg is { row, column } + if (typeof text === 'object' && text !== null && text.column != null) { + await dismissPendingErrors(); + return clickSpreadsheetCell(text, { dblclick, modifier }); + } + + await dismissPendingErrors(); + if (highlightMode) { + try { await highlight(text, { table }); await page.waitForTimeout(500); await unhighlight(); } catch {} + } + + try { + // 1. Intercept open confirmation dialog (Да/Нет/Отмена) — match button by text. + const pending = await checkForErrors(); + if (pending?.confirmation) { + return await clickConfirmationButton(text); + } + + // 2. Intercept open popup (from previous submenu/split-button click). + // Returns null if popup is open but `text` doesn't match — fall through. + const popupItems = await page.evaluate(readSubmenuScript()); + if (Array.isArray(popupItems) && popupItems.length > 0) { + const popupResult = await tryClickPopupItem(text, popupItems); + if (popupResult) return popupResult; + } + + // 3. Find a target on the current form. + let formNum = await page.evaluate(detectFormScript()); + if (formNum === null) throw new Error(`clickElement: no form found`); + + let gridSelector; + if (table) { + const resolved = await page.evaluate(resolveGridScript(formNum, table)); + if (resolved.error) throw new Error(`clickElement: table "${table}" not found. Available: ${resolved.available?.map(a => a.name).join(', ') || 'none'}`); + gridSelector = resolved.gridSelector; + } + + let target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector })); + + // Retry: if not found, a modal form may still be loading (e.g. after F4). + if (target?.error) { + for (let retry = 0; retry < 4; retry++) { + await page.waitForTimeout(500); + const newForm = await page.evaluate(detectFormScript()); + if (newForm !== null && newForm !== formNum) { + formNum = newForm; + target = await page.evaluate(findClickTargetScript(formNum, text, { tableName: table, gridSelector })); + if (!target?.error) break; + } + } + } + + // Spreadsheet fallback: search iframes for text match before giving up. + if (target?.error) { + const ssCell = await findSpreadsheetCellByText(formNum, text); + if (ssCell) { + const cx = ssCell.box.x + ssCell.box.width / 2; + const cy = ssCell.box.y + ssCell.box.height / 2; + await modifierClick(cx, cy, modifier, { dbl: !!dblclick }); + await waitForStable(); + return returnFormState({ + clicked: { kind: 'spreadsheetCell', name: ssCell.text, ...(dblclick ? { dblclick: true } : {}) }, + }); + } + throw new Error(`clickElement: "${text}" not found. Available: ${target.available?.join(', ') || 'none'}`); + } + + // 4. Dispatch to the right handler by target kind. + const ctx = { formNum, modifier, dblclick, toggle, expand, timeout, table, gridSelector }; + if (target.kind === 'gridGroup' || target.kind === 'gridParent') return await clickGridGroupTarget(target, ctx); + if (target.kind === 'gridTreeNode') return await clickGridTreeNodeTarget(target, ctx); + if (target.kind === 'gridRow') return await clickGridRowTarget(target, ctx); + return await clickFormTarget(target, ctx); + } finally { + if (highlightMode) try { await unhighlight(); } catch {} + } +} diff --git a/.claude/skills/web-test/scripts/engine/core/helpers.mjs b/.claude/skills/web-test/scripts/engine/core/helpers.mjs index 9f0aa46b..fecec2f3 100644 --- a/.claude/skills/web-test/scripts/engine/core/helpers.mjs +++ b/.claude/skills/web-test/scripts/engine/core/helpers.mjs @@ -1,4 +1,4 @@ -// web-test core/helpers v1.19 — private, cross-cutting helpers used by the +// web-test core/helpers v1.20 — private, cross-cutting helpers used by the // public action functions (clickElement/fillFields/selectValue/etc). // Source: https://github.com/Nikolay-Shirokov/cc-1c-skills @@ -162,3 +162,16 @@ export async function returnFormState(extras = {}) { if (err) state.errors = err; return state; } + +/** + * Mouse click at (x, y) with an optional modifier key held down for the duration. + * Supports `'ctrl'` / `'shift'` (used by clickElement for multi-select). + * Pass `{ dbl: true }` for double-click. + */ +export async function modifierClick(x, y, modifier, { dbl = false } = {}) { + const modKey = modifier === 'ctrl' ? 'Control' : modifier === 'shift' ? 'Shift' : null; + if (modKey) await page.keyboard.down(modKey); + if (dbl) await page.mouse.dblclick(x, y); + else await page.mouse.click(x, y); + if (modKey) await page.keyboard.up(modKey); +} diff --git a/.claude/skills/web-test/scripts/engine/forms/click-form.mjs b/.claude/skills/web-test/scripts/engine/forms/click-form.mjs new file mode 100644 index 00000000..b4ccfc52 --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/forms/click-form.mjs @@ -0,0 +1,107 @@ +// web-test forms/click-form v1.0 — click handler for form-element targets: button, tab, submenu, link. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Called by core/click.mjs dispatcher after target is found. +// Owns the CDP network-monitor lifecycle for button clicks (server roundtrip waits), +// post-click submenu detection (split buttons like "Создать на основании"), +// and confirmation hint propagation in the final state. + +import { page, ACTION_WAIT } from '../core/state.mjs'; +import { + detectFormScript, readSubmenuScript, +} from '../../dom.mjs'; +import { checkForErrors } from '../core/errors.mjs'; +import { waitForStable, startNetworkMonitor } from '../core/wait.mjs'; +import { safeClick, returnFormState } from '../core/helpers.mjs'; + +/** + * Click a form target (button, tab, submenu, link) using its resolved {kind, id, x, y, name}. + * Handles three special concerns: + * 1. **netMonitor** for `kind: 'button'` — captures CDP requests started by the click + * so we can wait for them (when the form doesn't change) before stabilising. + * 2. **Submenu detection** — both pre-click (`kind: 'submenu'` already known) and + * post-click (split buttons like "Создать на основании" which open a popup). + * Returns `submenu[]` items as a hint for the caller. + * 3. **Confirmation propagation** — if a confirmation dialog opens as a result of the + * click, surface `confirmation` and `hint` fields on the returned state so the + * caller can react with Да/Нет/Отмена on the next call. + */ +export async function clickFormTarget(target, ctx) { + const { formNum, timeout } = ctx; + let netMonitor = null; + + try { + // CDP network monitor BEFORE the click for buttons — captures all server requests + // triggered by the click so we can wait for them after. + if (target.kind === 'button') { + try { netMonitor = await startNetworkMonitor(); } catch {} + } + + // Tabs without ID — use coordinate click to avoid global [data-content] ambiguity + if (target.kind === 'tab' && !target.id && target.x && target.y) { + await page.mouse.click(target.x, target.y); + } else { + const selector = `[id="${target.id}"]`; + // Use Playwright click for proper mousedown/mouseup events + await safeClick(selector, { timeout: 5000 }); + } + + // Pre-known submenu button — read popup items and return them as hints + if (target.kind === 'submenu') { + await page.waitForTimeout(ACTION_WAIT); + const submenuItems = await page.evaluate(readSubmenuScript()); + const extras = { clicked: { kind: 'submenu', name: target.name } }; + if (Array.isArray(submenuItems)) { + extras.submenu = submenuItems.map(i => i.name); + extras.hint = 'Call web_click again with a submenu item name to select it'; + } + return returnFormState(extras); + } + + await waitForStable(formNum); + + // Check if the click opened a popup/submenu (split buttons like "Создать на основании") + const openedPopup = await page.evaluate(readSubmenuScript()); + if (Array.isArray(openedPopup) && openedPopup.length > 0) { + return returnFormState({ + clicked: { kind: 'submenu', name: target.name }, + submenu: openedPopup.map(i => i.name), + hint: 'Call web_click again with a submenu item name to select it', + }); + } + + // For buttons that trigger server-side operations (post, write, etc.), + // the DOM may stabilise BEFORE the server response arrives. + // The CDP monitor (started before click) lets us wait for all in-flight requests + // to complete (300ms debounce) or for a modal/balloon/confirm to appear. + // Skip for grid edit mode (e.g. "Добавить" row) — no server round-trip expected. + if (target.kind === 'button') { + const postForm = await page.evaluate(detectFormScript()); + if (postForm === formNum) { + const inGridEdit = await page.evaluate(`(() => { + const f = document.activeElement; + if (!f || (f.tagName !== 'INPUT' && f.tagName !== 'TEXTAREA')) return false; + let n = f; while (n) { if (n.classList?.contains('grid')) return true; n = n.parentElement; } + return false; + })()`); + if (!inGridEdit && netMonitor) { + await netMonitor.waitDone(timeout); + await waitForStable(); + } + } + } + + // Build final state with confirmation propagation + // (the one custom branch deliberately skipped by Phase 2 — surfaces confirmation + // + hint when a save/delete dialog opened as a result of the click). + const extras = { clicked: { kind: target.kind, name: target.name } }; + const err = await checkForErrors(); + if (err?.confirmation) { + extras.confirmation = err.confirmation; + extras.hint = 'Call web_click with a button name (e.g. "Да", "Нет", "Отмена") to respond'; + } + return returnFormState(extras); + } finally { + if (netMonitor) try { await netMonitor.cleanup(); } catch {} + } +} diff --git a/.claude/skills/web-test/scripts/engine/forms/click-popup.mjs b/.claude/skills/web-test/scripts/engine/forms/click-popup.mjs new file mode 100644 index 00000000..bc1f7745 --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/forms/click-popup.mjs @@ -0,0 +1,90 @@ +// web-test forms/click-popup v1.0 — click handlers for in-form popups: confirmation dialogs and open submenus. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// Both handlers run BEFORE clickElement's regular target-finding flow: +// - clickConfirmationButton intercepts when a pending confirmation dialog is open +// - tryClickPopupItem intercepts when a submenu/popup is open from a previous click + +import { page, ACTION_WAIT, normYo } from '../core/state.mjs'; +import { readSubmenuScript } from '../../dom.mjs'; +import { waitForStable } from '../core/wait.mjs'; +import { returnFormState } from '../core/helpers.mjs'; + +/** + * Click a button in the currently-open confirmation dialog (Да/Нет/Отмена, etc). + * Caller is responsible for verifying that a confirmation is actually pending + * (via checkForErrors().confirmation) before invoking this handler. + * + * Throws if no button matching `text` is found in the dialog. + */ +export async function clickConfirmationButton(text) { + const btnResult = await page.evaluate(`(() => { + const norm = s => s?.trim().replace(/\\u00a0/g, ' ') || ''; + const ny = s => s.replace(/ё/gi, 'е').replace(/\\u00a0/g, ' '); + const target = ny(${JSON.stringify(text.toLowerCase())}); + const btns = [...document.querySelectorAll('a.press.pressButton')].filter(el => el.offsetWidth > 0); + let best = btns.find(el => ny(norm(el.innerText).toLowerCase()) === target); + if (!best) best = btns.find(el => ny(norm(el.innerText).toLowerCase()).includes(target)); + if (best) { + const r = best.getBoundingClientRect(); + return { name: norm(best.innerText), x: Math.round(r.x + r.width/2), y: Math.round(r.y + r.height/2) }; + } + return { error: 'not_found', available: btns.map(el => norm(el.innerText)).filter(Boolean) }; + })()`); + if (btnResult?.error) { + throw new Error(`clickElement: "${text}" not found among confirmation buttons. Available: ${btnResult.available?.join(', ') || 'none'}`); + } + await page.mouse.click(btnResult.x, btnResult.y); + await waitForStable(); + return returnFormState({ clicked: { kind: 'confirmation', name: btnResult.name } }); +} + +/** + * Try to click an item inside an already-open submenu/popup. + * + * Returns a form-state result on match (kind: 'popupItem' or 'submenuArrow'), + * or `null` if the requested text doesn't match any visible popup item — in + * which case the caller should fall through to regular form-element finding. + * + * @param {string} text — fuzzy-matched against item labels (NBSP/ё-normalised) + * @param {Array} popupItems — items already read via readSubmenuScript() + */ +export async function tryClickPopupItem(text, popupItems) { + const target = normYo(text.toLowerCase()); + let found = popupItems.find(i => normYo(i.name.toLowerCase()) === target); + if (!found) found = popupItems.find(i => normYo(i.name.toLowerCase()).includes(target)); + if (!found) return null; + + // submenuArrow items (group headers like "Создать", "Печать") — hover to expand nested submenu + if (found.kind === 'submenuArrow') { + // page.hover(selector) is more reliable than page.mouse.move(x,y) — + // some submenu groups don't expand with plain mouse.move + if (found.id) { + await page.hover(`[id="${found.id}"]`); + } else { + await page.mouse.move(found.x, found.y); + } + await page.waitForTimeout(ACTION_WAIT); + const nestedItems = await page.evaluate(readSubmenuScript()); + const extras = { clicked: { kind: 'submenuArrow', name: found.name } }; + if (Array.isArray(nestedItems)) { + extras.submenu = nestedItems.map(i => i.name); + extras.hint = 'Call web_click again with a submenu item name to select it'; + } + return returnFormState(extras); + } + + // Regular submenu/dropdown items — trusted events required. + // Use mouse.click(x,y) when in viewport; use :visible selector for clipped items + // (same ID can exist hidden in parent cloud AND visible in nested cloud). + const vpHeight = await page.evaluate('window.innerHeight'); + if (found.x && found.y && found.y > 0 && found.y < vpHeight) { + await page.mouse.click(found.x, found.y); + } else if (found.id) { + await page.click(`[id="${found.id}"]:visible`); + } else if (found.x && found.y) { + await page.mouse.click(found.x, found.y); + } + await waitForStable(); + return returnFormState({ clicked: { kind: 'popupItem', name: found.name } }); +} diff --git a/.claude/skills/web-test/scripts/engine/table/click-row.mjs b/.claude/skills/web-test/scripts/engine/table/click-row.mjs new file mode 100644 index 00000000..c931f4bd --- /dev/null +++ b/.claude/skills/web-test/scripts/engine/table/click-row.mjs @@ -0,0 +1,95 @@ +// web-test table/click-row v1.0 — click handlers for grid row targets: gridGroup, gridTreeNode, gridRow. +// Source: https://github.com/Nikolay-Shirokov/cc-1c-skills +// +// All handlers are called by core/click.mjs dispatcher after target is found. +// Each takes (target, ctx) where ctx = { formNum, modifier, dblclick, toggle, expand, ... } +// and returns a form state with `clicked: { kind, name, ... }`. + +import { waitForStable } from '../core/wait.mjs'; +import { modifierClick, returnFormState } from '../core/helpers.mjs'; +import { getGridToggleIcon, shouldClickToggle } from './grid-toggle.mjs'; + +/** + * Click handler for gridGroup / gridParent targets (hierarchy mode). + * With `expand`/`toggle` — click the level-indicator icon to expand/collapse the group. + * Without — dblclick the row to enter the group / go up to parent. + */ +export async function clickGridGroupTarget(target, ctx) { + const { formNum, modifier, toggle, expand } = ctx; + if (expand != null || toggle) { + // Expand/collapse group — click the triangle icon (.gridListH/.gridListV). + // expand=true: only expand (skip if already expanded), expand=false: only collapse, toggle: always click. + const levelIconInfo = await getGridToggleIcon(target, formNum, { + iconSelector: '.gridListH, .gridListV', + isExpandedExpr: "icon.classList.contains('gridListV')", + }); + const shouldClick = shouldClickToggle(levelIconInfo, expand, toggle); + if (shouldClick) { + if (levelIconInfo) { + await modifierClick(levelIconInfo.x, levelIconInfo.y, modifier); + } else { + // Fallback: dblclick (standard hierarchy navigation) + await modifierClick(target.x, target.y, modifier, { dbl: true }); + } + } + await waitForStable(formNum); + return returnFormState({ + clicked: { kind: target.kind, name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }, + hint: shouldClick ? 'Group toggled. Use readTable to see updated list.' : 'Group already in desired state.', + }); + } + // Default: dblclick to enter group / go up to parent + await modifierClick(target.x, target.y, modifier, { dbl: true }); + await waitForStable(formNum); + return returnFormState({ clicked: { kind: target.kind, name: target.name, ...(modifier ? { modifier } : {}) } }); +} + +/** + * Click handler for gridTreeNode targets (tree-style grid). + * With `expand`/`toggle` — click the tree icon to expand/collapse. + * Without — single-click to select the row (no expand). + */ +export async function clickGridTreeNodeTarget(target, ctx) { + const { formNum, modifier, toggle, expand } = ctx; + if (expand != null || toggle) { + // Expand/collapse tree node — click the tree icon [tree="true"]. + const treeIconInfo = await getGridToggleIcon(target, formNum, { + iconSelector: '.gridBoxImg [tree="true"]', + isExpandedExpr: '(icon.style.backgroundImage || "").includes("gx=0")', + }); + const shouldClick = shouldClickToggle(treeIconInfo, expand, toggle); + if (shouldClick) { + if (treeIconInfo) { + await modifierClick(treeIconInfo.x, treeIconInfo.y, modifier); + } else { + // Fallback: dblclick on row (works for trees without clickable +/- icons) + await modifierClick(target.x, target.y, modifier, { dbl: true }); + } + } + await waitForStable(formNum); + return returnFormState({ + clicked: { kind: 'gridTreeNode', name: target.name, toggled: shouldClick, ...(modifier ? { modifier } : {}) }, + hint: shouldClick ? 'Tree node toggled. Use readTable to see updated tree.' : 'Tree node already in desired state.', + }); + } + // Default: select row (click text, no expand/collapse) + await modifierClick(target.x, target.y, modifier); + await waitForStable(formNum); + return returnFormState({ + clicked: { kind: 'gridTreeNode', name: target.name, ...(modifier ? { modifier } : {}) }, + hint: 'Row selected. Use { expand: true } to expand/collapse.', + }); +} + +/** + * Click handler for gridRow targets (flat list row). + * Single click selects the row; `dblclick: true` opens the item. + */ +export async function clickGridRowTarget(target, ctx) { + const { modifier, dblclick } = ctx; + await modifierClick(target.x, target.y, modifier, { dbl: !!dblclick }); + await waitForStable(); + return returnFormState({ + clicked: { kind: 'gridRow', name: target.name, ...(dblclick ? { dblclick: true } : {}), ...(modifier ? { modifier } : {}) }, + }); +} From 70be567b13504a67446a1a1e94a5f9e44cb4a38f Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Wed, 27 May 2026 18:25:26 +0300 Subject: [PATCH 46/47] =?UTF-8?q?test(web-test):=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20multi-select=20(Ctrl/Shift=20+?= =?UTF-8?q?=20clickElement)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clickElement поддерживает modifier: 'ctrl'|'shift' для multi-select строк списка с момента введения, но не было ни одного теста. Добавлен 17-multiselect.test.mjs: - ctrl-add: click+ctrl-click → 2 выделенные строки - shift-range: shift-click формирует диапазон от anchor'а - readTable отмечает _selected: true на выделенных строках Полный регресс 20/20 зелёный. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/web-test/17-multiselect.test.mjs | 50 ++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 tests/web-test/17-multiselect.test.mjs diff --git a/tests/web-test/17-multiselect.test.mjs b/tests/web-test/17-multiselect.test.mjs new file mode 100644 index 00000000..1c4e0818 --- /dev/null +++ b/tests/web-test/17-multiselect.test.mjs @@ -0,0 +1,50 @@ +export const name = 'multi-select: clickElement с modifier ctrl/shift на gridRow'; +export const tags = ['table', 'multi-select']; +export const timeout = 60000; + +// Покрытие feature `modifier: 'ctrl' | 'shift'` у clickElement для grid-row. +// Ctrl+click добавляет строку в выделение; Shift+click выделяет диапазон от +// anchor'a (последнего non-modifier клика). readTable отмечает выделенные +// строки полем _selected: true. +// +// Свежая синтетика содержит ровно 4 Контрагентов (ООО Север, ООО Юг, ООО +// Восток, ООО Запад). Используем их для предсказуемых проверок. + +export default async function({ navigateSection, openCommand, clickElement, readTable, closeForm, assert, step, log }) { + + await step('setup: открыть список Контрагентов', async () => { + await navigateSection('Склад'); + await openCommand('Контрагенты'); + const t = await readTable(); + log(`Контрагентов в списке: ${t.total}`); + assert.ok(t.total >= 3, `Нужно как минимум 3 строки для multi-select теста, есть ${t.total}`); + }); + + await step('ctrl-add: click ООО Север + ctrl-click ООО Юг → 2 выделенные строки', async () => { + await clickElement('ООО Север'); + await clickElement('ООО Юг', { modifier: 'ctrl' }); + const t = await readTable(); + const selected = t.rows.filter(r => r._selected).map(r => r['Наименование']); + log(`selected after ctrl-add: ${JSON.stringify(selected)}`); + assert.equal(selected.length, 2, '2 выделенные строки после ctrl-add'); + assert.includes(selected, 'ООО Север', 'ООО Север выделен'); + assert.includes(selected, 'ООО Юг', 'ООО Юг выделен'); + }); + + await step('shift-range: shift-click на третью строку → диапазон выделен', async () => { + // Сбрасываем выделение одиночным кликом, anchor = ООО Север + await clickElement('ООО Север'); + // Shift+click на ООО Восток (третий по списку) — должен выделить Север..Восток + await clickElement('ООО Восток', { modifier: 'shift' }); + const t = await readTable(); + const selected = t.rows.filter(r => r._selected).map(r => r['Наименование']); + log(`selected after shift-range: ${JSON.stringify(selected)}`); + assert.ok(selected.length >= 2, `Диапазон должен включать минимум 2 строки, выделено ${selected.length}`); + assert.includes(selected, 'ООО Север', 'anchor ООО Север в диапазоне'); + assert.includes(selected, 'ООО Восток', 'shift-target ООО Восток в диапазоне'); + }); + + await step('cleanup: закрыть форму', async () => { + await closeForm(); + }); +} From 403da66dd5a831be4d402afff602ec7457b83a23 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Thu, 28 May 2026 12:21:35 +0300 Subject: [PATCH 47/47] =?UTF-8?q?docs(web-test):=20README=20=D1=81=20CLI?= =?UTF-8?q?=20=D1=84=D0=BB=D0=B0=D0=B3=D0=B0=D0=BC=D0=B8,=20=D0=BE=D0=BF?= =?UTF-8?q?=D1=86=D0=B8=D1=8F=D0=BC=D0=B8=20=D1=81=D1=82=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0,=20=D0=B8=D0=B7=D0=B2=D0=B5=D1=81=D1=82=D0=BD=D1=8B?= =?UTF-8?q?=D0=BC=D0=B8=20=D0=BD=D1=8E=D0=B0=D0=BD=D1=81=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/web-test/README.md — практический mini-doc по запуску регресса: - Запуск (полный / один файл / по тегам / по grep) - CLI флаги runner'а (--tags, --grep, --bail, --retry, --timeout, --report, --format, --screenshot, --report-dir, --record) - Опции стенда после `--` (--rebuild-config, --reload-data, --rebuild-epf, --rebuild-stand) - Когда пересобирать стенд (warm-старт vs триггеры авто-пересборки vs ручные сценарии) - Конфигурация (webtest.config.mjs с contexts a/b, isolation модели) - Env переменные (WEB_TEST_PRESERVE_CLIPBOARD, WEBTEST_HOOKS_RUNTIME) - Артефакты (error-*.png, _allure/, lockfiles) - Известные нюансы: * 15-multi-context-handover накапливает Контрагентов между прогонами — `02-crud` ловит «`ООО Север` должен быть в списке» когда total>20. Лечится `-- --rebuild-stand`. * 04-selectvalue auto-history шаг делает warm-up для детерминизма. * --screenshot=every-step для full-trace. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/web-test/README.md | 101 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 tests/web-test/README.md diff --git a/tests/web-test/README.md b/tests/web-test/README.md new file mode 100644 index 00000000..d791e96f --- /dev/null +++ b/tests/web-test/README.md @@ -0,0 +1,101 @@ +# Регресс-тесты web-test + +E2E-тесты движка `web-test` (Playwright + изолированная синтетическая БД 1С), запускаются через `node .claude/skills/web-test/scripts/run.mjs test`. + +## Запуск + +```bash +# Полный регресс (все 20 тестов) +node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ + +# Один файл +node .claude/skills/web-test/scripts/run.mjs test tests/web-test/02-crud.test.mjs + +# Несколько по фильтру тегов +node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ --tags=table,smoke + +# По regex имени теста +node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ --grep=multi +``` + +URL не указываем — берётся из `webtest.config.mjs` (`contexts.a.url` = `http://localhost:9191/webtest-runner/ru_RU`). + +Exit code: 0 = все прошли, 1 = есть падения. + +## CLI флаги runner'а + +| Флаг | Описание | +|---|---| +| `--tags=A,B` | Запустить только тесты с одним из тегов | +| `--grep=regex` | Фильтр по имени теста | +| `--bail` | Остановиться на первой ошибке | +| `--retry=N` | Перепрогон упавших тестов N раз | +| `--timeout=ms` | Таймаут одного теста (default 30000) | +| `--report=path` | Сохранить отчёт в файл | +| `--format=json\|allure\|junit` | Формат отчёта | +| `--report-dir=path` | Корень для Allure/JUnit артефактов | +| `--screenshot=on-failure\|every-step\|off` | Когда снимать скриншоты | +| `--record` | Включить запись MP4 (CDP screencast → ffmpeg) | + +## Опции стенда (после `--`) + +`_hooks.mjs` поднимает изолированный стенд (Apache на `:9191`, своя БД, отдельный набор EPF). По умолчанию работает в smart-режиме: пересборка только когда поменялся `config-hash` / `epf-hash`. Принудительно — через флаги после `--`: + +```bash +# Принудительно пересобрать XML + БД + EPF +node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ -- --rebuild-stand + +# Точечно — только пересобрать БД из существующего XML (свежая синтетика) +node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ -- --reload-data + +# Только пересобрать XML (когда хочется новой конфигурации) +node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ -- --rebuild-config + +# Только EPF (внешние обработки для openFile) +node .claude/skills/web-test/scripts/run.mjs test tests/web-test/ -- --rebuild-epf +``` + +| Флаг | Что делает | +|---|---| +| `--rebuild-stand` | Эквивалент всех трёх ниже | +| `--rebuild-config` | XML-исходники + БД | +| `--reload-data` | Только БД (drop+create+load+update) | +| `--rebuild-epf` | Только EPF-обработки | + +## Когда пересобирать стенд + +**Warm-старт (~200 ms):** lockfile + probe Apache, БД жива, EPF на диске — ничего не делаем. + +**Триггеры авто-пересборки** (без флагов): +- Изменился `config-hash` синтетической XML — пересобирается конфигурация + БД. +- Изменился `epf-hash` исходников EPF — пересобираются EPF. + +**Когда нужен `--rebuild-stand` вручную:** +- БД накопила «мусорных» данных от write-сценариев. `15-multi-context-handover` создаёт нового Контрагента каждый прогон с unique-именем — со временем `02-crud` начнёт падать (Контрагент `ООО Север` уезжает за `maxRows=20`). +- Подозрение что Apache держит зависший процесс — `--rebuild-stand` делает `web-stop` + `web-publish`. + +## Конфигурация + +`tests/web-test/webtest.config.mjs` задаёт: +- **`contexts.a` / `contexts.b`** — два независимых 1C-сеанса (разные cookies) на той же URL. Тесты с `multi-context` тегом используют оба. +- **`defaultContext: 'a'`** — большинство тестов работают в одном контексте. +- **`isolation: 'tab'`** — вкладки в одном окне (default). Альтернатива `'window'` — отдельный BrowserContext (полная изоляция cookies). + +## Env переменные + +| Переменная | Значение | +|---|---| +| `WEB_TEST_PRESERVE_CLIPBOARD=0` | Отключить save/restore буфера обмена вокруг `pasteText` | +| `WEBTEST_HOOKS_RUNTIME=python` | Использовать py-версии скиллов вместо ps1 (для не-Windows) | + +## Артефакты + +- `tests/web-test/error-*.png` — скриншоты упавших шагов (auto на `--screenshot=on-failure`) +- `tests/web-test/_allure/` — Allure-результаты (на `--format=allure`) +- `tests/skills/.cache/webtest-stand/` — lockfiles стенда (config-hash, epf-hash, data-hash) + +## Известные нюансы + +- **`15-multi-context-handover`** создаёт `unique`-Контрагента и **сохраняет** — за серию прогонов накапливаются «лишние» записи. Если `02-crud` начал падать на «`ООО Север` должен быть в списке» — это симптом, лечится `-- --rebuild-stand`. +- **`04-selectvalue` auto-history шаг** — в изоляции делает warm-up через двойной `selectValue('Менеджер', 'ООО Юг')` чтобы наполнить history, иначе первый вызов идёт через `method:form`, а тест ожидает `method:dropdown`. Не зависит от других файлов. +- **Скриншот ошибки только на последнем падении** — `--screenshot=on-failure` (default) делает один кадр в момент исключения. Для full-trace используй `--screenshot=every-step`.