import { chromium, firefox, webkit, Browser } from '@playwright/test'; import { createServer, Server } from 'http'; import { readFileSync, writeFileSync, mkdirSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import * as vega from 'vega'; import * as vegaLite from 'vega-lite'; import sharp from 'sharp'; import type { BrowserStoreBenchmarkResults, BrowserBenchmarkResult } from '../harness/browser-types.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const HARNESS_PATH = join(__dirname, 'harness.html'); const BROWSERS = ['chromium', 'firefox', 'webkit'] as const; const STORES = ['indexeddb', 'localstorage', 'pouchdb', 'pglite'] as const; const ITERATIONS = 10; const PORT = 9876; type BrowserType = typeof BROWSERS[number]; type StoreType = typeof STORES[number]; interface StoreRunResult { success: boolean; error?: string; init: BrowserBenchmarkResult; writes: { allUrls: BrowserBenchmarkResult; allMetadata: BrowserBenchmarkResult; allImages: BrowserBenchmarkResult; allDocuments: BrowserBenchmarkResult; }; reads: { recentUrls: BrowserBenchmarkResult; randomImages: BrowserBenchmarkResult; randomDocuments: BrowserBenchmarkResult; }; storage: { totalBytes: number }; } interface BrowserRunResults { browserName: string; userAgent: string; stores: Record; success: boolean; error?: string; } function formatDuration(ms: number): string { if (ms < 1000) return `${ms.toFixed(0)}ms`; if (ms < 60000) return `${(ms / 1000).toFixed(2)}s`; return `${(ms / 60000).toFixed(2)}m`; } function medianExcludingExtremes(values: number[]): number { if (values.length <= 2) return values[0] ?? 0; const sorted = [...values].sort((a, b) => a - b); const trimmed = sorted.slice(1, -1); const mid = Math.floor(trimmed.length / 2); if (trimmed.length % 2 === 0) { return (trimmed[mid - 1] + trimmed[mid]) / 2; } return trimmed[mid]; } function aggregateBenchmarkResults(results: BrowserBenchmarkResult[]): BrowserBenchmarkResult { const successful = results.filter(r => !r.failed); if (successful.length === 0) return results[0]; const durations = successful.map(r => r.durationMs); return { name: successful[0].name, durationMs: medianExcludingExtremes(durations), itemCount: successful[0].itemCount, bytesProcessed: successful[0].bytesProcessed, failed: false }; } function aggregateStoreRuns(runs: StoreRunResult[], storeName: string, userAgent: string): BrowserStoreBenchmarkResults { const successful = runs.filter(r => r.success); if (successful.length === 0) { return { storeName, userAgent, init: { name: 'init', durationMs: 0, failed: true, error: runs[0]?.error }, writes: { allUrls: { name: 'allUrls', durationMs: 0, failed: true }, allMetadata: { name: 'allMetadata', durationMs: 0, failed: true }, allImages: { name: 'allImages', durationMs: 0, failed: true }, allDocuments: { name: 'allDocuments', durationMs: 0, failed: true } }, reads: { recentUrls: { name: 'recentUrls', durationMs: 0, failed: true }, randomImages: { name: 'randomImages', durationMs: 0, failed: true }, randomDocuments: { name: 'randomDocuments', durationMs: 0, failed: true } }, storage: { totalBytes: 0 } }; } return { storeName, userAgent, init: aggregateBenchmarkResults(successful.map(r => r.init)), writes: { allUrls: aggregateBenchmarkResults(successful.map(r => r.writes.allUrls)), allMetadata: aggregateBenchmarkResults(successful.map(r => r.writes.allMetadata)), allImages: aggregateBenchmarkResults(successful.map(r => r.writes.allImages)), allDocuments: aggregateBenchmarkResults(successful.map(r => r.writes.allDocuments)) }, reads: { recentUrls: aggregateBenchmarkResults(successful.map(r => r.reads.recentUrls)), randomImages: aggregateBenchmarkResults(successful.map(r => r.reads.randomImages)), randomDocuments: aggregateBenchmarkResults(successful.map(r => r.reads.randomDocuments)) }, storage: { totalBytes: medianExcludingExtremes(successful.map(r => r.storage.totalBytes)) } }; } const BENCHMARK_TIMEOUT = 30000; // 30 seconds per benchmark async function benchmarkStore(page: any, store: StoreType): Promise { const script = ` (async function(storeName) { const testData = window.generateTestData(); function createResult(name, durationMs, itemCount, bytesProcessed) { return { name: name, durationMs: durationMs, itemCount: itemCount, bytesProcessed: bytesProcessed, failed: false }; } if (storeName === 'indexeddb') { const DB_NAME = 'localstress-bench'; try { // Delete old DB await new Promise(function(resolve, reject) { const req = indexedDB.deleteDatabase(DB_NAME); req.onsuccess = function() { resolve(); }; req.onerror = function() { reject(req.error); }; }); // Init const initStart = performance.now(); const db = await new Promise(function(resolve, reject) { const req = indexedDB.open(DB_NAME, 1); req.onupgradeneeded = function(e) { const db = e.target.result; const urlStore = db.createObjectStore('urls', { keyPath: 'id' }); urlStore.createIndex('createdAt', 'createdAt'); db.createObjectStore('images', { keyPath: 'id' }); db.createObjectStore('documents', { keyPath: 'id' }); const metaStore = db.createObjectStore('metadata', { keyPath: 'id' }); metaStore.createIndex('timestamp', 'timestamp'); }; req.onsuccess = function() { resolve(req.result); }; req.onerror = function() { reject(req.error); }; }); const initDuration = performance.now() - initStart; // Write URLs const urlsStart = performance.now(); await new Promise(function(resolve, reject) { const tx = db.transaction(['urls'], 'readwrite'); const store = tx.objectStore('urls'); for (let i = 0; i < testData.urls.length; i++) { store.add(testData.urls[i]); } tx.oncomplete = function() { resolve(); }; tx.onerror = function() { reject(tx.error); }; }); const urlsDuration = performance.now() - urlsStart; // Write Metadata const metaStart = performance.now(); await new Promise(function(resolve, reject) { const tx = db.transaction(['metadata'], 'readwrite'); const store = tx.objectStore('metadata'); for (let i = 0; i < testData.metadata.length; i++) { store.add(testData.metadata[i]); } tx.oncomplete = function() { resolve(); }; tx.onerror = function() { reject(tx.error); }; }); const metaDuration = performance.now() - metaStart; // Write Images const imagesStart = performance.now(); let imageBytes = 0; await new Promise(function(resolve, reject) { const tx = db.transaction(['images'], 'readwrite'); const store = tx.objectStore('images'); for (let i = 0; i < testData.images.length; i++) { const img = testData.images[i]; store.add({ id: img.id, data: img.data, size: img.data.byteLength }); imageBytes += img.data.byteLength; } tx.oncomplete = function() { resolve(); }; tx.onerror = function() { reject(tx.error); }; }); const imagesDuration = performance.now() - imagesStart; // Write Documents const docsStart = performance.now(); let docBytes = 0; await new Promise(function(resolve, reject) { const tx = db.transaction(['documents'], 'readwrite'); const store = tx.objectStore('documents'); for (let i = 0; i < testData.documents.length; i++) { const doc = testData.documents[i]; const size = new TextEncoder().encode(doc.content).byteLength; store.add({ id: doc.id, content: doc.content, size: size }); docBytes += size; } tx.oncomplete = function() { resolve(); }; tx.onerror = function() { reject(tx.error); }; }); const docsDuration = performance.now() - docsStart; // Read recent URLs const readUrlsStart = performance.now(); await new Promise(function(resolve, reject) { const tx = db.transaction(['urls'], 'readonly'); const idx = tx.objectStore('urls').index('createdAt'); const req = idx.openCursor(null, 'prev'); const results = []; req.onsuccess = function(e) { const cursor = e.target.result; if (cursor && results.length < 100) { results.push(cursor.value); cursor.continue(); } else { resolve(results); } }; req.onerror = function() { reject(req.error); }; }); const readUrlsDuration = performance.now() - readUrlsStart; // Read random images const readImagesStart = performance.now(); const imageIds = testData.images.slice(0, Math.min(10, testData.images.length)).map(function(i) { return i.id; }); let readImageBytes = 0; if (imageIds.length > 0) { await new Promise(function(resolve, reject) { const tx = db.transaction(['images'], 'readonly'); const store = tx.objectStore('images'); let completed = 0; for (let i = 0; i < imageIds.length; i++) { const req = store.get(imageIds[i]); req.onsuccess = function() { if (req.result) readImageBytes += req.result.data.byteLength; completed++; if (completed === imageIds.length) resolve(); }; req.onerror = function() { reject(req.error); }; } }); } const readImagesDuration = performance.now() - readImagesStart; // Read random documents const readDocsStart = performance.now(); const docIds = testData.documents.slice(0, Math.min(50, testData.documents.length)).map(function(d) { return d.id; }); let readDocBytes = 0; if (docIds.length > 0) { await new Promise(function(resolve, reject) { const tx = db.transaction(['documents'], 'readonly'); const store = tx.objectStore('documents'); let completed = 0; for (let i = 0; i < docIds.length; i++) { const req = store.get(docIds[i]); req.onsuccess = function() { if (req.result) readDocBytes += new TextEncoder().encode(req.result.content).byteLength; completed++; if (completed === docIds.length) resolve(); }; req.onerror = function() { reject(req.error); }; } }); } const readDocsDuration = performance.now() - readDocsStart; // Storage const estimate = await navigator.storage.estimate(); const storageBytes = estimate.usage || 0; db.close(); await new Promise(function(resolve) { const req = indexedDB.deleteDatabase(DB_NAME); req.onsuccess = function() { resolve(); }; req.onerror = function() { resolve(); }; }); return { success: true, init: createResult('init', initDuration), writes: { allUrls: createResult('allUrls', urlsDuration, testData.urls.length), allMetadata: createResult('allMetadata', metaDuration, testData.metadata.length), allImages: createResult('allImages', imagesDuration, testData.images.length, imageBytes), allDocuments: createResult('allDocuments', docsDuration, testData.documents.length, docBytes) }, reads: { recentUrls: createResult('recentUrls', readUrlsDuration, 100), randomImages: createResult('randomImages', readImagesDuration, imageIds.length, readImageBytes), randomDocuments: createResult('randomDocuments', readDocsDuration, docIds.length, readDocBytes) }, storage: { totalBytes: storageBytes } }; } catch (error) { return { success: false, error: error.message }; } } else if (storeName === 'localstorage') { try { localStorage.clear(); // Init const initStart = performance.now(); const initDuration = performance.now() - initStart; // Write URLs const urlsStart = performance.now(); localStorage.setItem('urls', JSON.stringify(testData.urls)); const urlsDuration = performance.now() - urlsStart; // Write Metadata const metaStart = performance.now(); localStorage.setItem('metadata', JSON.stringify(testData.metadata)); const metaDuration = performance.now() - metaStart; // Images - base64 encode const imagesStart = performance.now(); let imageBytes = 0; const imageData = []; for (let i = 0; i < testData.images.length; i++) { const img = testData.images[i]; const base64 = btoa(String.fromCharCode.apply(null, img.data)); imageData.push({ id: img.id, data: base64 }); imageBytes += img.data.byteLength; } localStorage.setItem('images', JSON.stringify(imageData)); const imagesDuration = performance.now() - imagesStart; // Documents const docsStart = performance.now(); let docBytes = 0; for (let i = 0; i < testData.documents.length; i++) { docBytes += new TextEncoder().encode(testData.documents[i].content).byteLength; } localStorage.setItem('documents', JSON.stringify(testData.documents)); const docsDuration = performance.now() - docsStart; // Read URLs const readUrlsStart = performance.now(); const urls = JSON.parse(localStorage.getItem('urls') || '[]'); urls.sort(function(a, b) { return b.createdAt - a.createdAt; }).slice(0, 100); const readUrlsDuration = performance.now() - readUrlsStart; // Read Images const readImagesStart = performance.now(); const images = JSON.parse(localStorage.getItem('images') || '[]'); let readImageBytes = 0; for (let i = 0; i < Math.min(10, images.length); i++) { const binary = atob(images[i].data); readImageBytes += binary.length; } const readImagesDuration = performance.now() - readImagesStart; // Read Documents const readDocsStart = performance.now(); const docs = JSON.parse(localStorage.getItem('documents') || '[]'); let readDocBytes = 0; for (let i = 0; i < Math.min(50, docs.length); i++) { readDocBytes += new TextEncoder().encode(docs[i].content).byteLength; } const readDocsDuration = performance.now() - readDocsStart; // Storage estimate const estimate = await navigator.storage.estimate(); const storageBytes = estimate.usage || 0; localStorage.clear(); return { success: true, init: createResult('init', initDuration), writes: { allUrls: createResult('allUrls', urlsDuration, testData.urls.length), allMetadata: createResult('allMetadata', metaDuration, testData.metadata.length), allImages: createResult('allImages', imagesDuration, testData.images.length, imageBytes), allDocuments: createResult('allDocuments', docsDuration, testData.documents.length, docBytes) }, reads: { recentUrls: createResult('recentUrls', readUrlsDuration, 100), randomImages: createResult('randomImages', readImagesDuration, 10, readImageBytes), randomDocuments: createResult('randomDocuments', readDocsDuration, 50, readDocBytes) }, storage: { totalBytes: storageBytes } }; } catch (error) { return { success: false, error: error.message }; } } else if (storeName === 'pouchdb') { if (!window.PouchDB) { return { success: false, error: 'PouchDB not loaded' }; } const PouchDB = window.PouchDB; const DB_PREFIX = 'localstress-pouchdb-' + Date.now(); try { // Init - create databases const initStart = performance.now(); const urlsDb = new PouchDB(DB_PREFIX + '-urls'); const metaDb = new PouchDB(DB_PREFIX + '-metadata'); const imagesDb = new PouchDB(DB_PREFIX + '-images'); const docsDb = new PouchDB(DB_PREFIX + '-documents'); const initDuration = performance.now() - initStart; // Write URLs const urlsStart = performance.now(); const urlDocs = testData.urls.map(function(url) { return { _id: url.id, url: url.url, title: url.title, createdAt: url.createdAt, tags: url.tags }; }); await urlsDb.bulkDocs(urlDocs); const urlsDuration = performance.now() - urlsStart; // Write Metadata const metaStart = performance.now(); const metaDocs = testData.metadata.map(function(m) { return { _id: m.id, key: m.key, value: m.value, category: m.category, timestamp: m.timestamp }; }); await metaDb.bulkDocs(metaDocs); const metaDuration = performance.now() - metaStart; // Write Images (as base64) const imagesStart = performance.now(); let imageBytes = 0; for (let i = 0; i < testData.images.length; i++) { const img = testData.images[i]; // Convert Uint8Array to base64 in chunks to avoid stack overflow let binary = ''; const bytes = img.data; const chunkSize = 8192; for (let j = 0; j < bytes.length; j += chunkSize) { const chunk = bytes.subarray(j, j + chunkSize); binary += String.fromCharCode.apply(null, chunk); } const base64 = btoa(binary); await imagesDb.put({ _id: img.id, data: base64, size: img.data.byteLength }); imageBytes += img.data.byteLength; } const imagesDuration = performance.now() - imagesStart; // Write Documents const docsStart = performance.now(); let docBytes = 0; const docDocs = testData.documents.map(function(doc) { const size = new TextEncoder().encode(doc.content).byteLength; docBytes += size; return { _id: doc.id, content: doc.content, size: size }; }); await docsDb.bulkDocs(docDocs); const docsDuration = performance.now() - docsStart; // Read recent URLs - PouchDB allDocs with descending const readUrlsStart = performance.now(); const urlResult = await urlsDb.allDocs({ include_docs: true, limit: 100 }); // Sort by createdAt descending (PouchDB doesn't have native sorting without views) const sortedUrls = urlResult.rows .map(function(r) { return r.doc; }) .sort(function(a, b) { return b.createdAt - a.createdAt; }) .slice(0, 100); const readUrlsDuration = performance.now() - readUrlsStart; // Read random images const readImagesStart = performance.now(); const imageIds = testData.images.slice(0, Math.min(10, testData.images.length)).map(function(i) { return i.id; }); let readImageBytes = 0; for (let i = 0; i < imageIds.length; i++) { try { const doc = await imagesDb.get(imageIds[i]); if (doc.data) { const binary = atob(doc.data); readImageBytes += binary.length; } } catch (e) {} } const readImagesDuration = performance.now() - readImagesStart; // Read random documents const readDocsStart = performance.now(); const docIds = testData.documents.slice(0, Math.min(50, testData.documents.length)).map(function(d) { return d.id; }); let readDocBytes = 0; for (let i = 0; i < docIds.length; i++) { try { const doc = await docsDb.get(docIds[i]); if (doc.content) { readDocBytes += new TextEncoder().encode(doc.content).byteLength; } } catch (e) {} } const readDocsDuration = performance.now() - readDocsStart; // Storage const estimate = await navigator.storage.estimate(); const storageBytes = estimate.usage || 0; // Cleanup await urlsDb.destroy(); await metaDb.destroy(); await imagesDb.destroy(); await docsDb.destroy(); return { success: true, init: createResult('init', initDuration), writes: { allUrls: createResult('allUrls', urlsDuration, testData.urls.length), allMetadata: createResult('allMetadata', metaDuration, testData.metadata.length), allImages: createResult('allImages', imagesDuration, testData.images.length, imageBytes), allDocuments: createResult('allDocuments', docsDuration, testData.documents.length, docBytes) }, reads: { recentUrls: createResult('recentUrls', readUrlsDuration, 100), randomImages: createResult('randomImages', readImagesDuration, imageIds.length, readImageBytes), randomDocuments: createResult('randomDocuments', readDocsDuration, docIds.length, readDocBytes) }, storage: { totalBytes: storageBytes } }; } catch (error) { return { success: false, error: error.message }; } } else if (storeName === 'pglite') { if (!window.PGlite) { return { success: false, error: 'PGlite not loaded' }; } const PGlite = window.PGlite; try { // Init - create in-memory database and tables const initStart = performance.now(); const db = new PGlite(); await db.exec(\` CREATE TABLE IF NOT EXISTS urls ( id TEXT PRIMARY KEY, url TEXT, title TEXT, created_at BIGINT, tags TEXT ); CREATE TABLE IF NOT EXISTS metadata ( id TEXT PRIMARY KEY, key TEXT, value TEXT, category TEXT, timestamp BIGINT ); CREATE TABLE IF NOT EXISTS images ( id TEXT PRIMARY KEY, data TEXT, size INTEGER ); CREATE TABLE IF NOT EXISTS documents ( id TEXT PRIMARY KEY, content TEXT, size INTEGER ); CREATE INDEX IF NOT EXISTS idx_urls_created ON urls(created_at); CREATE INDEX IF NOT EXISTS idx_meta_timestamp ON metadata(timestamp); \`); const initDuration = performance.now() - initStart; // Write URLs const urlsStart = performance.now(); for (let i = 0; i < testData.urls.length; i++) { const u = testData.urls[i]; await db.query( 'INSERT INTO urls (id, url, title, created_at, tags) VALUES ($1, $2, $3, $4, $5)', [u.id, u.url, u.title, u.createdAt, JSON.stringify(u.tags)] ); } const urlsDuration = performance.now() - urlsStart; // Write Metadata const metaStart = performance.now(); for (let i = 0; i < testData.metadata.length; i++) { const m = testData.metadata[i]; await db.query( 'INSERT INTO metadata (id, key, value, category, timestamp) VALUES ($1, $2, $3, $4, $5)', [m.id, m.key, String(m.value), m.category, m.timestamp] ); } const metaDuration = performance.now() - metaStart; // Write Images (as base64) const imagesStart = performance.now(); let imageBytes = 0; for (let i = 0; i < testData.images.length; i++) { const img = testData.images[i]; let binary = ''; const bytes = img.data; const chunkSize = 8192; for (let j = 0; j < bytes.length; j += chunkSize) { const chunk = bytes.subarray(j, j + chunkSize); binary += String.fromCharCode.apply(null, chunk); } const base64 = btoa(binary); await db.query( 'INSERT INTO images (id, data, size) VALUES ($1, $2, $3)', [img.id, base64, img.data.byteLength] ); imageBytes += img.data.byteLength; } const imagesDuration = performance.now() - imagesStart; // Write Documents const docsStart = performance.now(); let docBytes = 0; for (let i = 0; i < testData.documents.length; i++) { const doc = testData.documents[i]; const size = new TextEncoder().encode(doc.content).byteLength; await db.query( 'INSERT INTO documents (id, content, size) VALUES ($1, $2, $3)', [doc.id, doc.content, size] ); docBytes += size; } const docsDuration = performance.now() - docsStart; // Read recent URLs const readUrlsStart = performance.now(); await db.query('SELECT * FROM urls ORDER BY created_at DESC LIMIT 100'); const readUrlsDuration = performance.now() - readUrlsStart; // Read random images const readImagesStart = performance.now(); const imageIds = testData.images.slice(0, Math.min(10, testData.images.length)).map(function(i) { return i.id; }); let readImageBytes = 0; for (let i = 0; i < imageIds.length; i++) { const result = await db.query('SELECT data FROM images WHERE id = $1', [imageIds[i]]); if (result.rows.length > 0 && result.rows[0].data) { const binary = atob(result.rows[0].data); readImageBytes += binary.length; } } const readImagesDuration = performance.now() - readImagesStart; // Read random documents const readDocsStart = performance.now(); const docIds = testData.documents.slice(0, Math.min(50, testData.documents.length)).map(function(d) { return d.id; }); let readDocBytes = 0; for (let i = 0; i < docIds.length; i++) { const result = await db.query('SELECT content FROM documents WHERE id = $1', [docIds[i]]); if (result.rows.length > 0 && result.rows[0].content) { readDocBytes += new TextEncoder().encode(result.rows[0].content).byteLength; } } const readDocsDuration = performance.now() - readDocsStart; // Storage estimate const estimate = await navigator.storage.estimate(); const storageBytes = estimate.usage || 0; // Cleanup await db.close(); return { success: true, init: createResult('init', initDuration), writes: { allUrls: createResult('allUrls', urlsDuration, testData.urls.length), allMetadata: createResult('allMetadata', metaDuration, testData.metadata.length), allImages: createResult('allImages', imagesDuration, testData.images.length, imageBytes), allDocuments: createResult('allDocuments', docsDuration, testData.documents.length, docBytes) }, reads: { recentUrls: createResult('recentUrls', readUrlsDuration, 100), randomImages: createResult('randomImages', readImagesDuration, imageIds.length, readImageBytes), randomDocuments: createResult('randomDocuments', readDocsDuration, docIds.length, readDocBytes) }, storage: { totalBytes: storageBytes } }; } catch (error) { return { success: false, error: error.message }; } } return { success: false, error: 'Unknown store' }; })('${store}') `; try { const result = await Promise.race([ page.evaluate(script), new Promise((_, reject) => setTimeout(() => reject(new Error('Benchmark timeout')), BENCHMARK_TIMEOUT) ) ]); return result as StoreRunResult; } catch (error) { const err = error as Error; return { success: false, error: err.message, init: { name: 'init', durationMs: 0, failed: true, error: err.message }, writes: { allUrls: { name: 'allUrls', durationMs: 0, failed: true }, allMetadata: { name: 'allMetadata', durationMs: 0, failed: true }, allImages: { name: 'allImages', durationMs: 0, failed: true }, allDocuments: { name: 'allDocuments', durationMs: 0, failed: true } }, reads: { recentUrls: { name: 'recentUrls', durationMs: 0, failed: true }, randomImages: { name: 'randomImages', durationMs: 0, failed: true }, randomDocuments: { name: 'randomDocuments', durationMs: 0, failed: true } }, storage: { totalBytes: 0 } }; } } async function runBrowserBenchmarks( browserType: BrowserType, serverUrl: string ): Promise { console.log(`\n${'─'.repeat(60)}`); console.log(`Testing ${browserType.toUpperCase()} (${ITERATIONS} iterations per store)`); console.log('─'.repeat(60)); let browser: Browser | undefined; const result: BrowserRunResults = { browserName: browserType, userAgent: '', stores: {} as Record, success: false }; try { if (browserType === 'chromium') { browser = await chromium.launch({ headless: true }); } else if (browserType === 'firefox') { browser = await firefox.launch({ headless: true }); } else if (browserType === 'webkit') { browser = await webkit.launch({ headless: true }); } const page = await browser!.newPage(); console.log(' Loading harness...'); await page.goto(serverUrl, { waitUntil: 'domcontentloaded' }); await page.waitForFunction(() => (window as any).generateTestData !== undefined, { timeout: 10000 }); result.userAgent = await page.evaluate(() => navigator.userAgent); for (const store of STORES) { console.log(`\n Benchmarking ${store.toUpperCase()}`); const runs: StoreRunResult[] = []; let consecutiveFailures = 0; for (let i = 0; i < ITERATIONS; i++) { process.stdout.write(` [${i + 1}/${ITERATIONS}] `); const runResult = await benchmarkStore(page, store); runs.push(runResult); if (runResult.success) { consecutiveFailures = 0; console.log(`✓ ${formatDuration(runResult.init.durationMs + runResult.writes.allUrls.durationMs + runResult.writes.allMetadata.durationMs)}`); } else { consecutiveFailures++; console.log(`✗ ${runResult.error}`); // If 3 consecutive failures (including timeouts), skip remaining iterations if (consecutiveFailures >= 3) { console.log(` Skipping remaining iterations due to repeated failures`); break; } } } result.stores[store] = aggregateStoreRuns(runs, store, result.userAgent); } await page.close(); result.success = true; } catch (error) { const err = error as Error; result.error = err.message; if (err.message.includes("Executable doesn't exist")) { console.error(` ✗ Browser not installed. Run: npm run browsers:install`); } else { console.error(` ✗ Error: ${err.message}`); } } finally { if (browser) { try { await browser.close(); } catch { // Ignore } } } return result; } function startServer(): Promise<{ url: string; server: Server }> { const harness = readFileSync(HARNESS_PATH, 'utf-8'); return new Promise((resolve) => { const server = createServer((req, res) => { if (req.url === '/') { res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(harness); } else { res.writeHead(404); res.end('Not found'); } }); server.listen(PORT, () => { resolve({ url: `http://localhost:${PORT}`, server }); }); }); } async function main() { console.log('='.repeat(60)); console.log('BROWSER DATASTORE BENCHMARK SUITE'); console.log('='.repeat(60)); console.log(`Iterations per store: ${ITERATIONS} (excluding high/low, using median)`); const { url: serverUrl, server } = await startServer(); console.log(`Server started at ${serverUrl}`); const allResults: BrowserRunResults[] = []; for (const browserType of BROWSERS) { const result = await runBrowserBenchmarks(browserType, serverUrl); allResults.push(result); if (result.success) { console.log(`\n ✓ ${browserType} completed`); } else { console.log(`\n ✗ ${browserType} failed: ${result.error}`); } } server.close(); // Save results const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const runDir = join('results', `browser-benchmark-${timestamp}`); mkdirSync(runDir, { recursive: true }); const resultsData = { timestamp: new Date().toISOString(), type: 'browser', iterations: ITERATIONS, browsers: allResults.map(r => ({ browserName: r.browserName, userAgent: r.userAgent, success: r.success, error: r.error, stores: r.stores })) }; writeFileSync(join(runDir, 'results.json'), JSON.stringify(resultsData, null, 2)); console.log(`\nResults saved to ${runDir}/results.json`); // Print summary console.log(`\n${'='.repeat(60)}`); console.log('BROWSER BENCHMARK SUMMARY'); console.log('='.repeat(60)); for (const browserResult of allResults) { if (!browserResult.success) continue; console.log(`\n${browserResult.browserName.toUpperCase()}`); for (const [storeName, storeResult] of Object.entries(browserResult.stores)) { if (storeResult.init.failed) { console.log(` ${storeName}: FAILED`); continue; } const totalWrite = storeResult.writes.allUrls.durationMs + storeResult.writes.allMetadata.durationMs + storeResult.writes.allImages.durationMs + storeResult.writes.allDocuments.durationMs; const totalRead = storeResult.reads.recentUrls.durationMs + storeResult.reads.randomImages.durationMs + storeResult.reads.randomDocuments.durationMs; console.log(` ${storeName}: init=${formatDuration(storeResult.init.durationMs)} write=${formatDuration(totalWrite)} read=${formatDuration(totalRead)}`); } } const successCount = allResults.filter(r => r.success).length; console.log(`\nBrowsers tested: ${successCount}/${BROWSERS.length} successful`); // Generate charts and HTML report if (successCount > 0) { console.log('\nGenerating charts...'); await generateBrowserCharts(allResults, runDir); console.log(`Charts and report saved to ${runDir}/`); } } const COLORS = ['#3498db', '#e74c3c', '#2ecc71', '#f39c12', '#9b59b6']; const WIDTH = 700; const HEIGHT = 400; async function renderVegaLiteChart(spec: any): Promise { const vegaSpec = vegaLite.compile(spec).spec; const view = new vega.View(vega.parse(vegaSpec), { renderer: 'none' }); const svg = await view.toSVG(); return await sharp(Buffer.from(svg)).png().toBuffer(); } async function generateBrowserCharts(results: BrowserRunResults[], outputDir: string): Promise { const successfulResults = results.filter(r => r.success); if (successfulResults.length === 0) return; // Prepare data for charts - one bar per browser/store combo const writeData: any[] = []; const readData: any[] = []; for (const browserResult of successfulResults) { for (const [storeName, storeResult] of Object.entries(browserResult.stores)) { if (storeResult.init.failed) continue; const label = `${browserResult.browserName}/${storeName}`; const totalWrite = storeResult.writes.allUrls.durationMs + storeResult.writes.allMetadata.durationMs + storeResult.writes.allImages.durationMs + storeResult.writes.allDocuments.durationMs; const totalRead = storeResult.reads.recentUrls.durationMs + storeResult.reads.randomImages.durationMs + storeResult.reads.randomDocuments.durationMs; writeData.push({ label, browser: browserResult.browserName, store: storeName, value: totalWrite }); readData.push({ label, browser: browserResult.browserName, store: storeName, value: totalRead }); } } // Write performance chart const writeSpec = { $schema: 'https://vega.github.io/schema/vega-lite/v5.json', width: WIDTH, height: HEIGHT, title: { text: 'Browser Write Performance', fontSize: 18 }, data: { values: writeData }, mark: 'bar', encoding: { x: { field: 'label', type: 'nominal', title: null, axis: { labelAngle: -45, labelFontSize: 10 }, sort: null }, y: { field: 'value', type: 'quantitative', title: 'Duration (ms)', axis: { titleFontSize: 12, labelFontSize: 11 } }, color: { field: 'store', type: 'nominal', title: 'Store', scale: { range: COLORS }, legend: { titleFontSize: 12, labelFontSize: 11 } } }, config: { background: 'white', view: { stroke: null } } }; const writeChart = await renderVegaLiteChart(writeSpec); writeFileSync(join(outputDir, 'write-comparison.png'), writeChart); // Read performance chart const readSpec = { $schema: 'https://vega.github.io/schema/vega-lite/v5.json', width: WIDTH, height: HEIGHT, title: { text: 'Browser Read Performance', fontSize: 18 }, data: { values: readData }, mark: 'bar', encoding: { x: { field: 'label', type: 'nominal', title: null, axis: { labelAngle: -45, labelFontSize: 10 }, sort: null }, y: { field: 'value', type: 'quantitative', title: 'Duration (ms)', axis: { titleFontSize: 12, labelFontSize: 11 } }, color: { field: 'store', type: 'nominal', title: 'Store', scale: { range: COLORS }, legend: { titleFontSize: 12, labelFontSize: 11 } } }, config: { background: 'white', view: { stroke: null } } }; const readChart = await renderVegaLiteChart(readSpec); writeFileSync(join(outputDir, 'read-comparison.png'), readChart); // Generate HTML report const html = generateBrowserHtmlReport(successfulResults); writeFileSync(join(outputDir, 'report.html'), html); } function generateBrowserHtmlReport(results: BrowserRunResults[]): string { const storeNames = [...new Set(results.flatMap(r => Object.keys(r.stores)))]; // Calculate totals for each browser/store combo type CellData = { write: number; read: number; failed: boolean }; const cellData: Map = new Map(); for (const browserResult of results) { for (const storeName of storeNames) { const key = `${browserResult.browserName}-${storeName}`; const storeResult = browserResult.stores[storeName as StoreType]; if (!storeResult || storeResult.init.failed) { cellData.set(key, { write: Infinity, read: Infinity, failed: true }); } else { const totalWrite = storeResult.writes.allUrls.durationMs + storeResult.writes.allMetadata.durationMs + storeResult.writes.allImages.durationMs + storeResult.writes.allDocuments.durationMs; const totalRead = storeResult.reads.recentUrls.durationMs + storeResult.reads.randomImages.durationMs + storeResult.reads.randomDocuments.durationMs; cellData.set(key, { write: totalWrite, read: totalRead, failed: false }); } } } // Find min/max for each store column (excluding failed) const storeStats: Map = new Map(); for (const storeName of storeNames) { const values = results .map(br => cellData.get(`${br.browserName}-${storeName}`)) .filter(v => v && !v.failed) as CellData[]; if (values.length > 0) { storeStats.set(storeName, { minWrite: Math.min(...values.map(v => v.write)), maxWrite: Math.max(...values.map(v => v.write)), minRead: Math.min(...values.map(v => v.read)), maxRead: Math.max(...values.map(v => v.read)) }); } } // Build table rows with color coding const tableRows = results.map(browserResult => { const cells = storeNames.map(storeName => { const key = `${browserResult.browserName}-${storeName}`; const data = cellData.get(key); const stats = storeStats.get(storeName); if (!data || data.failed) { return 'FAILED'; } // Determine colors (green = best/lowest, red = worst/highest) let writeClass = ''; let readClass = ''; if (stats) { if (data.write === stats.minWrite) writeClass = 'best'; else if (data.write === stats.maxWrite) writeClass = 'worst'; if (data.read === stats.minRead) readClass = 'best'; else if (data.read === stats.maxRead) readClass = 'worst'; } const writeSpan = writeClass ? `${formatDuration(data.write)}` : formatDuration(data.write); const readSpan = readClass ? `${formatDuration(data.read)}` : formatDuration(data.read); return `${writeSpan} / ${readSpan}`; }); return ` ${browserResult.browserName} ${cells.join('\n ')} `; }).join('\n'); return ` Browser Benchmark Results

Browser Datastore Benchmark Results

${new Date().toLocaleString()}

Environment: Browser-based benchmarks running in Chromium, Firefox, and WebKit

Methodology: 10 iterations per store, excluding high/low, reporting median

Test Data: 1000 URLs, 10000 metadata rows, 20 images (50KB each = 1MB), 500 documents

Values shown: Total Write Time / Total Read Time

Note: PouchDB fails on WebKit in Playwright due to a compatibility issue with PouchDB's IndexedDB adapter (raw IndexedDB works fine)

Summary

${storeNames.map(s => ``).join('\n ')} ${tableRows}
Browser${s}
Write Performance
Read Performance
`; } main().catch((err) => { console.error('Benchmark failed:', err); process.exit(1); });