From eda6df4a5dc6be8fe032a4ab0001545fc79ab6f4 Mon Sep 17 00:00:00 2001 From: pixelpaws Date: Wed, 24 Sep 2025 23:22:32 +0800 Subject: [PATCH] chore(ci): add frontend coverage workflow --- .github/workflows/frontend-tests.yml | 50 +++++++ docs/frontend-testing-roadmap.md | 4 +- package.json | 3 +- scripts/run_frontend_coverage.js | 198 +++++++++++++++++++++++++++ vitest.config.js | 4 +- 5 files changed, 255 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/frontend-tests.yml create mode 100755 scripts/run_frontend_coverage.js diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 00000000..362a8b67 --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,50 @@ +name: Frontend Tests + +on: + push: + branches: + - main + - master + paths: + - 'package.json' + - 'package-lock.json' + - 'vitest.config.js' + - 'tests/frontend/**' + - 'static/js/**' + - '.github/workflows/frontend-tests.yml' + pull_request: + paths: + - 'package.json' + - 'package-lock.json' + - 'vitest.config.js' + - 'tests/frontend/**' + - 'static/js/**' + - '.github/workflows/frontend-tests.yml' + +jobs: + vitest: + name: Run Vitest with coverage + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Use Node.js 20 + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run frontend tests with coverage + run: npm run test:coverage + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: frontend-coverage + path: coverage/frontend + if-no-files-found: warn diff --git a/docs/frontend-testing-roadmap.md b/docs/frontend-testing-roadmap.md index 952b5af8..6ef5b514 100644 --- a/docs/frontend-testing-roadmap.md +++ b/docs/frontend-testing-roadmap.md @@ -11,7 +11,7 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR | Phase 2 | Test AppCore orchestration | Simulate page bootstrapping, infinite scroll hooks, and manager registration using JSDOM DOM fixtures | ✅ Complete | AppCore initialization + page feature suites now validate manager wiring, infinite scroll hooks, and onboarding gating | | Phase 3 | Validate page-specific managers | Add focused suites for `loras`, `checkpoints`, `embeddings`, and `recipes` managers covering filtering, sorting, and bulk actions | ✅ Complete | LoRA/checkpoint suites expanded; embeddings + recipes managers now covered with initialization, filtering, and duplicate workflows | | Phase 4 | Interaction-level regression tests | Exercise template fragments, modals, and menus to ensure UI wiring remains intact | ✅ Complete | Vitest DOM suites cover NSFW selector, recipe modal editing, and global context menus | -| Phase 5 | Continuous integration & coverage | Integrate frontend tests into CI workflow and track coverage metrics | ⚪ Not Started | Align reporting directories with backend coverage for unified reporting | +| Phase 5 | Continuous integration & coverage | Integrate frontend tests into CI workflow and track coverage metrics | ✅ Complete | CI workflow runs Vitest and aggregates V8 coverage into `coverage/frontend` via a dedicated script | ## Next Steps Checklist @@ -21,7 +21,7 @@ This roadmap tracks the planned rollout of automated testing for the ComfyUI LoR - [x] Add AppCore page feature suite exercising context menu creation and infinite scroll registration via DOM fixtures. - [x] Extend AppCore orchestration tests to cover manager wiring, bulk menu setup, and onboarding gating scenarios. - [x] Add interaction regression suites for context menus and recipe modals to complete Phase 4. -- [ ] Evaluate integrating coverage reporting once test surface grows (> 20 specs). +- [x] Evaluate integrating coverage reporting once test surface grows (> 20 specs). - [x] Create shared fixtures for the loras and checkpoints pages once dedicated manager suites are added. - [x] Draft focused test matrix for loras/checkpoints manager filtering and sorting paths ahead of Phase 3. - [x] Implement LoRAs manager filtering/sorting specs for scenarios F-01–F-05 & F-09; queue remaining edge cases after duplicate/bulk flows stabilize. diff --git a/package.json b/package.json index 6f4a7229..1a5d64e8 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:coverage": "node scripts/run_frontend_coverage.js" }, "devDependencies": { "jsdom": "^24.0.0", diff --git a/scripts/run_frontend_coverage.js b/scripts/run_frontend_coverage.js new file mode 100755 index 00000000..efc69475 --- /dev/null +++ b/scripts/run_frontend_coverage.js @@ -0,0 +1,198 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { mkdirSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, '..'); +const coverageRoot = path.join(repoRoot, 'coverage'); +const v8OutputDir = path.join(coverageRoot, '.v8'); +const frontendCoverageDir = path.join(coverageRoot, 'frontend'); + +rmSync(v8OutputDir, { recursive: true, force: true }); +rmSync(frontendCoverageDir, { recursive: true, force: true }); +mkdirSync(v8OutputDir, { recursive: true }); +mkdirSync(frontendCoverageDir, { recursive: true }); + +const vitestBinName = process.platform === 'win32' ? 'vitest.cmd' : 'vitest'; +const vitestBin = path.join(repoRoot, 'node_modules', '.bin', vitestBinName); + +const env = { ...process.env, NODE_V8_COVERAGE: v8OutputDir }; +const result = spawnSync(vitestBin, ['run'], { stdio: 'inherit', env }); + +if (result.error) { + console.error('Failed to execute Vitest:', result.error.message); + process.exit(result.status ?? 1); +} + +if (result.status !== 0) { + process.exit(result.status ?? 1); +} + +const fileCoverage = collectCoverageFromV8(v8OutputDir, repoRoot); +writeCoverageOutputs(fileCoverage, frontendCoverageDir, repoRoot); +printSummary(fileCoverage); +rmSync(v8OutputDir, { recursive: true, force: true }); + +function collectCoverageFromV8(v8Dir, rootDir) { + const coverageMap = new Map(); + const files = readdirSync(v8Dir).filter((file) => file.endsWith('.json')); + + for (const file of files) { + const reportPath = path.join(v8Dir, file); + const report = JSON.parse(readFileSync(reportPath, 'utf8')); + if (!Array.isArray(report.result)) { + continue; + } + + for (const script of report.result) { + const filePath = normalizeFilePath(script.url, rootDir); + if (!filePath) { + continue; + } + + if (!filePath.startsWith('static/')) { + continue; + } + + if (!filePath.endsWith('.js')) { + continue; + } + + const absolutePath = path.join(rootDir, filePath); + let lineMap = coverageMap.get(filePath); + if (!lineMap) { + lineMap = new Map(); + coverageMap.set(filePath, lineMap); + } + + const source = readFileSync(absolutePath, 'utf8'); + const lineOffsets = calculateLineOffsets(source); + + for (const fn of script.functions ?? []) { + for (const range of fn.ranges ?? []) { + if (range.startOffset === range.endOffset) { + continue; + } + const count = typeof range.count === 'number' ? range.count : 0; + const startLine = findLineNumber(range.startOffset, lineOffsets); + const endLine = findLineNumber(Math.max(range.endOffset - 1, range.startOffset), lineOffsets); + for (let line = startLine; line <= endLine; line += 1) { + const current = lineMap.get(line); + if (current === undefined || count > current) { + lineMap.set(line, count); + } + } + } + } + } + } + + return coverageMap; +} + +function normalizeFilePath(url, rootDir) { + if (!url) { + return null; + } + try { + const parsed = new URL(url); + if (parsed.protocol !== 'file:') { + return null; + } + const absolute = fileURLToPath(parsed); + const relative = path.relative(rootDir, absolute); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + return null; + } + return relative.replace(/\\/g, '/'); + } catch { + if (url.startsWith(rootDir)) { + return url.slice(rootDir.length + 1).replace(/\\/g, '/'); + } + return null; + } +} + +function calculateLineOffsets(content) { + const offsets = [0]; + for (let index = 0; index < content.length; index += 1) { + if (content.charCodeAt(index) === 10) { + offsets.push(index + 1); + } + } + offsets.push(content.length); + return offsets; +} + +function findLineNumber(offset, lineOffsets) { + let low = 0; + let high = lineOffsets.length - 1; + while (low < high) { + const mid = Math.floor((low + high + 1) / 2); + if (lineOffsets[mid] <= offset) { + low = mid; + } else { + high = mid - 1; + } + } + return low + 1; +} + +function writeCoverageOutputs(coverageMap, outputDir, rootDir) { + const summary = { + total: { lines: { total: 0, covered: 0, pct: 100 } }, + files: {}, + }; + + let lcovContent = ''; + + for (const [relativePath, lineMap] of [...coverageMap.entries()].sort()) { + const lines = [...lineMap.entries()].sort((a, b) => a[0] - b[0]); + const total = lines.length; + const covered = lines.filter(([, count]) => count > 0).length; + const pct = total === 0 ? 100 : (covered / total) * 100; + + summary.files[relativePath] = { + lines: { + total, + covered, + pct, + }, + }; + + summary.total.lines.total += total; + summary.total.lines.covered += covered; + + const absolute = path.join(rootDir, relativePath); + lcovContent += 'TN:\n'; + lcovContent += `SF:${absolute.replace(/\\/g, '/')}\n`; + for (const [line, count] of lines) { + lcovContent += `DA:${line},${count}\n`; + } + lcovContent += `LF:${total}\n`; + lcovContent += `LH:${covered}\n`; + lcovContent += 'end_of_record\n'; + } + + summary.total.lines.pct = summary.total.lines.total === 0 + ? 100 + : (summary.total.lines.covered / summary.total.lines.total) * 100; + + writeFileSync(path.join(outputDir, 'coverage-summary.json'), JSON.stringify(summary, null, 2)); + writeFileSync(path.join(outputDir, 'lcov.info'), lcovContent, 'utf8'); +} + +function printSummary(coverageMap) { + let totalLines = 0; + let totalCovered = 0; + for (const lineMap of coverageMap.values()) { + const lines = lineMap.size; + const covered = [...lineMap.values()].filter((count) => count > 0).length; + totalLines += lines; + totalCovered += covered; + } + const pct = totalLines === 0 ? 100 : (totalCovered / totalLines) * 100; + console.log(`\nFrontend coverage: ${totalCovered}/${totalLines} lines (${pct.toFixed(2)}%)`); +} diff --git a/vitest.config.js b/vitest.config.js index 7f804af9..3d88d268 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -9,7 +9,9 @@ export default defineConfig({ 'tests/frontend/**/*.test.js' ], coverage: { - enabled: false, + enabled: process.env.VITEST_COVERAGE === 'true', + provider: 'v8', + reporter: ['text', 'lcov', 'json-summary'], reportsDirectory: 'coverage/frontend' } }