import { access, mkdir, readFile, writeFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { CdpConnection, NetworkRecorder, resolveEndpoint } from './cdp-recorder.mjs';
const channels = { edge: 'msedge', chrome: 'chrome', chromium: undefined };
const timestamp = () => new Date().toISOString().replace(/[:.]/g, '-');
export async function doctor() {
let playwright;
try { playwright = await import('playwright-core'); } catch {}
const candidates = [
process.env['ProgramFiles(x86)'] && path.join(process.env['ProgramFiles(x86)'], 'Microsoft/Edge/Application/msedge.exe'),
process.env.ProgramFiles && path.join(process.env.ProgramFiles, 'Microsoft/Edge/Application/msedge.exe'),
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Microsoft/Edge/Application/msedge.exe'),
process.env.ProgramFiles && path.join(process.env.ProgramFiles, 'Google/Chrome/Application/chrome.exe'),
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Google/Chrome/Application/chrome.exe'),
].filter(Boolean);
const browsers = [];
for (const candidate of candidates) {
try { await access(candidate); browsers.push(candidate); } catch {}
}
return {
node: process.version, nativeWebSocket: typeof WebSocket === 'function',
playwrightCoreInstalled: Boolean(playwright), browsers,
ready: Number(process.versions.node.split('.')[0]) >= 22 && Boolean(playwright) && typeof WebSocket === 'function',
note: 'ready checks runtime dependencies; a launch still needs Edge/Chrome, an executablePath, or a Playwright Chromium installation. Existing browser attachment needs its CDP endpoint.',
};
}
export async function openSession(options = {}) {
if (Number(process.versions.node.split('.')[0]) < 22 || typeof WebSocket !== 'function') {
throw new Error('Node.js 22+ with native WebSocket is required.');
}
let chromium;
try { ({ chromium } = await import('playwright-core')); }
catch { throw new Error('Missing playwright-core. Run npm.cmd ci --ignore-scripts in the skill directory.'); }
const browserName = options.browser ?? 'edge';
if (!(browserName in channels)) throw new Error('browser must be edge, chrome, or chromium.');
const workDir = path.resolve(options.workDir ?? path.join(process.cwd(), 'work', 'browser-debug'));
const outputDir = path.resolve(options.outputDir ?? path.join(workDir, 'captures', `${timestamp()}-${randomUUID().slice(0, 8)}`));
// Refuse an accidental overwrite of a previous capture.
try { await access(path.join(outputDir, 'events.jsonl')); throw new Error(`Capture already exists: ${outputDir}`); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
await mkdir(outputDir, { recursive: true });
let browser, context, protocol, recorder, endpoint, version;
const passiveDialogContexts = [];
const passiveDialog = () => {}; // An observing CDP client must not dismiss another client's dialogs.
const owned = !options.endpoint;
let profileDir;
try {
if (owned) {
profileDir = path.resolve(options.profileDir ?? path.join(workDir, `profile-${browserName}`));
await mkdir(profileDir, { recursive: true });
context = await chromium.launchPersistentContext(profileDir, {
...(options.executablePath ? { executablePath: options.executablePath } : { channel: channels[browserName] }),
headless: options.headless ?? false, acceptDownloads: true,
serviceWorkers: 'allow', viewport: options.headless ? { width: 1440, height: 900 } : null,
recordHar: { path: path.join(outputDir, 'network.playwright.har'), mode: 'full', content: 'embed' },
args: ['--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0'],
});
browser = context.browser();
const activePort = (await readFile(path.join(profileDir, 'DevToolsActivePort'), 'utf8')).trim().split(/\r?\n/);
const port = Number(activePort[0]);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Browser did not create a valid DevToolsActivePort file.');
endpoint = `http://127.0.0.1:${port}`;
} else {
endpoint = options.endpoint;
browser = await chromium.connectOverCDP(endpoint);
const contexts = browser.contexts();
for (const candidate of contexts) { candidate.on('dialog', passiveDialog); passiveDialogContexts.push(candidate); }
const index = options.contextIndex ?? 0;
context = contexts[index];
if (!context) throw new Error(`No existing browser context at index ${index}. Available: ${contexts.length}`);
}
context.setDefaultTimeout(options.timeoutMs ?? 15000);
context.setDefaultNavigationTimeout(options.navigationTimeoutMs ?? 30000);
version = await resolveEndpoint(endpoint);
protocol = await CdpConnection.connect(version.webSocketDebuggerUrl);
recorder = new NetworkRecorder(protocol, outputDir, { maxBodyBytes: options.maxBodyBytes });
await recorder.start();
const existingPages = context.pages();
let page = existingPages.find(page => /^https?:/i.test(page.url())) ?? existingPages[0];
if (!page && !owned) throw new Error('The attached browser has no existing page. Let its owning application create one before attaching; WebView2 Target.createTarget may open a visible external window.');
if (!page) page = await context.newPage();
const session = new BrowserDebugSession({ browser, context, page, protocol, recorder, outputDir, workDir, endpoint, owned, profileDir, version, passiveDialogContexts, passiveDialog });
await session.pageSessionId(page);
await writeFile(path.join(outputDir, 'session.json'), `${JSON.stringify({ endpoint, owned, profileDir, outputDir, browser: version, captureStartedAt: recorder.startedAt }, null, 2)}\n`, 'utf8');
if (/^https?:/i.test(endpoint)) {
try {
const response = await fetch(new URL('/json/protocol', endpoint), { signal: AbortSignal.timeout(10000) });
if (response.ok) await writeFile(path.join(outputDir, 'protocol.json'), await response.text(), 'utf8');
else recorder.warn('protocol-schema-unavailable', `HTTP ${response.status}`);
} catch (error) { recorder.warn('protocol-schema-unavailable', error); }
}
return session;
} catch (error) {
if (recorder?.events) { try { await recorder.stop(); } catch {} }
else if (protocol) { try { await protocol.close(); } catch {} }
if (owned && context) { try { await context.close(); } catch {} }
else if (browser) { try { await browser.close(); } catch {} }
throw error;
}
}
export class BrowserDebugSession {
constructor(values) { Object.assign(this, values); this.pageIds = new WeakMap(); }
async pageSessionId(page = this.page) {
let targetId = this.pageIds.get(page);
if (!targetId) {
const probe = await page.context().newCDPSession(page);
try { ({ targetInfo: { targetId } } = await probe.send('Target.getTargetInfo')); }
finally { await probe.detach(); }
this.pageIds.set(page, targetId);
}
return this.recorder.readyTarget(targetId);
}
targets() { return [...this.recorder.sessions.values()].map(info => ({ ...info })); }
async newPage() {
const page = await this.context.newPage();
await this.pageSessionId(page);
return page;
}
async cdp(method, params = {}, options = {}) {
const root = options.root ?? /^(Browser|Target|SystemInfo)\./.test(method);
const sessionId = options.sessionId ?? (root ? undefined : await this.pageSessionId(options.page ?? this.page));
return this.protocol.call(method, params, sessionId, options.timeoutMs ?? 15000);
}
async upload({ files, selector = 'input[type="file"]', page = this.page, trigger, timeoutMs = 15000 }) {
const paths = (Array.isArray(files) ? files : [files]).map(file => {
if (typeof file !== 'string' || !path.isAbsolute(file)) throw new Error('Upload requires actual absolute local file/directory paths.');
return path.resolve(file);
});
for (const file of paths) await stat(file);
if (trigger) {
const chooserPromise = page.waitForEvent('filechooser', { timeout: timeoutMs });
const [chooser] = await Promise.all([chooserPromise, trigger()]);
await chooser.setFiles(paths, { timeout: timeoutMs });
} else {
await page.locator(selector).setInputFiles(paths, { timeout: timeoutMs });
}
// A selected file is not proof that the website finished its upload.
return { files: paths, selected: true, uploadConfirmed: false };
}
flush() { return this.recorder.flush(); }
async close() {
if (this.closed) return this.finalSummary;
let summary, captureError;
try { summary = await this.recorder.stop(); }
catch (error) { captureError = error; }
finally {
if (this.protocol.connected) await this.protocol.close();
if (this.owned) await this.context.close();
else await this.browser.close(); // connectOverCDP: disconnect; do not send Browser.close.
for (const candidate of this.passiveDialogContexts) candidate.off('dialog', this.passiveDialog);
this.closed = true;
}
this.finalSummary = summary;
if (captureError) throw captureError;
return summary;
}
}
import { access, mkdir, readFile, writeFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { CdpConnection, NetworkRecorder, resolveEndpoint } from './cdp-recorder.mjs';
const channels = { edge: 'msedge', chrome: 'chrome', chromium: undefined };
const timestamp = () => new Date().toISOString().replace(/[:.]/g, '-');
export async function doctor() {
let playwright;
try { playwright = await import('playwright-core'); } catch {}
const candidates = [
process.env['ProgramFiles(x86)'] && path.join(process.env['ProgramFiles(x86)'], 'Microsoft/Edge/Application/msedge.exe'),
process.env.ProgramFiles && path.join(process.env.ProgramFiles, 'Microsoft/Edge/Application/msedge.exe'),
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Microsoft/Edge/Application/msedge.exe'),
process.env.ProgramFiles && path.join(process.env.ProgramFiles, 'Google/Chrome/Application/chrome.exe'),
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Google/Chrome/Application/chrome.exe'),
].filter(Boolean);
const browsers = [];
for (const candidate of candidates) {
try { await access(candidate); browsers.push(candidate); } catch {}
}
return {
node: process.version, nativeWebSocket: typeof WebSocket === 'function',
playwrightCoreInstalled: Boolean(playwright), browsers,
ready: Number(process.versions.node.split('.')[0]) >= 22 && Boolean(playwright) && typeof WebSocket === 'function',
note: 'ready checks runtime dependencies; a launch still needs Edge/Chrome, an executablePath, or a Playwright Chromium installation. Existing browser attachment needs its CDP endpoint.',
};
}
export async function openSession(options = {}) {
if (Number(process.versions.node.split('.')[0]) < 22 || typeof WebSocket !== 'function') {
throw new Error('Node.js 22+ with native WebSocket is required.');
}
let chromium;
try { ({ chromium } = await import('playwright-core')); }
catch { throw new Error('Missing playwright-core. Run npm.cmd ci --ignore-scripts in the skill directory.'); }
const browserName = options.browser ?? 'edge';
if (!(browserName in channels)) throw new Error('browser must be edge, chrome, or chromium.');
const workDir = path.resolve(options.workDir ?? path.join(process.cwd(), 'work', 'browser-debug'));
const outputDir = path.resolve(options.outputDir ?? path.join(workDir, 'captures', `${timestamp()}-${randomUUID().slice(0, 8)}`));
// Refuse an accidental overwrite of a previous capture.
try { await access(path.join(outputDir, 'events.jsonl')); throw new Error(`Capture already exists: ${outputDir}`); }
catch (error) { if (error.code !== 'ENOENT') throw error; }
await mkdir(outputDir, { recursive: true });
let browser, context, protocol, recorder, endpoint, version;
const passiveDialogContexts = [];
const passiveDialog = () => {}; // An observing CDP client must not dismiss another client's dialogs.
const owned = !options.endpoint;
let profileDir;
try {
if (owned) {
profileDir = path.resolve(options.profileDir ?? path.join(workDir, `profile-${browserName}`));
await mkdir(profileDir, { recursive: true });
context = await chromium.launchPersistentContext(profileDir, {
...(options.executablePath ? { executablePath: options.executablePath } : { channel: channels[browserName] }),
headless: options.headless ?? false, acceptDownloads: true,
serviceWorkers: 'allow', viewport: options.headless ? { width: 1440, height: 900 } : null,
recordHar: { path: path.join(outputDir, 'network.playwright.har'), mode: 'full', content: 'embed' },
args: ['--remote-debugging-address=127.0.0.1', '--remote-debugging-port=0'],
});
browser = context.browser();
const activePort = (await readFile(path.join(profileDir, 'DevToolsActivePort'), 'utf8')).trim().split(/\r?\n/);
const port = Number(activePort[0]);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('Browser did not create a valid DevToolsActivePort file.');
endpoint = `http://127.0.0.1:${port}`;
} else {
endpoint = options.endpoint;
browser = await chromium.connectOverCDP(endpoint);
const contexts = browser.contexts();
for (const candidate of contexts) { candidate.on('dialog', passiveDialog); passiveDialogContexts.push(candidate); }
const index = options.contextIndex ?? 0;
context = contexts[index];
if (!context) throw new Error(`No existing browser context at index ${index}. Available: ${contexts.length}`);
}
context.setDefaultTimeout(options.timeoutMs ?? 15000);
context.setDefaultNavigationTimeout(options.navigationTimeoutMs ?? 30000);
version = await resolveEndpoint(endpoint);
protocol = await CdpConnection.connect(version.webSocketDebuggerUrl);
recorder = new NetworkRecorder(protocol, outputDir, { maxBodyBytes: options.maxBodyBytes });
await recorder.start();
const existingPages = context.pages();
let page = existingPages.find(page => /^https?:/i.test(page.url())) ?? existingPages[0];
if (!page && !owned) throw new Error('The attached browser has no existing page. Let its owning application create one before attaching; WebView2 Target.createTarget may open a visible external window.');
if (!page) page = await context.newPage();
const session = new BrowserDebugSession({ browser, context, page, protocol, recorder, outputDir, workDir, endpoint, owned, profileDir, version, passiveDialogContexts, passiveDialog });
await session.pageSessionId(page);
await writeFile(path.join(outputDir, 'session.json'), `${JSON.stringify({ endpoint, owned, profileDir, outputDir, browser: version, captureStartedAt: recorder.startedAt }, null, 2)}\n`, 'utf8');
if (/^https?:/i.test(endpoint)) {
try {
const response = await fetch(new URL('/json/protocol', endpoint), { signal: AbortSignal.timeout(10000) });
if (response.ok) await writeFile(path.join(outputDir, 'protocol.json'), await response.text(), 'utf8');
else recorder.warn('protocol-schema-unavailable', `HTTP ${response.status}`);
} catch (error) { recorder.warn('protocol-schema-unavailable', error); }
}
return session;
} catch (error) {
if (recorder?.events) { try { await recorder.stop(); } catch {} }
else if (protocol) { try { await protocol.close(); } catch {} }
if (owned && context) { try { await context.close(); } catch {} }
else if (browser) { try { await browser.close(); } catch {} }
throw error;
}
}
export class BrowserDebugSession {
constructor(values) { Object.assign(this, values); this.pageIds = new WeakMap(); }
async pageSessionId(page = this.page) {
let targetId = this.pageIds.get(page);
if (!targetId) {
const probe = await page.context().newCDPSession(page);
try { ({ targetInfo: { targetId } } = await probe.send('Target.getTargetInfo')); }
finally { await probe.detach(); }
this.pageIds.set(page, targetId);
}
return this.recorder.readyTarget(targetId);
}
targets() { return [...this.recorder.sessions.values()].map(info => ({ ...info })); }
async newPage() {
const page = await this.context.newPage();
await this.pageSessionId(page);
return page;
}
async cdp(method, params = {}, options = {}) {
const root = options.root ?? /^(Browser|Target|SystemInfo)\./.test(method);
const sessionId = options.sessionId ?? (root ? undefined : await this.pageSessionId(options.page ?? this.page));
return this.protocol.call(method, params, sessionId, options.timeoutMs ?? 15000);
}
async upload({ files, selector = 'input[type="file"]', page = this.page, trigger, timeoutMs = 15000 }) {
const paths = (Array.isArray(files) ? files : [files]).map(file => {
if (typeof file !== 'string' || !path.isAbsolute(file)) throw new Error('Upload requires actual absolute local file/directory paths.');
return path.resolve(file);
});
for (const file of paths) await stat(file);
if (trigger) {
const chooserPromise = page.waitForEvent('filechooser', { timeout: timeoutMs });
const [chooser] = await Promise.all([chooserPromise, trigger()]);
await chooser.setFiles(paths, { timeout: timeoutMs });
} else {
await page.locator(selector).setInputFiles(paths, { timeout: timeoutMs });
}
// A selected file is not proof that the website finished its upload.
return { files: paths, selected: true, uploadConfirmed: false };
}
flush() { return this.recorder.flush(); }
async close() {
if (this.closed) return this.finalSummary;
let summary, captureError;
try { summary = await this.recorder.stop(); }
catch (error) { captureError = error; }
finally {
if (this.protocol.connected) await this.protocol.close();
if (this.owned) await this.context.close();
else await this.browser.close(); // connectOverCDP: disconnect; do not send Browser.close.
for (const candidate of this.passiveDialogContexts) candidate.off('dialog', this.passiveDialog);
this.closed = true;
}
this.finalSummary = summary;
if (captureError) throw captureError;
return summary;
}
}