diff --git a/.changeset/coverage-api.md b/.changeset/coverage-api.md new file mode 100644 index 0000000..9866b5b --- /dev/null +++ b/.changeset/coverage-api.md @@ -0,0 +1,5 @@ +--- +'@smooai/testing': minor +--- + +SMOODEV-2721: coverage API — `reportCoverage()`, `listCoverage()` (incl. `latest=true` per-scope baseline), exported `parseLcov()` (LF/LH with DA-fallback), and the high-level `reportCoverageFromLcov(path, { scope, branch, commitSha })` that parses an lcov.info and uploads totals + per-file counters (per-file dropped above the API's 5000-file cap). diff --git a/src/lib/index.ts b/src/lib/index.ts index 305d84a..ebcb981 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -4,6 +4,9 @@ import { readFileSync } from 'fs'; import type { + CoverageFileCounters, + CoverageReport, + CreateCoverageReportInput, CtrfReport, CreateDeploymentInput, CreateTestCaseInput, @@ -11,6 +14,8 @@ import type { CreateTestRunInput, Credentials, Deployment, + LcovSummary, + ListCoverageReportsFilters, ListDeploymentsFilters, ListTestCasesFilters, ListTestRunsFilters, @@ -245,4 +250,117 @@ export class SmooTestingClient { return this.getRun(run.id); } + + // ── Coverage (SMOODEV-2721) ── + + async reportCoverage(input: CreateCoverageReportInput): Promise { + return this.request('POST', '/testing/coverage', input); + } + + /** + * List coverage reports. With `{ latest: true, branch }` returns the latest + * report per scope on that branch (a bare array — the diff baseline); + * otherwise a paginated envelope. + */ + async listCoverage(filters?: ListCoverageReportsFilters): Promise> { + const qs = this.buildQueryString(filters as Record); + return this.request>('GET', `/testing/coverage${qs}`); + } + + /** + * High-level: parse an LCOV file and upload its summary for a scope. + * Per-file detail is dropped above the API's 5000-file cap. + */ + async reportCoverageFromLcov( + lcovFilePath: string, + options: { + scope: string; + branch: string; + commitSha: string; + testRunId?: string; + includeFiles?: boolean; + }, + ): Promise { + const summary = parseLcov(readFileSync(lcovFilePath, 'utf-8')); + const includeFiles = options.includeFiles !== false && Object.keys(summary.files).length <= 5000; + return this.reportCoverage({ + scope: options.scope, + branch: options.branch, + commitSha: options.commitSha, + testRunId: options.testRunId, + linesCovered: summary.linesCovered, + linesTotal: summary.linesTotal, + ...(summary.branchesTotal > 0 ? { branchesCovered: summary.branchesCovered, branchesTotal: summary.branchesTotal } : {}), + ...(summary.functionsTotal > 0 ? { functionsCovered: summary.functionsCovered, functionsTotal: summary.functionsTotal } : {}), + ...(includeFiles ? { files: summary.files } : {}), + }); + } +} + +/** + * Parse LCOV text into totals + per-file counters. Prefers `LF:`/`LH:` and + * falls back to counting `DA:` lines when an emitter omits them. + */ +export function parseLcov(text: string): LcovSummary { + const summary: LcovSummary = { + linesCovered: 0, + linesTotal: 0, + branchesCovered: 0, + branchesTotal: 0, + functionsCovered: 0, + functionsTotal: 0, + files: {}, + }; + let path: string | null = null; + let counters = [0, 0, 0, 0, 0, 0] as CoverageFileCounters; + let daFound = 0; + let daHit = 0; + let sawLf = false; + + for (const rawLine of text.split('\n')) { + const line = rawLine.trim(); + if (line.startsWith('SF:')) { + path = line.slice(3).trim(); + counters = [0, 0, 0, 0, 0, 0]; + daFound = 0; + daHit = 0; + sawLf = false; + } else if (path == null) { + continue; + } else if (line.startsWith('LF:')) { + counters[1] = Number(line.slice(3)) || 0; + sawLf = true; + } else if (line.startsWith('LH:')) { + counters[0] = Number(line.slice(3)) || 0; + } else if (line.startsWith('BRF:')) { + counters[3] = Number(line.slice(4)) || 0; + } else if (line.startsWith('BRH:')) { + counters[2] = Number(line.slice(4)) || 0; + } else if (line.startsWith('FNF:')) { + counters[5] = Number(line.slice(4)) || 0; + } else if (line.startsWith('FNH:')) { + counters[4] = Number(line.slice(4)) || 0; + } else if (line.startsWith('DA:')) { + daFound += 1; + const hit = Number(line.slice(3).split(',')[1]); + if (hit > 0) daHit += 1; + } else if (line === 'end_of_record') { + if (!sawLf) { + counters[1] = daFound; + counters[0] = daHit; + } + summary.files[path] = counters; + summary.linesCovered += counters[0]; + summary.linesTotal += counters[1]; + summary.branchesCovered += counters[2]; + summary.branchesTotal += counters[3]; + summary.functionsCovered += counters[4]; + summary.functionsTotal += counters[5]; + path = null; + } + } + if (Object.keys(summary.files).length === 0) { + throw new Error('no SF:/end_of_record records found — is this an LCOV file?'); + } + return summary; } diff --git a/src/lib/parse-lcov.test.ts b/src/lib/parse-lcov.test.ts new file mode 100644 index 0000000..701562a --- /dev/null +++ b/src/lib/parse-lcov.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { parseLcov } from './index'; + +const LCOV = [ + 'TN:', + 'SF:src/a.ts', + 'FNF:4', + 'FNH:3', + 'DA:1,1', + 'DA:2,0', + 'LF:10', + 'LH:7', + 'BRF:6', + 'BRH:2', + 'end_of_record', + 'SF:src/b.ts', + 'DA:1,5', + 'DA:2,0', + 'DA:3,1', + 'end_of_record', + '', +].join('\n'); + +describe('parseLcov', () => { + it('sums LF/LH and derives from DA when absent', () => { + const s = parseLcov(LCOV); + // a.ts uses LF/LH (10/7) — DA lines must not override them. + expect(s.files['src/a.ts']).toEqual([7, 10, 2, 6, 3, 4]); + // b.ts has no LF/LH — derived from DA: 3 lines, 2 hit. + expect(s.files['src/b.ts']).toEqual([2, 3, 0, 0, 0, 0]); + expect(s.linesCovered).toBe(9); + expect(s.linesTotal).toBe(13); + expect(s.branchesTotal).toBe(6); + expect(s.functionsCovered).toBe(3); + }); + + it('rejects non-lcov input', () => { + expect(() => parseLcov('{"not":"lcov"}')).toThrow(/LCOV/); + expect(() => parseLcov('')).toThrow(/LCOV/); + }); +}); diff --git a/src/lib/types.ts b/src/lib/types.ts index da49193..3f07202 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -321,3 +321,66 @@ export interface SmooTestingClientOptions { apiUrl?: string; authUrl?: string; } + +// ── Coverage (SMOODEV-2721) ── + +/** Per-file coverage counters: [linesHit, linesFound, branchesHit, branchesFound, functionsHit, functionsFound]. */ +export type CoverageFileCounters = [number, number, number, number, number, number]; + +export interface CoverageReport { + id: string; + organizationId: string; + testRunId: string | null; + scope: string; + branch: string; + commitSha: string; + format: string; + linesCovered: number; + linesTotal: number; + branchesCovered: number | null; + branchesTotal: number | null; + functionsCovered: number | null; + functionsTotal: number | null; + /** Present on the POST echo; list reads return null (stripped server-side). */ + files: Record | null; + metadata: Record | null; + createdAt: string; +} + +export interface CreateCoverageReportInput { + scope: string; + branch: string; + commitSha: string; + linesCovered: number; + linesTotal: number; + branchesCovered?: number; + branchesTotal?: number; + functionsCovered?: number; + functionsTotal?: number; + testRunId?: string; + format?: string; + /** Max 5000 entries — the API rejects larger; drop per-file detail instead. */ + files?: Record; + metadata?: Record; +} + +export interface ListCoverageReportsFilters { + branch?: string; + scope?: string; + commitSha?: string; + /** With `branch`: return the latest report per scope (bare array, the baseline). */ + latest?: boolean; + limit?: number; + offset?: number; +} + +/** Parsed LCOV summary produced by `parseLcov`. */ +export interface LcovSummary { + linesCovered: number; + linesTotal: number; + branchesCovered: number; + branchesTotal: number; + functionsCovered: number; + functionsTotal: number; + files: Record; +}