XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

BrowserDebugger

【Skill】浏览器调试工具

公开
关注 0 Fork 0 Star 0
UTF-8
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;
  }
}