Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/coverage-api.md
Original file line number Diff line number Diff line change
@@ -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).
118 changes: 118 additions & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@

import { readFileSync } from 'fs';
import type {
CoverageFileCounters,
CoverageReport,
CreateCoverageReportInput,
CtrfReport,
CreateDeploymentInput,
CreateTestCaseInput,
CreateTestEnvironmentInput,
CreateTestRunInput,
Credentials,
Deployment,
LcovSummary,
ListCoverageReportsFilters,
ListDeploymentsFilters,
ListTestCasesFilters,
ListTestRunsFilters,
Expand Down Expand Up @@ -245,4 +250,117 @@ export class SmooTestingClient {

return this.getRun(run.id);
}

// ── Coverage (SMOODEV-2721) ──

async reportCoverage(input: CreateCoverageReportInput): Promise<CoverageReport> {
return this.request<CoverageReport>('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<CoverageReport[] | PaginatedResponse<CoverageReport>> {
const qs = this.buildQueryString(filters as Record<string, string | number | undefined>);
return this.request<CoverageReport[] | PaginatedResponse<CoverageReport>>('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<CoverageReport> {
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;
}
41 changes: 41 additions & 0 deletions src/lib/parse-lcov.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
63 changes: 63 additions & 0 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CoverageFileCounters> | null;
metadata: Record<string, unknown> | 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<string, CoverageFileCounters>;
metadata?: Record<string, unknown>;
}

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<string, CoverageFileCounters>;
}
Loading