/** * Stress test payload generator for PDF export benchmarking. * * Generates a realistic XWX AI Chat Exporter PDF HTML document: * - chat message bubbles (AI + user) * - markdown-rendered content: headings, paragraphs, tables * - Shiki-style syntax highlighted code blocks (default 'github' theme) * - optional base64 images (small PNGs, deterministic) * * Deterministic output: same profile => byte-identical HTML, so before/after * optimization runs are comparable. */ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const CSS = ` @media print { @page { size: A4; margin: 15mm 10mm; } body { -webkit-print-color-adjust: exact; } } body { font-family: -apple-system, sans-serif; font-size: 14px; line-height: 1.6; max-width: 746px; margin: 0 auto; padding: 20px; } h1,h2,h3 { font-weight: 600; margin: 16px 0 8px; } pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; border: 1px solid #e1e4e8; } code { font-family: monospace; font-size: 13px; } p { margin: 8px 0; } table { border-collapse: collapse; width: 100%; margin: 12px 0; } th,td { border: 1px solid #dfe2e5; padding: 8px 12px; } th { background: #f1f3f4; } .chat-container { display: flex; flex-direction: column; gap: 16px; } .message-row { display: flex; gap: 10px; } .message-bubble { max-width: 85%; padding: 12px 16px; border-radius: 12px; } .ai-bubble { background: #fff; border: 1px solid #eee; } .user-bubble { background: #e8f0fe; } .avatar { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; } `.trim(); function escapeHtml(s) { return s.replace(/&/g, '&').replace(//g, '>'); } const CODE_LINES = [ 'async function fetchData(url, options = {}) {', ' const controller = new AbortController();', ' const timeout = setTimeout(() => controller.abort(), 30000);', ' try {', ' const response = await fetch(url, {', ' ...options,', ' signal: controller.signal,', " headers: { 'Content-Type': 'application/json' },", ' });', ' if (!response.ok) {', ' throw new Error(`HTTP ${response.status}: ${response.statusText}`);', ' }', ' const data = await response.json();', " console.log('Data received:', data);", ' return data;', ' } catch (error) {', " console.error('Fetch failed:', error.message);", ' throw error;', ' } finally {', ' clearTimeout(timeout);', ' }', '}', ]; function shikiWrap(token, color) { return `${escapeHtml(token)}`; } // Deterministic pseudo-random generator (mulberry32) so payloads are stable. function mulberry32(seed) { return function () { let t = (seed += 0x6d2b79f5); t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function generateShikiCode(rng) { return CODE_LINES.map((line) => { const tokens = line.split(/(\s+|[^a-zA-Z0-9_\s]+)/g); return tokens.map((tok) => { if (!tok) return ''; if (/^\s+$/.test(tok)) return tok; let color = '#24292e'; if (/^(const|let|var|function|return|if|else|for|async|await|import|from|export|class|extends|new|try|catch|throw|typeof|this|switch|case|break|continue|while|do|of|in|static|get|set|super)$/.test(tok)) color = '#d73a49'; else if (/^(console|log|error|fetch|Promise|Math|Date|JSON|Map|Set|Array|Object|String|Number|Error|setTimeout|clearTimeout|AbortController|AbortSignal|require|module|exports|process)$/.test(tok)) color = '#6f42c1'; else if (/^\d+$/.test(tok)) color = '#005cc5'; else if (/^[{}()\[\];,.:=+\-*/<>!&|?%@~^'"`]+$/.test(tok)) color = '#24292e'; else if (/^[A-Z]/.test(tok) && tok.length > 1) color = '#e36209'; return shikiWrap(tok, color); }).join(''); }).join('\n'); } // Tiny 1x1 PNG base64 (repeated to simulate distinct images) const TINY_PNG_B64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='; function buildParagraph(rng, words) { const vocab = [ '性能', '优化', '浏览器', '渲染', 'PDF', '导出', '并发', '线程', '内存', '缓存', '请求', '服务器', 'docker', 'huggingface', 'puppeteer', 'chromium', 'the', 'quick', 'brown', 'fox', 'jumps', 'over', 'lazy', 'dog', 'data', 'stream', 'buffer', 'async', 'await', 'promise', 'queue', 'worker', ]; const parts = []; for (let i = 0; i < words; i++) { parts.push(vocab[Math.floor(rng() * vocab.length)]); } return `

${parts.join(' ')}

`; } /** * @param {object} opts * @param {number} opts.textMB target text-only size in MB * @param {number} opts.images number of base64 images to embed * @param {number} opts.codeRatio fraction (0-1) of content that is code blocks * @param {number} opts.messages number of chat messages */ function buildHtml(opts = {}) { const textMB = opts.textMB ?? 0.3; const images = opts.images ?? 0; const codeRatio = opts.codeRatio ?? 0.35; const rng = mulberry32(0x5eed1234); const codeBlock = `
${generateShikiCode(rng)}
`; const codeBlockSize = Buffer.byteLength(codeBlock, 'utf8'); const paraWords = 40; const paraBlock = buildParagraph(rng, paraWords); const paraBlockSize = Buffer.byteLength(paraBlock, 'utf8'); const overhead = 4096; const targetBytes = Math.max(2048, textMB * 1024 * 1024 - overhead); const codeBytes = targetBytes * codeRatio; const textBytes = targetBytes * (1 - codeRatio); const codeIters = Math.max(1, Math.ceil(codeBytes / codeBlockSize)); const textIters = Math.max(1, Math.ceil(textBytes / paraBlockSize)); const imageTag = `i`; let html = `
`; let i = 0, j = 0; while (i < codeIters || j < textIters) { if (i < codeIters) { html += `
🤖
${codeBlock}
`; i++; } if (j < textIters) { html += `
👤
${paraBlock}
`; j++; } } // Append images at the end (outside the loop so they don't blow up text size). for (let k = 0; k < images; k++) { html += `
🤖
${imageTag}
`; } html += '
'; return html; } /** * Build a full request body for /api/generate_pdf. * @param {object} opts same opts as buildHtml * @param {object} meta optional metadata overrides */ function buildPdfRequest(opts = {}, meta = {}) { const html = buildHtml(opts); const textOnlySizeMB = Buffer.byteLength(html, 'utf8') / (1024 * 1024); const images = opts.images ?? 0; return { html, textOnlySizeMB, codeTheme: meta.codeTheme ?? 'github', showWatermark: meta.showWatermark ?? false, imageCount: images, totalImageSizeMB: (images * 1) / (1024 * 1024), // 1x1 png ~ 70 bytes messageCount: meta.messageCount ?? 50, platform: meta.platform ?? 'StressTest', language: 'en-US', extensionVersion: '2.1.6', exportCount: 0, exportPdf: 0, exportMd: 0, exportTxt: 0, exportDocx: 0, exportJson: 0, exportClipboard: 0, exportNotion: 0, }; } /** * Deterministically generate and cache a payload profile to a file. * Returns { html, requestBody, sizeMB }. */ function prepareProfile(profile, outDir = path.join(__dirname, 'results')) { const hash = crypto.createHash('md5').update(JSON.stringify(profile)).digest('hex').slice(0, 10); const file = path.join(outDir, `payload-${hash}.json`); if (fs.existsSync(file)) { const cached = JSON.parse(fs.readFileSync(file, 'utf8')); cached.requestBody.html = cached.html; return cached; } const requestBody = buildPdfRequest(profile.opts, profile.meta || {}); const payload = { profile: profile.name, html: requestBody.html, textOnlySizeMB: requestBody.textOnlySizeMB, imageCount: requestBody.imageCount, htmlSizeMB: Buffer.byteLength(requestBody.html, 'utf8') / (1024 * 1024), requestBody: { ...requestBody, html: undefined, // strip html from cache to keep file small }, }; if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); fs.writeFileSync(file, JSON.stringify(payload)); return payload; } module.exports = { buildHtml, buildPdfRequest, prepareProfile, CODE_LINES }; if (require.main === module) { const profiles = { small: { name: 'small', opts: { textMB: 0.05, images: 0, codeRatio: 0.3 } }, medium: { name: 'medium', opts: { textMB: 0.3, images: 3, codeRatio: 0.35 } }, large: { name: 'large', opts: { textMB: 1.0, images: 10, codeRatio: 0.4 } }, }; for (const [key, prof] of Object.entries(profiles)) { const p = prepareProfile(prof); console.log(`${key.padEnd(8)} html=${p.htmlSizeMB.toFixed(3)} MB text=${p.textOnlySizeMB.toFixed(3)} MB images=${p.imageCount}`); } }