From b10edd3de909cf8774ffe0e1e82754f68c365703 Mon Sep 17 00:00:00 2001 From: Vaivaswat <vaivaswat2244@gmail.com> Date: Fri, 21 Mar 2025 19:35:46 +0530 Subject: [PATCH 1/3] Adding Visual Test Report in Github Actions --- .github/workflows/ci-test.yml | 10 + .gitignore | 4 +- test/unit/visual/visual-report.js | 427 ++++++++++++++++++++++++++++++ test/unit/visual/visualTest.js | 3 + 4 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 test/unit/visual/visual-report.js diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index bd3ca55ba0..d38a09b64f 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -27,6 +27,16 @@ jobs: run: npm test env: CI: true + - name: Generate Visual Test Report + run: node test/unit/visual/generate-visual-report.js + env: + CI: true + - name: Upload Visual Test Report + uses: actions/upload-artifact@v3 + with: + name: visual-test-report + path: test/unit/visual/visual-report.html + retention-days: 14 - name: report test coverage run: bash <(curl -s https://codecov.io/bash) -f coverage/coverage-final.json env: diff --git a/.gitignore b/.gitignore index 6d6bb7e175..1504e8b5a4 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ yarn.lock docs/data.json analyzer/ preview/ -__screenshots__/ \ No newline at end of file +__screenshots__/ +actual-screenshots/ +visual-report.html \ No newline at end of file diff --git a/test/unit/visual/visual-report.js b/test/unit/visual/visual-report.js new file mode 100644 index 0000000000..1989c0b72a --- /dev/null +++ b/test/unit/visual/visual-report.js @@ -0,0 +1,427 @@ +const fs = require('fs'); +const path = require('path'); + +async function generateVisualReport() { + const expectedDir = path.join(process.cwd(), 'test/unit/visual/screenshots'); + const actualDir = path.join(process.cwd(), 'test/unit/visual/actual-screenshots'); + const outputFile = path.join(process.cwd(), 'test/unit/visual/visual-report.html'); + + // Make sure the output directory exists + const outputDir = path.dirname(outputFile); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Function to read image file and convert to data URL + function imageToDataURL(filePath) { + try { + const data = fs.readFileSync(filePath); + const base64 = data.toString('base64'); + return `data:image/png;base64,${base64}`; + } catch (error) { + console.error(`Failed to read image: ${filePath}`, error); + return null; + } + } + + // Create a lookup map for actual screenshots + function createActualScreenshotMap() { + const actualMap = new Map(); + if (!fs.existsSync(actualDir)) { + console.warn(`Actual screenshots directory does not exist: ${actualDir}`); + return actualMap; + } + + const files = fs.readdirSync(actualDir); + for (const file of files) { + if (file.endsWith('.png') && !file.endsWith('-diff.png')) { + actualMap.set(file, path.join(actualDir, file)); + } + } + + return actualMap; + } + + const actualScreenshotMap = createActualScreenshotMap(); + + // Recursively find all test cases + function findTestCases(dir, prefix = '') { + const testCases = []; + + if (!fs.existsSync(path.join(dir, prefix))) { + console.warn(`Directory does not exist: ${path.join(dir, prefix)}`); + return testCases; + } + + const entries = fs.readdirSync(path.join(dir, prefix), { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(prefix, entry.name); + + if (entry.isDirectory()) { + // Recursively search subdirectories + testCases.push(...findTestCases(dir, fullPath)); + } else if (entry.name === 'metadata.json') { + // Found a test case + const metadataPath = path.join(dir, fullPath); + let metadata; + + try { + metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + } catch (error) { + console.error(`Failed to read metadata: ${metadataPath}`, error); + continue; + } + + const testDir = path.dirname(fullPath); + + const test = { + name: testDir, + numScreenshots: metadata.numScreenshots || 0, + screenshots: [] + }; + + // Create flattened name for lookup + const flattenedName = testDir.replace(/\//g, '-'); + + // Collect all screenshots for this test + for (let i = 0; i < test.numScreenshots; i++) { + const screenshotName = i.toString().padStart(3, '0') + '.png'; + const expectedPath = path.join(dir, testDir, screenshotName); + + // Use flattened name for actual screenshots + const actualScreenshotName = `${flattenedName}-${i.toString().padStart(3, '0')}.png`; + const actualPath = actualScreenshotMap.get(actualScreenshotName) || null; + + // Use flattened name for diff image + const diffScreenshotName = `${flattenedName}-${i.toString().padStart(3, '0')}-diff.png`; + const diffPath = path.join(actualDir, diffScreenshotName); + + const hasExpected = fs.existsSync(expectedPath); + const hasActual = actualPath && fs.existsSync(actualPath); + const hasDiff = fs.existsSync(diffPath); + + const screenshot = { + index: i, + expectedImage: hasExpected ? imageToDataURL(expectedPath) : null, + actualImage: hasActual ? imageToDataURL(actualPath) : null, + diffImage: hasDiff ? imageToDataURL(diffPath) : null, + passed: hasExpected && hasActual && !hasDiff + }; + + test.screenshots.push(screenshot); + } + + // Don't add tests with no screenshots + if (test.screenshots.length > 0) { + testCases.push(test); + } + } + } + + return testCases; + } + + // Find all test cases from the expected directory + const testCases = findTestCases(expectedDir); + + if (testCases.length === 0) { + console.warn('No test cases found. Check if the expected directory is correct.'); + } + + // Count passed/failed tests and screenshots + const totalTests = testCases.length; + let passedTests = 0; + let totalScreenshots = 0; + let passedScreenshots = 0; + + for (const test of testCases) { + const testPassed = test.screenshots.every(screenshot => screenshot.passed); + if (testPassed) passedTests++; + + totalScreenshots += test.screenshots.length; + passedScreenshots += test.screenshots.filter(s => s.passed).length; + } + + // Generate HTML + const html = ` +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>p5.js Visual Test Results</title> + <style> + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + line-height: 1.6; + color: #333; + max-width: 1200px; + margin: 0 auto; + padding: 20px; + } + + header { + margin-bottom: 30px; + } + + .summary { + background-color: #f5f5f5; + padding: 15px; + border-radius: 5px; + margin-bottom: 30px; + } + + .summary h2 { + margin-top: 0; + } + + .test-group { + border: 1px solid #ddd; + border-radius: 5px; + margin-bottom: 30px; + overflow: hidden; + } + + .test-header { + background-color: #f5f5f5; + padding: 10px 15px; + border-bottom: 1px solid #ddd; + font-weight: bold; + display: flex; + justify-content: space-between; + align-items: center; + } + + .test-status { + display: inline-block; + padding: 3px 8px; + border-radius: 3px; + font-size: 14px; + font-weight: normal; + } + + .status-pass { + background-color: #dff0d8; + color: #3c763d; + } + + .status-fail { + background-color: #f2dede; + color: #a94442; + } + + .screenshots { + padding: 20px; + } + + .screenshot-set { + margin-bottom: 30px; + border-bottom: 1px solid #eee; + padding-bottom: 20px; + position: relative; + } + + .screenshot-set:last-child { + margin-bottom: 0; + border-bottom: none; + padding-bottom: 0; + } + + .screenshot-header { + margin-bottom: 15px; + font-weight: 500; + display: flex; + justify-content: space-between; + align-items: center; + } + + .screenshot-status { + display: inline-block; + padding: 3px 8px; + border-radius: 3px; + font-size: 14px; + } + + .screenshot-images { + display: flex; + flex-wrap: wrap; + gap: 20px; + } + + .image-container { + flex: 1; + min-width: 300px; + } + + .image-header { + margin-bottom: 5px; + font-weight: 500; + } + + img { + max-width: 100%; + border: 1px solid #ddd; + background-color: #f8f8f8; + } + + .toggle-btn { + background-color: #f8f9fa; + border: 1px solid #ddd; + padding: 5px 10px; + border-radius: 3px; + cursor: pointer; + margin-right: 5px; + } + + .toggle-btn.active { + background-color: #e9ecef; + font-weight: bold; + } + + .hidden { + display: none; + } + + .filters { + margin-bottom: 20px; + } + + .missing-notice { + padding: 10px; + background-color: #fff3cd; + color: #856404; + border-radius: 4px; + margin-top: 5px; + } + </style> +</head> +<body> + <header> + <h1>p5.js Visual Test Results</h1> + <div class="filters"> + <button id="show-all" class="toggle-btn active">Show All</button> + <button id="show-failed" class="toggle-btn">Show Only Failed</button> + <button id="show-passed" class="toggle-btn">Show Only Passed</button> + </div> + </header> + + <div class="summary"> + <h2>Summary</h2> + <p> + <strong>Total Tests:</strong> ${totalTests}<br> + <strong>Passed Tests:</strong> ${passedTests} (${totalTests > 0 ? Math.round(passedTests/totalTests*100) : 0}%)<br> + <strong>Failed Tests:</strong> ${totalTests - passedTests} (${totalTests > 0 ? Math.round((totalTests-passedTests)/totalTests*100) : 0}%)<br> + <strong>Total Screenshots:</strong> ${totalScreenshots}<br> + <strong>Passed Screenshots:</strong> ${passedScreenshots} (${totalScreenshots > 0 ? Math.round(passedScreenshots/totalScreenshots*100) : 0}%)<br> + <strong>Report Generated:</strong> ${new Date().toLocaleString()} + </p> + </div> + + <div id="test-results"> + ${testCases.map(test => { + const passed = test.screenshots.every(s => s.passed); + return ` + <div class="test-group ${passed ? 'test-passed' : 'test-failed'}"> + <div class="test-header"> + <span>${test.name}</span> + <span class="test-status ${passed ? 'status-pass' : 'status-fail'}">${passed ? 'PASS' : 'FAIL'}</span> + </div> + <div class="screenshots"> + ${test.screenshots.map(screenshot => ` + <div class="screenshot-set"> + <div class="screenshot-header"> + <span>Screenshot #${screenshot.index + 1}</span> + <span class="screenshot-status ${screenshot.passed ? 'status-pass' : 'status-fail'}"> + ${screenshot.passed ? 'PASS' : 'FAIL'} + </span> + </div> + <div class="screenshot-images"> + <div class="image-container"> + <div class="image-header">Expected</div> + ${screenshot.expectedImage ? + `<img src="${screenshot.expectedImage}" alt="Expected Result">` : + `<div class="missing-notice">No expected image found</div>`} + </div> + <div class="image-container"> + <div class="image-header">Actual</div> + ${screenshot.actualImage ? + `<img src="${screenshot.actualImage}" alt="Actual Result">` : + `<div class="missing-notice">No actual image found</div>`} + </div> + ${screenshot.diffImage ? ` + <div class="image-container"> + <div class="image-header">Diff</div> + <img src="${screenshot.diffImage}" alt="Difference"> + </div> + ` : ''} + </div> + </div> + `).join('')} + </div> + </div> + `; + }).join('')} + </div> + + <script> + // Filter functionality + const buttons = document.querySelectorAll('.toggle-btn'); + const testGroups = document.querySelectorAll('.test-group'); + + document.getElementById('show-all').addEventListener('click', function() { + testGroups.forEach(el => { + el.style.display = 'block'; + }); + setActiveButton(this); + }); + + document.getElementById('show-failed').addEventListener('click', function() { + testGroups.forEach(el => { + el.style.display = el.classList.contains('test-failed') ? 'block' : 'none'; + }); + setActiveButton(this); + }); + + document.getElementById('show-passed').addEventListener('click', function() { + testGroups.forEach(el => { + el.style.display = el.classList.contains('test-passed') ? 'block' : 'none'; + }); + setActiveButton(this); + }); + + function setActiveButton(activeButton) { + buttons.forEach(button => { + button.classList.remove('active'); + }); + activeButton.classList.add('active'); + } + </script> +</body> +</html> + `; + + // Write HTML to file + fs.writeFileSync(outputFile, html); + console.log(`Visual test report generated: ${outputFile}`); + + return { + totalTests, + passedTests, + failedTests: totalTests - passedTests, + totalScreenshots, + passedScreenshots, + failedScreenshots: totalScreenshots - passedScreenshots, + reportPath: outputFile + }; +} + +// Run the function if this script is executed directly +if (require.main === module) { + generateVisualReport().catch(error => { + console.error('Failed to generate report:', error); + process.exit(1); + }); +} + +module.exports = { generateVisualReport }; \ No newline at end of file diff --git a/test/unit/visual/visualTest.js b/test/unit/visual/visualTest.js index 120ce79565..236110f20c 100644 --- a/test/unit/visual/visualTest.js +++ b/test/unit/visual/visualTest.js @@ -441,6 +441,8 @@ export function visualTest( : []; for (let i = 0; i < actual.length; i++) { + const flatName = name.replace(/\//g, '-'); + const actualFilename = `../actual-screenshots/${flatName}-${i.toString().padStart(3, '0')}.png`; if (expected[i]) { const result = await checkMatch(actual[i], expected[i], myp5); if (!result.ok) { @@ -453,6 +455,7 @@ export function visualTest( } else { writeImageFile(expectedFilenames[i], toBase64(actual[i])); } + writeImageFile(actualFilename, toBase64(actual[i])); } }); }); From 8679a7aff4deb75bdeff12733db2c735fa7c213d Mon Sep 17 00:00:00 2001 From: Vaivaswat <vaivaswat2244@gmail.com> Date: Fri, 21 Mar 2025 20:01:51 +0530 Subject: [PATCH 2/3] Updated file name and upload-artifact version --- .github/workflows/ci-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index d38a09b64f..dee848d10a 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -28,11 +28,11 @@ jobs: env: CI: true - name: Generate Visual Test Report - run: node test/unit/visual/generate-visual-report.js + run: node test/unit/visual/visual-report.js env: CI: true - name: Upload Visual Test Report - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: visual-test-report path: test/unit/visual/visual-report.html From 4a674e3fad1f232a159f8f2bb45749e90c5218a4 Mon Sep 17 00:00:00 2001 From: Vaivaswat <vaivaswat2244@gmail.com> Date: Fri, 21 Mar 2025 20:30:43 +0530 Subject: [PATCH 3/3] moved the script from test folder to root --- .github/workflows/ci-test.yml | 2 +- test/unit/visual/visual-report.js => visual-report.js | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename test/unit/visual/visual-report.js => visual-report.js (100%) diff --git a/.github/workflows/ci-test.yml b/.github/workflows/ci-test.yml index dee848d10a..58c9740882 100644 --- a/.github/workflows/ci-test.yml +++ b/.github/workflows/ci-test.yml @@ -28,7 +28,7 @@ jobs: env: CI: true - name: Generate Visual Test Report - run: node test/unit/visual/visual-report.js + run: node visual-report.js env: CI: true - name: Upload Visual Test Report diff --git a/test/unit/visual/visual-report.js b/visual-report.js similarity index 100% rename from test/unit/visual/visual-report.js rename to visual-report.js