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

BrowserDebugger

【Skill】浏览器调试工具

公开
关注 0 Fork 0 Star 0
UTF-8
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { createHash } from 'node:crypto';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { openSession } from './browser-session.mjs';
import { inspectMedia, captureMediaEvidence } from './media-evidence.mjs';

// Entirely local integration fixture. No external website, account, or upload service.
const root = path.resolve(process.argv[2] ?? path.join(process.cwd(), 'work', `self-test-${Date.now()}`));
await mkdir(root, { recursive: true });
const fileA = path.join(root, '测试文件.txt');
const fileB = path.join(root, 'second.txt');
const directory = path.join(root, 'upload-directory');
await mkdir(directory, { recursive: true });
await Promise.all([
  writeFile(fileA, 'local upload fixture 中文'), writeFile(fileB, 'second fixture'),
  writeFile(path.join(directory, 'nested.txt'), 'directory fixture'),
]);

let uploadReceived = false;
const sockets = new Set();
const fixture = `<!doctype html><meta charset="utf-8"><title>Browser debugger integration fixture</title>
<h1>Local browser test</h1>
<input id="direct" type="file" multiple><input id="chooser" type="file" hidden multiple>
<button id="choose" onclick="document.querySelector('#chooser').click()">Choose files</button>
<input id="directory" type="file" webkitdirectory>
<button id="submit" onclick="upload()">Upload chosen files</button>
<a id="download" href="/download">Download</a><a id="popup" href="/popup" target="_blank">Popup</a>
<script>
window.uploadResult=null;
async function upload(){const data=new FormData();for(const file of document.querySelector('#chooser').files)data.append('files',file);window.uploadResult=await(await fetch('/api/upload',{method:'POST',body:data})).json()}
window.fixtureDone=false;
(async()=>{
  console.log('fixture-console-marker');
  await fetch('/api/example?sample=yes',{method:'POST',headers:{'Content-Type':'application/json','X-Probe':'request-extra-info'},body:JSON.stringify({message:'request payload 中文'})});
  await fetch('/redirect');
  await fetch('/large');
  await fetch('/failure').catch(()=>{});
  await new Promise(resolve=>{const xhr=new XMLHttpRequest();xhr.open('GET','/api/xhr');xhr.onload=resolve;xhr.send()});
  await new Promise(resolve=>{const worker=new Worker('/worker.js');worker.onmessage=()=>{worker.terminate();resolve()}});
  await new Promise(resolve=>{const worker=new SharedWorker('/shared-worker.js');worker.port.onmessage=()=>{worker.port.close();resolve()};worker.port.start()});
  window.fixtureStep='register-service-worker';
  const registration=await navigator.serviceWorker.register('/sw.js');
  window.fixtureStep='service-worker-ready';await navigator.serviceWorker.ready;
  window.fixtureStep='service-worker-controller';
  if(!navigator.serviceWorker.controller)await new Promise(resolve=>navigator.serviceWorker.addEventListener('controllerchange',resolve,{once:true}));
  await fetch('/sw-proxy');window.fixtureStep='websocket';
  await new Promise(resolve=>{const ws=new WebSocket('ws://'+location.host+'/ws');ws.onopen=()=>ws.send('ws-client-marker');ws.onmessage=()=>{ws.close();resolve()}});
  window.fixtureStep='sse';
  await new Promise(resolve=>{const source=new EventSource('/sse');source.onmessage=()=>{source.close();resolve()}});
  window.fixtureStep='iframe';
  const frame=document.createElement('iframe');frame.src=location.origin.replace('127.0.0.1','localhost')+'/frame';
  await new Promise(resolve=>{frame.onload=resolve;document.body.append(frame)});
  window.fixtureDone=true;
})().catch(error=>{window.fixtureError=String(error)});
</script>`;

const server = createServer(async (request, response) => {
  try {
    const url = new URL(request.url, 'http://local.test');
    const json = value => { response.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'X-Response-Probe': 'response-extra-info' }); response.end(JSON.stringify(value)); };
    if (url.pathname === '/') { response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); response.end(fixture); }
    else if (url.pathname === '/worker.js') { response.setHeader('Content-Type', 'text/javascript'); response.end("fetch('/api/from-worker').then(r=>r.json()).then(data=>postMessage(data));"); }
    else if (url.pathname === '/shared-worker.js') { response.setHeader('Content-Type', 'text/javascript'); response.end("onconnect=event=>fetch('/api/from-shared-worker').then(r=>r.json()).then(data=>event.ports[0].postMessage(data));"); }
    else if (url.pathname === '/sw.js') {
      response.setHeader('Content-Type', 'text/javascript');
      response.end("self.addEventListener('install',e=>e.waitUntil(self.skipWaiting()));self.addEventListener('activate',e=>e.waitUntil(self.clients.claim()));self.addEventListener('fetch',e=>{if(new URL(e.request.url).pathname==='/sw-proxy')e.respondWith(fetch('/api/from-service-worker'))});");
    } else if (url.pathname === '/frame' || url.pathname === '/popup') {
      const source = url.pathname === '/frame' ? 'frame' : 'popup';
      response.setHeader('Content-Type', 'text/html');
      response.end(`<title>${source}</title><script>fetch('/api/from-${source}').then(()=>window.done=true)</script>`);
    } else if (url.pathname === '/redirect') { response.writeHead(302, { Location: '/api/redirect-target' }); response.end(); }
    else if (url.pathname === '/large') { response.setHeader('Content-Type', 'text/plain'); response.end('L'.repeat(256 * 1024)); }
    else if (url.pathname === '/failure') request.socket.destroy();
    else if (url.pathname === '/download') { response.writeHead(200, { 'Content-Type': 'text/plain', 'Content-Disposition': 'attachment; filename="fixture.txt"' }); response.end('download-marker'); }
    else if (url.pathname === '/sse') { response.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' }); response.end('id: 1\ndata: sse-marker\n\n'); }
    else if (url.pathname === '/api/upload') {
      const chunks = [];
      for await (const chunk of request) chunks.push(chunk);
      const body = Buffer.concat(chunks).toString('utf8');
      uploadReceived = body.includes('local upload fixture') && body.includes('second fixture');
      json({ ok: uploadReceived, bytes: Buffer.byteLength(body) });
    } else if (url.pathname.startsWith('/api/')) {
      const chunks = [];
      for await (const chunk of request) chunks.push(chunk);
      json({ ok: true, path: url.pathname, body: Buffer.concat(chunks).toString('utf8'), message: 'response payload 中文' });
    } else { response.writeHead(404); response.end('not found'); }
  } catch (error) { response.destroy(error); }
});
server.on('connection', socket => { sockets.add(socket); socket.on('close', () => sockets.delete(socket)); });
server.on('upgrade', (request, socket) => {
  const accept = createHash('sha1').update(`${request.headers['sec-websocket-key']}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64');
  socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
  socket.on('error', () => {});
  socket.once('data', () => {
    const message = Buffer.from('ws-server-marker');
    socket.write(Buffer.concat([Buffer.from([0x81, message.length]), message]));
  });
});
await new Promise(resolve => server.listen(0, resolve));
const base = `http://127.0.0.1:${server.address().port}`;
let session, attached;
let summary;
try {
  session = await openSession({ headless: true, workDir: root, outputDir: path.join(root, 'capture'), maxBodyBytes: 128 * 1024 });
  console.log(`Launched local Edge fixture; output: ${session.outputDir}`);
  const page = session.page;
  await page.goto(base, { waitUntil: 'domcontentloaded' });
  try { await page.waitForFunction(() => window.fixtureDone || window.fixtureError, null, { timeout: 30000 }); }
  catch (error) {
    console.error('Fixture state:', await page.evaluate(() => ({ step: window.fixtureStep, error: window.fixtureError })));
    throw error;
  }
  assert.equal(await page.evaluate(() => window.fixtureError), undefined);
  assert.equal(await page.title(), 'Browser debugger integration fixture');
  await session.upload({ files: [fileA, fileB], selector: '#direct' });
  assert.equal(await page.locator('#direct').evaluate(input => input.files.length), 2);
  await session.upload({ files: [fileA, fileB], trigger: () => page.locator('#choose').click() });
  assert.equal(await page.locator('#chooser').evaluate(input => input.files.length), 2);
  await session.upload({ files: directory, selector: '#directory' });
  assert.equal(await page.locator('#directory').evaluate(input => input.files.length), 1);
  const uploaded = page.waitForResponse(response => response.url().endsWith('/api/upload'));
  await page.locator('#submit').click();
  assert.equal((await (await uploaded).json()).ok, true);
  assert.equal(uploadReceived, true);
  const popupPromise = page.waitForEvent('popup');
  await page.locator('#popup').click();
  const popup = await popupPromise;
  await popup.waitForFunction(() => window.done);
  const downloadPromise = page.waitForEvent('download');
  await page.locator('#download').click();
  const download = await downloadPromise;
  const downloadPath = path.join(root, download.suggestedFilename());
  await download.saveAs(downloadPath);
  assert.equal(await readFile(downloadPath, 'utf8'), 'download-marker');
  await page.screenshot({ path: path.join(root, 'fixture.png'), fullPage: true });
  assert.ok((await session.cdp('DOM.getDocument')).root.nodeId);
  await session.cdp('Debugger.enable');
  const evaluated = await session.cdp('Runtime.evaluate', { expression: '6 * 7', returnByValue: true });
  assert.equal(evaluated.result.value, 42);
  await session.cdp('Debugger.disable');
  assert.ok((await session.cdp('Browser.getVersion')).product);
  console.log('Verified file input, native chooser event, directory upload, download, screenshot, DOM and Debugger commands.');

  const mediaFrame = page.frames().find(frame => frame.url().endsWith('/frame'));
  await page.locator('iframe').evaluate(frame => { frame.style.height = '300px'; frame.style.width = '500px'; });
  await mediaFrame.evaluate(async () => {
    const audition = document.createElement('audio'); audition.id = 'auditionAudio'; audition.src = ''; audition.hidden = true; document.body.append(audition);
    const canvas = document.createElement('canvas'); canvas.width = 320; canvas.height = 180;
    const context = canvas.getContext('2d'); let tick = 0;
    const paint = setInterval(() => { context.fillStyle = '#007e75'; context.fillRect(0, 0, 320, 180);
      context.fillStyle = 'white'; context.font = '22px sans-serif'; context.fillText(`Media evidence ${++tick}`, 15, 90); }, 50);
    const stream = canvas.captureStream(10), chunks = [];
    const recorder = new MediaRecorder(stream, { mimeType: 'video/webm' });
    recorder.ondataavailable = event => chunks.push(event.data);
    const stopped = new Promise(resolve => recorder.onstop = resolve);
    recorder.start(); await new Promise(resolve => setTimeout(resolve, 6000)); recorder.stop(); await stopped;
    clearInterval(paint); stream.getTracks().forEach(track => track.stop());
    const video = document.createElement('video'); video.width = 320; video.height = 180; video.muted = true;
    video.src = URL.createObjectURL(new Blob(chunks, { type: 'video/webm' })); document.body.append(video);
    const track = video.addTextTrack('captions', 'Local captions', 'en'); track.mode = 'showing';
    track.addCue(new VTTCue(0, 10, 'media-evidence-caption')); await video.play();
  });
  const mediaState = await inspectMedia(page);
  assert.equal(mediaState.media.length, 2);
  assert.equal(mediaState.media.filter(item => item.placeholder).length, 1);
  const actualVideo = mediaState.media.find(item => item.tag === 'video');
  assert.ok(actualVideo.frameUrl.endsWith('/frame'));
  assert.equal(actualVideo.playbackRate, 1);
  const evidence = await captureMediaEvidence(page, { outputDir: path.join(root, 'media'), seconds: 3.5, intervalSeconds: .3, pollMs: 100 });
  assert.ok(evidence.images >= 2, `Expected progressing media frames, got ${evidence.images}`);
  const manifest = await readFile(evidence.manifestPath, 'utf8');
  assert.ok(manifest.includes('media-evidence-caption'));
  const snapshots = manifest.trim().split('\n').flatMap(line => JSON.parse(line).evidence);
  assert.ok(snapshots.at(-1).currentTime > snapshots[0].currentTime);
  for (const snapshot of snapshots) if (snapshot.image) assert.ok((await readFile(path.join(root, 'media', snapshot.image))).length > 0);
  const cancelled = new AbortController(); cancelled.abort();
  await assert.rejects(captureMediaEvidence(page, { outputDir: path.join(root, 'media'), signal: cancelled.signal }), { name: 'AbortError' });
  console.log('Verified nested-frame media inspection, advancing video screenshots/captions, and cancellation.');

  attached = await openSession({ endpoint: session.endpoint, workDir: root, outputDir: path.join(root, 'attached') });
  attached.page = attached.context.pages().find(page => page.url() === `${base}/`);
  assert.ok(attached.page, 'Select the actual fixture tab rather than browser-internal pages or the popup.');
  const dialogOpened = new Promise(resolve => page.once('dialog', resolve));
  const dialogResult = page.evaluate(() => confirm('attached-observer-dialog'));
  const ownerDialog = await dialogOpened;
  await new Promise(resolve => setTimeout(resolve, 200));
  await ownerDialog.accept();
  assert.equal(await dialogResult, true, 'An attached observer must not automatically dismiss dialogs belonging to the primary client.');
  await attached.page.evaluate(() => fetch('/api/attached').then(r => r.json()));
  await attached.close();
  assert.equal(await page.evaluate(() => 40 + 2), 42, 'Disconnecting the attached client must preserve the original browser.');
  await page.evaluate(() => fetch('/api/after-disconnect').then(r => r.json()));
  const attachedRequests = JSON.parse(await readFile(path.join(root, 'attached', 'requests.json'), 'utf8'));
  assert.ok(attachedRequests.some(r => r.request.url.endsWith('/api/attached')));

  const live = await session.flush();
  assert.ok(live.requestCount > 10);
  await popup.close();
  summary = await session.close();
  const requests = JSON.parse(await readFile(path.join(root, 'capture', 'requests.json'), 'utf8'));
  const find = suffix => requests.filter(r => new URL(r.request.url).pathname === suffix);
  for (const suffix of ['/api/example', '/api/xhr', '/api/from-worker', '/api/from-shared-worker', '/api/from-service-worker', '/api/from-frame', '/api/from-popup', '/api/upload', '/api/redirect-target', '/api/after-disconnect']) {
    assert.ok(find(suffix).length, `Missing capture: ${suffix}`);
  }
  for (const [suffix, expectedType] of [['/api/from-worker', 'worker'], ['/api/from-shared-worker', 'shared_worker'], ['/api/from-service-worker', 'service_worker']]) {
    assert.ok(find(suffix).some(r => r.targetType === expectedType), `Missing independent ${expectedType} target capture`);
  }
  assert.ok(find('/redirect').some(r => r.state === 'redirect' && r.response.status === 302));
  for (const suffix of ['/worker.js', '/shared-worker.js', '/frame']) {
    assert.ok(find(suffix).some(r => r.state === 'finished'), `Missing completion after target migration: ${suffix}`);
  }
  assert.ok(find('/failure').some(r => r.state === 'failed'));
  assert.ok(find('/large').some(r => r.bodyUnavailable?.includes('maxBodyBytes')));
  const example = find('/api/example').find(r => r.body);
  assert.ok(example.request.postData.includes('request payload'));
  assert.equal(example.requestExtraInfo.headers['X-Probe'] ?? example.requestExtraInfo.headers['x-probe'], 'request-extra-info');
  const responseBody = JSON.parse(await readFile(path.join(root, 'capture', example.body.file), 'utf8'));
  assert.equal(responseBody.message, 'response payload 中文');
  const events = (await readFile(path.join(root, 'capture', 'events.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line));
  assert.ok(events.some(e => e.method === 'Network.webSocketFrameSent' && e.params.response.payloadData === 'ws-client-marker'));
  assert.ok(events.some(e => e.method === 'Network.webSocketFrameReceived' && e.params.response.payloadData === 'ws-server-marker'));
  assert.ok(events.some(e => e.method === 'Network.eventSourceMessageReceived' && e.params.data === 'sse-marker'));
  assert.ok((await readFile(path.join(root, 'capture', 'console.jsonl'), 'utf8')).includes('fixture-console-marker'));
  const cdpHar = JSON.parse(await readFile(path.join(root, 'capture', 'network.cdp.har'), 'utf8'));
  assert.equal(cdpHar.log.version, '1.2');
  assert.equal(cdpHar.log.entries.length, requests.length);
  const nativeHar = JSON.parse(await readFile(path.join(root, 'capture', 'network.playwright.har'), 'utf8'));
  assert.ok(nativeHar.log.entries.length > 10);
  const schema = JSON.parse(await readFile(path.join(root, 'capture', 'protocol.json'), 'utf8'));
  assert.ok(schema.domains.some(domain => domain.domain === 'Network'));
  assert.equal(summary.warnings.length, 0, JSON.stringify(summary.warnings));
  const report = {
    passed: true, testedAt: new Date().toISOString(), browser: session.version.Browser,
    outputDir: root, requests: requests.length, targets: [...new Set(requests.map(r => r.targetType))],
    checks: ['multiple files', 'file chooser', 'directory upload', 'server upload response', 'download', 'screenshot', 'DOM', 'Debugger', 'raw CDP', 'existing browser attach/disconnect', 'fetch', 'XHR', 'redirect hops', 'network failure', 'response body', 'ExtraInfo headers', 'large-body omission', 'dedicated worker', 'shared worker', 'service worker', 'cross-origin iframe', 'cross-target request completion', 'popup', 'WebSocket sent/received', 'SSE', 'console', 'all-target HAR', 'native HAR', 'live protocol schema', 'nested-frame media state', 'video frames and active captions', 'media cancellation', 'empty audio placeholder filtering'],
  };
  await writeFile(path.join(root, 'test-result.json'), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
  console.log(JSON.stringify(report, null, 2));
} finally {
  if (attached && !attached.closed) await attached.close().catch(() => {});
  if (session && !session.closed) await session.close().catch(() => {});
  for (const socket of sockets) socket.destroy();
  await new Promise(resolve => server.close(resolve));
}