From 38ccded7d95b88d4207636a93a3f874b8edf2a35 Mon Sep 17 00:00:00 2001 From: Nick Shirokov Date: Tue, 17 Mar 2026 18:09:20 +0300 Subject: [PATCH] feat(web-test): auto-load 1C browser extension for file operations Auto-detect 1C:Enterprise browser extension from Chrome/Edge profiles and load it via launchPersistentContext. Enables native file dialogs (Excel import/export) without "install extension" prompt. - findExtension() scans Chrome/Edge User Data by extension ID - connect() uses launchPersistentContext when extension found, falls back to chromium.launch() otherwise - isConnected() handles both Browser and BrowserContext objects - Temp userDataDir cleaned on disconnect() Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude/skills/web-test/scripts/browser.mjs | 85 ++++++++++++++++++--- 1 file changed, 76 insertions(+), 9 deletions(-) diff --git a/.claude/skills/web-test/scripts/browser.mjs b/.claude/skills/web-test/scripts/browser.mjs index b7a34e6f..b2d65ee9 100644 --- a/.claude/skills/web-test/scripts/browser.mjs +++ b/.claude/skills/web-test/scripts/browser.mjs @@ -8,7 +8,7 @@ */ import { chromium } from 'playwright'; import { spawn, execFileSync } from 'child_process'; -import { statSync, mkdirSync, existsSync as fsExistsSync, writeFileSync, readFileSync, rmSync } from 'fs'; +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'; @@ -40,25 +40,87 @@ 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 + +/** + * 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; +} + /** Check if browser is connected and page is usable. */ export function isConnected() { - return browser?.isConnected() && page && !page.isClosed(); + 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; } /** * 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) { +export async function connect(url, { extensionPath } = {}) { if (isConnected()) { await page.goto(url, { waitUntil: 'domcontentloaded', timeout: LOAD_TIMEOUT }); } else { - browser = await chromium.launch({ headless: false, args: ['--start-maximized'] }); - const context = await browser.newContext({ - viewport: null, - permissions: ['clipboard-read', 'clipboard-write'], - }); - page = await context.newPage(); + const extPath = findExtension(extensionPath); + if (extPath) { + // Launch with 1C browser extension via persistent context + persistentUserDataDir = 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'], + }); + browser = context; // persistent context IS the browser + page = context.pages()[0] || await context.newPage(); + } else { + // Fallback: launch without extension + browser = await chromium.launch({ headless: false, args: ['--start-maximized'] }); + const context = await browser.newContext({ + viewport: null, + permissions: ['clipboard-read', 'clipboard-write'], + }); + page = await context.newPage(); + } // Capture seanceId from network requests for graceful logout sessionPrefix = null; @@ -116,6 +178,11 @@ export async function disconnect() { page = null; sessionPrefix = null; seanceId = null; + // Clean up persistent user data dir + if (persistentUserDataDir) { + try { rmSync(persistentUserDataDir, { recursive: true, force: true }); } catch {} + persistentUserDataDir = null; + } } }