mirror of
https://github.com/Nikolay-Shirokov/cc-1c-skills.git
synced 2026-07-19 09:09:41 +03:00
refactor(web-test): этап A.4 — выделить core/session.mjs
Перенос 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
export {
|
||||||
/**
|
connect, disconnect, attach, detach, getSession,
|
||||||
* Save current module-level state into the active slot before switching.
|
createContext, setActiveContext, listContexts, getActiveContext,
|
||||||
* No-op if no active slot.
|
hasContext, closeContext,
|
||||||
*/
|
} from './core/session.mjs';
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Wait + error/modal handling — extracted to core/{wait,errors}.mjs
|
// Wait + error/modal handling — extracted to core/{wait,errors}.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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user