Spaces:
Running
Running
| /** | |
| * 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, '<').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 `<span style="color:${color}">${escapeHtml(token)}</span>`; | |
| } | |
| // 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 `<p>${parts.join(' ')}</p>`; | |
| } | |
| /** | |
| * @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 = `<pre data-language="javascript"><code class="language-javascript">${generateShikiCode(rng)}</code></pre>`; | |
| 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 = `<img src="data:image/png;base64,${TINY_PNG_B64}" alt="i">`; | |
| let html = `<!DOCTYPE html><html><head><meta charset="UTF-8"><style>${CSS}</style></head><body><div class="chat-container">`; | |
| let i = 0, j = 0; | |
| while (i < codeIters || j < textIters) { | |
| if (i < codeIters) { | |
| html += `<div class="message-row"><div class="avatar">🤖</div><div class="message-bubble ai-bubble">${codeBlock}</div></div>`; | |
| i++; | |
| } | |
| if (j < textIters) { | |
| html += `<div class="message-row"><div class="avatar">👤</div><div class="message-bubble user-bubble">${paraBlock}</div></div>`; | |
| 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 += `<div class="message-row"><div class="avatar">🤖</div><div class="message-bubble ai-bubble">${imageTag}</div></div>`; | |
| } | |
| html += '</div></body></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}`); | |
| } | |
| } | |