From 88358406f4c62588d07b6527a5b52e7b7a564081 Mon Sep 17 00:00:00 2001 From: DeNNiiInc Date: Sun, 28 Dec 2025 03:13:08 +1100 Subject: [PATCH] Phase C.1 - Multi-run backend implementation (database + execution logic) --- lib/multi-run.js | 268 +++++++++++++++++++++++++++ lib/runner.js | 3 +- migrations/001_multi_run_support.sql | 47 +++++ server.js | 49 ++++- 4 files changed, 361 insertions(+), 6 deletions(-) create mode 100644 lib/multi-run.js create mode 100644 migrations/001_multi_run_support.sql diff --git a/lib/multi-run.js b/lib/multi-run.js new file mode 100644 index 0000000..8b2c096 --- /dev/null +++ b/lib/multi-run.js @@ -0,0 +1,268 @@ +/** + * Multi-Run Test Execution Module + * Handles running multiple performance tests and calculating statistics + */ + +const runner = require('./runner'); +const { Pool } = require('pg'); +const dbConfig = require('./db-config'); + +const pool = new Pool(dbConfig); + +/** + * Statistical helper functions + */ +function median(values) { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 + ? (sorted[mid - 1] + sorted[mid]) / 2 + : sorted[mid]; +} + +function average(values) { + if (values.length === 0) return 0; + return values.reduce((sum, v) => sum + v, 0) / values.length; +} + +function stddev(values) { + if (values.length === 0) return 0; + const avg = average(values); + const squareDiffs = values.map(v => Math.pow(v - avg, 2)); + return Math.sqrt(average(squareDiffs)); +} + +function findClosestToMedian(values, medianValue) { + let closestIndex = 0; + let minDiff = Math.abs(values[0] - medianValue); + + values.forEach((v, i) => { + const diff = Math.abs(v - medianValue); + if (diff < minDiff) { + minDiff = diff; + closestIndex = i; + } + }); + + return closestIndex; +} + +/** + * Execute multiple test runs and calculate statistics + */ +async function executeMultipleRuns(suiteId, url, isMobile, runCount, userUuid, userIp) { + const results = []; + const testIds = []; + + console.log(`[Multi-Run] Starting ${runCount} runs for suite ${suiteId}`); + + for (let i = 1; i <= runCount; i++) { + try { + console.log(`[Multi-Run] Running test ${i}/${runCount}...`); + + // Generate unique test ID for this run + const testId = `${suiteId}-run${i}`; + testIds.push(testId); + + // Run individual test + const result = await runner.runTest(url, { + isMobile, + userUuid, + userIp, + suiteId, + runNumber: i, + testId + }); + + results.push(result); + + // Update progress + await updateSuiteProgress(suiteId, i); + + console.log(`[Multi-Run] Completed run ${i}/${runCount}`); + + } catch (error) { + console.error(`[Multi-Run] Run ${i} failed:`, error); + // Continue with other runs even if one fails + } + } + + if (results.length === 0) { + await markSuiteFailed(suiteId); + throw new Error('All test runs failed'); + } + + // Calculate statistics + console.log(`[Multi-Run] Calculating statistics for ${results.length} runs`); + await calculateStatistics(suiteId, results, testIds); + + // Mark suite as complete + await completeSuite(suiteId); + + console.log(`[Multi-Run] Suite ${suiteId} completed`); + + return { + suiteId, + completedRuns: results.length, + totalRuns: runCount + }; +} + +/** + * Calculate statistics for all runs and update suite + */ +async function calculateStatistics(suiteId, results, testIds) { + const metrics = { + performanceScore: [], + lcp: [], + cls: [], + tbt: [] + }; + + // Extract metrics from each result + results.forEach(r => { + const lr = r.lighthouseResult; + metrics.performanceScore.push(lr.categories.performance.score * 100); + metrics.lcp.push(lr.audits['largest-contentful-paint']?.numericValue || 0); + metrics.cls.push(lr.audits['cumulative-layout-shift']?.numericValue || 0); + metrics.tbt.push(lr.audits['total-blocking-time']?.numericValue || 0); + }); + + // Calculate statistics + const stats = { + median_performance_score: median(metrics.performanceScore), + avg_performance_score: average(metrics.performanceScore), + stddev_performance_score: stddev(metrics.performanceScore), + median_lcp: median(metrics.lcp), + avg_lcp: average(metrics.lcp), + stddev_lcp: stddev(metrics.lcp), + median_cls: median(metrics.cls), + avg_cls: average(metrics.cls), + stddev_cls: stddev(metrics.cls), + median_tbt: median(metrics.tbt), + avg_tbt: average(metrics.tbt), + stddev_tbt: stddev(metrics.tbt) + }; + + // Find median run (closest to median performance score) + const medianPerfScore = stats.median_performance_score; + const closestRunIndex = findClosestToMedian(metrics.performanceScore, medianPerfScore); + const medianTestId = testIds[closestRunIndex]; + + console.log(`[Multi-Run] Median run is #${closestRunIndex + 1} (${medianTestId})`); + + // Mark median run + await markMedianRun(medianTestId); + + // Update suite with statistics + await updateSuiteStats(suiteId, stats); +} + +/** + * Database operations + */ +async function createSuite(suiteId, userUuid, url, deviceType, runCount) { + const query = ` + INSERT INTO test_suites (suite_id, user_uuid, url, device_type, run_count, status) + VALUES ($1, $2, $3, $4, $5, 'running') + `; + await pool.query(query, [suiteId, userUuid, url, deviceType, runCount]); +} + +async function updateSuiteProgress(suiteId, completedRuns) { + const query = ` + UPDATE test_suites + SET completed_runs = $1 + WHERE suite_id = $2 + `; + await pool.query(query, [completedRuns, suiteId]); +} + +async function updateSuiteStats(suiteId, stats) { + const query = ` + UPDATE test_suites SET + median_performance_score = $1, + avg_performance_score = $2, + stddev_performance_score = $3, + median_lcp = $4, + avg_lcp = $5, + stddev_lcp = $6, + median_cls = $7, + avg_cls = $8, + stddev_cls = $9, + median_tbt = $10, + avg_tbt = $11, + stddev_tbt = $12 + WHERE suite_id = $13 + `; + await pool.query(query, [ + stats.median_performance_score, + stats.avg_performance_score, + stats.stddev_performance_score, + stats.median_lcp, + stats.avg_lcp, + stats.stddev_lcp, + stats.median_cls, + stats.avg_cls, + stats.stddev_cls, + stats.median_tbt, + stats.avg_tbt, + stats.stddev_tbt, + suiteId + ]); +} + +async function completeSuite(suiteId) { + const query = ` + UPDATE test_suites + SET status = 'completed', completed_at = NOW() + WHERE suite_id = $1 + `; + await pool.query(query, [suiteId]); +} + +async function markSuiteFailed(suiteId) { + const query = ` + UPDATE test_suites + SET status = 'failed' + WHERE suite_id = $1 + `; + await pool.query(query, [suiteId]); +} + +async function markMedianRun(testId) { + const query = ` + UPDATE test_results + SET is_median = TRUE + WHERE test_id = $1 + `; + await pool.query(query, [testId]); +} + +async function getSuiteStatus(suiteId) { + const query = ` + SELECT + s.*, + json_agg( + json_build_object( + 'test_id', r.test_id, + 'run_number', r.run_number, + 'is_median', r.is_median, + 'created_at', r.created_at + ) ORDER BY r.run_number + ) FILTER (WHERE r.test_id IS NOT NULL) as runs + FROM test_suites s + LEFT JOIN test_results r ON s.suite_id = r.suite_id + WHERE s.suite_id = $1 + GROUP BY s.id + `; + const result = await pool.query(query, [suiteId]); + return result.rows[0]; +} + +module.exports = { + executeMultipleRuns, + createSuite, + getSuiteStatus +}; diff --git a/lib/runner.js b/lib/runner.js index 1d58dea..2c6f7a2 100644 --- a/lib/runner.js +++ b/lib/runner.js @@ -270,5 +270,6 @@ const os = require('os'); module.exports = { runTest, - getHistory + getHistory, + generateTestId }; diff --git a/migrations/001_multi_run_support.sql b/migrations/001_multi_run_support.sql new file mode 100644 index 0000000..cd22262 --- /dev/null +++ b/migrations/001_multi_run_support.sql @@ -0,0 +1,47 @@ +-- Multi-Run Statistics Database Migration +-- Creates test_suites table and modifies test_results for multi-run support + +-- Create test_suites table to group multiple test runs +CREATE TABLE IF NOT EXISTS test_suites ( + id SERIAL PRIMARY KEY, + suite_id TEXT UNIQUE NOT NULL, + user_uuid TEXT NOT NULL, + url TEXT NOT NULL, + device_type TEXT, + run_count INTEGER DEFAULT 1, + completed_runs INTEGER DEFAULT 0, + status TEXT DEFAULT 'running', -- running, completed, failed + created_at TIMESTAMP DEFAULT NOW(), + completed_at TIMESTAMP, + + -- Statistical results (calculated after all runs complete) + median_performance_score NUMERIC, + avg_performance_score NUMERIC, + stddev_performance_score NUMERIC, + median_lcp NUMERIC, + avg_lcp NUMERIC, + stddev_lcp NUMERIC, + median_cls NUMERIC, + avg_cls NUMERIC, + stddev_cls NUMERIC, + median_tbt NUMERIC, + avg_tbt NUMERIC, + stddev_tbt NUMERIC +); + +-- Add columns to test_results for multi-run support +ALTER TABLE test_results +ADD COLUMN IF NOT EXISTS suite_id TEXT, +ADD COLUMN IF NOT EXISTS run_number INTEGER DEFAULT 1, +ADD COLUMN IF NOT EXISTS is_median BOOLEAN DEFAULT FALSE; + +-- Create index for faster lookups +CREATE INDEX IF NOT EXISTS idx_suite_id ON test_results(suite_id); +CREATE INDEX IF NOT EXISTS idx_test_suites_user ON test_suites(user_uuid); +CREATE INDEX IF NOT EXISTS idx_test_suites_status ON test_suites(status); + +-- Comment +COMMENT ON TABLE test_suites IS 'Groups multiple test runs for statistical analysis'; +COMMENT ON COLUMN test_results.suite_id IS 'Links individual run to parent test suite'; +COMMENT ON COLUMN test_results.run_number IS 'Run number within the suite (1-10)'; +COMMENT ON COLUMN test_results.is_median IS 'TRUE if this run represents the median performance'; diff --git a/server.js b/server.js index c4a67b1..ed8cab7 100644 --- a/server.js +++ b/server.js @@ -35,24 +35,63 @@ app.get("/api/git-info", (req, res) => { }); }); -// API Endpoint: Run Test +// API Endpoint: Run Test (supports multi-run) app.post("/api/run-test", async (req, res) => { - const { url, isMobile } = req.body; + const { url, isMobile, runs = 1 } = req.body; const userUuid = req.headers['x-user-uuid']; - // Use header for IP if behind proxy, fallback to socket address const userIp = req.headers['x-forwarded-for'] || req.socket.remoteAddress; if (!url) return res.status(400).json({ error: "URL is required" }); + + // Validate run count + if (runs < 1 || runs > 10) { + return res.status(400).json({ error: "Runs must be between 1 and 10" }); + } try { - const result = await runner.runTest(url, { isMobile, userUuid, userIp }); - res.json(result); + // Single run (original behavior) + if (runs === 1) { + const result = await runner.runTest(url, { isMobile, userUuid, userIp }); + return res.json(result); + } + + // Multi-run + const multiRun = require('./lib/multi-run'); + const suiteId = runner.generateTestId(); + + // Create suite record + await multiRun.createSuite(suiteId, userUuid, url, isMobile ? 'mobile' : 'desktop', runs); + + // Return suite ID immediately + res.json({ suiteId, runs, status: 'running' }); + + // Execute runs asynchronously + multiRun.executeMultipleRuns(suiteId, url, isMobile, runs, userUuid, userIp) + .catch(error => console.error('Multi-run execution failed:', error)); + } catch (error) { console.error("Test failed:", error); res.status(500).json({ error: "Test failed", details: error.message }); } }); +// API Endpoint: Suite Status (for multi-run progress tracking) +app.get("/api/suite-status/:suiteId", async (req, res) => { + try { + const multiRun = require('./lib/multi-run'); + const suite = await multiRun.getSuiteStatus(req.params.suiteId); + + if (!suite) { + return res.status(404).json({ error: "Suite not found" }); + } + + res.json(suite); + } catch (error) { + console.error("Suite status error:", error); + res.status(500).json({ error: "Failed to get suite status" }); + } +}); + // API Endpoint: History app.get("/api/history", async (req, res) => { const userUuid = req.headers['x-user-uuid'];