api-server / tests /stress /payload-generator.js
XWX-AI's picture
fix(widget): DOCX图表fallback成数据表格根因修复 — widget渲染浏览器池+CDP有限超时+渲染硬超时+队列超时取消+BrowserPool补位bug+body-parser JSON兜底, bump v2.1.11
24a2ddf
Raw
History Blame Contribute Delete
9.32 kB
/**
* 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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">&#129302;</div><div class="message-bubble ai-bubble">${codeBlock}</div></div>`;
i++;
}
if (j < textIters) {
html += `<div class="message-row"><div class="avatar">&#128100;</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">&#129302;</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}`);
}
}