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; +}