From 3d549bf09fa52339ddc30a968524f2f0766da148 Mon Sep 17 00:00:00 2001 From: Sam Gammon Date: Mon, 12 Oct 2026 16:25:57 -0700 Subject: [PATCH] feat: run tests with coverage and show it in the editor Adds a Run with Coverage test profile. It runs elide test --coverage and publishes the guest LCOV and JVM JaCoCo XML reports the run writes under .dev to VS Code's coverage gutter and Test Coverage view, with per-line branch counts. JaCoCo execution data alone cannot be read, so it earns a hint to ask for the XML report in the manifest. Signed-off-by: Sam Gammon --- README.md | 25 ++ packages/core/src/coverage.ts | 240 ++++++++++++++++++ packages/core/src/index.ts | 1 + packages/core/test/coverage.test.ts | 145 +++++++++++ .../core/test/fixtures/coverage/jacoco.xml | 1 + .../core/test/fixtures/coverage/lcov.info | 16 ++ packages/vscode/README.md | 3 +- packages/vscode/src/testing.ts | 90 ++++++- packages/vscode/test/integration/index.ts | 31 ++- 9 files changed, 541 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/coverage.ts create mode 100644 packages/core/test/coverage.test.ts create mode 100644 packages/core/test/fixtures/coverage/jacoco.xml create mode 100644 packages/core/test/fixtures/coverage/lcov.info diff --git a/README.md b/README.md index ca6d4e3..840b876 100644 --- a/README.md +++ b/README.md @@ -167,6 +167,31 @@ The **Debug** profile runs the same command with a bare `--debugger` and attache agent announces itself. That flag takes no address on `elide test`: the agent always binds 5005, so one debug run at a time. +The **Run with Coverage** profile adds `--coverage` and, once the run exits, shows the reports it wrote in the +editor's coverage gutter and the Test Coverage view: guest (JavaScript/TypeScript) LCOV from +`.dev/reports/coverage/js/lcov.info`, and JVM coverage from the JaCoCo XML report under +`.dev/reports/coverage/jvm//` (`.dev/artifacts/coverage/jvm//` before Elide 1.6), with per-line branch +counts. Elide writes JVM coverage as JaCoCo execution data (`.exec`) by default, which cannot be read without the +compiled classes; ask for the XML report in `elide.pkl` to see it in the editor: + +```pkl +import "elide:Coverage.pkl" as Coverage + +coverage { + jvm { + reports { + Coverage.xmlReport() + } + } +} +``` + +Before Elide 1.6 the same setting is `testing { coverage { jvm { reports { Jvm.xmlReport() } } } }`, with +`import "elide:Jvm.pkl" as Jvm`. + +JaCoCo sources are resolved against the project's source roots by package and file name, so a Kotlin file whose +directory does not match its package is not shown. + ## Debugging Launch configuration type `elide`: diff --git a/packages/core/src/coverage.ts b/packages/core/src/coverage.ts new file mode 100644 index 0000000..137986c --- /dev/null +++ b/packages/core/src/coverage.ts @@ -0,0 +1,240 @@ +import { existsSync, realpathSync } from "node:fs"; +import { readFile, readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { OUTPUT_DIR } from "./elide.js"; + +/** One branch of a line: how often it was taken (`0` when never). */ +export interface BranchHits { + hits: number; +} + +/** A line with executable code; `line` is 1-based, as both report formats number lines. */ +export interface LineHits { + line: number; + hits: number; + branches: BranchHits[]; +} + +/** Coverage of one source file, keyed by absolute path. */ +export interface FileCoverageData { + path: string; + /** Sorted by line. */ + lines: LineHits[]; +} + +/** The coverage files a `--coverage` run left in a project's output directory. */ +export interface CoverageReports { + /** Guest (JavaScript/TypeScript) coverage, LCOV. */ + lcov: string[]; + /** JVM coverage as a JaCoCo XML report, written when the manifest asks for one (`Coverage.xmlReport()`). */ + jacocoXml: string[]; + /** JVM coverage as JaCoCo execution data, which is only readable together with the compiled classes. */ + jacocoExec: string[]; +} + +/** + * Directories under a project's `.dev` holding coverage: Elide 1.6 writes every engine's reports under + * `reports/coverage//`, while 1.5 wrote JVM coverage under `artifacts/coverage/jvm/`. + */ +const COVERAGE_DIRS = [path.join("reports", "coverage"), path.join("artifacts", "coverage")]; +const LCOV_NAME = "lcov.info"; +const JACOCO_DOCTYPE = "-//JACOCO//DTD Report"; + +/** + * Coverage files under `projectRoot`, ignoring any last modified before `modifiedSince` (epoch ms): they stay in the + * output directory until the next `--coverage` run overwrites them, so an older one belongs to an earlier run. + */ +export async function findCoverageReports(projectRoot: string, modifiedSince = 0): Promise { + const found: CoverageReports = { lcov: [], jacocoXml: [], jacocoExec: [] }; + for (const dir of COVERAGE_DIRS) { + for (const file of await filesUnder(path.join(projectRoot, OUTPUT_DIR, dir))) { + try { + if ((await stat(file)).mtimeMs < modifiedSince) continue; + } catch { + continue; + } + const name = path.basename(file); + if (name === LCOV_NAME) found.lcov.push(file); + else if (name.endsWith(".xml")) found.jacocoXml.push(file); + else if (name.endsWith(".exec")) found.jacocoExec.push(file); + } + } + return found; +} + +async function filesUnder(dir: string): Promise { + let entries; + try { + entries = await readdir(dir, { withFileTypes: true, recursive: true }); + } catch { + return []; + } + return entries + .filter((e) => e.isFile()) + .map((e) => path.join(e.parentPath, e.name)) + .sort(); +} + +/** + * Decode an LCOV report: `SF` opens a record (Elide 1.5 wrote paths such as `/p/src/./a.ts`, normalized here), `DA:,` counts a line, `BRDA:,,,` + * a branch (`-` when its block never ran), `end_of_record` closes it. Everything else is a summary or a function record + * and is skipped. + */ +export function parseLcov(text: string): FileCoverageData[] { + const files: FileCoverageData[] = []; + let current: Map | undefined; + let source = ""; + const lineOf = (n: number): LineHits => { + let entry = current!.get(n); + if (!entry) current!.set(n, (entry = { line: n, hits: 0, branches: [] })); + return entry; + }; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (line.startsWith("SF:")) { + source = path.normalize(line.slice(3)); + current = new Map(); + } else if (!current) { + continue; + } else if (line.startsWith("DA:")) { + const [n, hits] = line.slice(3).split(","); + const lineNo = Number(n); + if (lineNo >= 1) lineOf(lineNo).hits += Number(hits) || 0; + } else if (line.startsWith("BRDA:")) { + const [n, , , taken] = line.slice(5).split(","); + const lineNo = Number(n); + if (lineNo >= 1) lineOf(lineNo).branches.push({ hits: taken === "-" ? 0 : Number(taken) || 0 }); + } else if (line === "end_of_record") { + files.push({ path: source, lines: sortedLines(current) }); + current = undefined; + } + } + return files; +} + +/** + * Decode a JaCoCo XML report. Its `` elements carry per-line instruction and branch counters (`ci`/`mi`, + * `cb`/`mb`), but know a source only by package and file name, so `resolve` maps `/` to an + * absolute path; a source it cannot place is dropped, since a record no editor file matches shows nothing. + * + * JaCoCo records whether an instruction ran, not how often: a line with any covered instruction counts as one hit. + */ +export function parseJacocoXml(text: string, resolve: (relative: string) => string | undefined): FileCoverageData[] { + if (!text.includes(JACOCO_DOCTYPE) && !/]/.test(text)) return []; + const files: FileCoverageData[] = []; + const packages = /([\s\S]*?)<\/package>/g; + for (const [, packageName = "", body = ""] of text.matchAll(packages)) { + const sources = /([\s\S]*?)<\/sourcefile>/g; + for (const [, fileName = "", lines = ""] of body.matchAll(sources)) { + const pkg = xmlText(packageName); + const name = xmlText(fileName); + const absolute = resolve(pkg ? `${pkg}/${name}` : name); + if (!absolute) continue; + const entries: LineHits[] = []; + for (const [attrs = ""] of lines.matchAll(/]*\/>/g)) { + const nr = attr(attrs, "nr"); + if (nr < 1) continue; + const covered = attr(attrs, "cb"); + const missed = attr(attrs, "mb"); + const branches: BranchHits[] = []; + for (let i = 0; i < covered; i++) branches.push({ hits: 1 }); + for (let i = 0; i < missed; i++) branches.push({ hits: 0 }); + entries.push({ line: nr, hits: attr(attrs, "ci") > 0 ? 1 : 0, branches }); + } + files.push({ path: absolute, lines: entries.sort((a, b) => a.line - b.line) }); + } + } + return files; +} + +function attr(element: string, name: string): number { + const m = new RegExp(`\\s${name}="(\\d+)"`).exec(element); + return m ? Number(m[1]) : 0; +} + +function xmlText(s: string): string { + return s.replace(/&(lt|gt|quot|apos|amp);/g, (_, e: string) => ({ lt: "<", gt: ">", quot: '"', apos: "'", amp: "&" })[e] ?? ""); +} + +function sortedLines(lines: Map): LineHits[] { + return [...lines.values()].sort((a, b) => a.line - b.line); +} + +/** + * Union reports by path: hits of the same line add up, and so do the hits of the branch at the same position, which + * is how several runs over one source combine. Files keep the order they first appear in. + */ +export function mergeCoverage(reports: Iterable): FileCoverageData[] { + const byPath = new Map>(); + for (const file of reports) { + let lines = byPath.get(file.path); + if (!lines) byPath.set(file.path, (lines = new Map())); + for (const { line, hits, branches } of file.lines) { + const entry = lines.get(line); + if (!entry) { + lines.set(line, { line, hits, branches: branches.map((b) => ({ ...b })) }); + continue; + } + entry.hits += hits; + branches.forEach((b, i) => { + const existing = entry.branches[i]; + if (existing) existing.hits += b.hits; + else entry.branches.push({ ...b }); + }); + } + } + return [...byPath].map(([p, lines]) => ({ path: p, lines: sortedLines(lines) })); +} + +/** Absolute path of `/` under the first source root holding it. */ +export function sourceResolver(sourceRoots: readonly string[], exists: (p: string) => boolean = existsSync): (relative: string) => string | undefined { + return (relative) => sourceRoots.map((root) => path.join(root, relative)).find(exists); +} + +export interface LoadedCoverage { + files: FileCoverageData[]; + reports: CoverageReports; +} + +/** + * Read the coverage a run left under `projectRoot` since `modifiedSince`: guest LCOV as it is, JaCoCo XML resolved + * against `sourceRoots`. JaCoCo execution data is reported in `reports` but not decoded. + * + * The CLI records the real path of the project directory, so for a project opened through a symlink LCOV paths under + * the real root are restated under `projectRoot`, the path the editor knows its files by. + */ +export async function loadCoverage(projectRoot: string, sourceRoots: readonly string[], modifiedSince = 0): Promise { + const reports = await findCoverageReports(projectRoot, modifiedSince); + const files: FileCoverageData[] = []; + let realRoot = projectRoot; + try { + realRoot = realpathSync(projectRoot); + } catch { + // keep the path as given + } + for (const file of reports.lcov) { + const text = await readText(file); + if (text === undefined) continue; + for (const entry of parseLcov(text)) files.push({ ...entry, path: rebase(entry.path, realRoot, projectRoot) }); + } + const resolve = sourceResolver(sourceRoots); + for (const file of reports.jacocoXml) { + const text = await readText(file); + if (text !== undefined) files.push(...parseJacocoXml(text, resolve)); + } + return { files: mergeCoverage(files), reports }; +} + +function rebase(p: string, from: string, to: string): string { + if (from === to) return p; + if (p === from) return to; + return p.startsWith(from + path.sep) || p.startsWith(`${from}/`) ? to + p.slice(from.length) : p; +} + +async function readText(file: string): Promise { + try { + return await readFile(file, "utf8"); + } catch { + return undefined; + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6f0feec..d3fd613 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -26,6 +26,7 @@ export { } from "./elide.js"; export * from "./artifacts.js"; export * from "./buildTasks.js"; +export * from "./coverage.js"; export * from "./invocation.js"; export * from "./init.js"; export * from "./jvmTests.js"; diff --git a/packages/core/test/coverage.test.ts b/packages/core/test/coverage.test.ts new file mode 100644 index 0000000..107b71c --- /dev/null +++ b/packages/core/test/coverage.test.ts @@ -0,0 +1,145 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { findCoverageReports, loadCoverage, mergeCoverage, parseJacocoXml, parseLcov, sourceResolver } from "../src/index.js"; + +const fixture = (name: string) => path.join(import.meta.dir, "fixtures", "coverage", name); +/** `elide test --coverage` (Elide 1.6.0-rc.2) on a TypeScript project, with the project root replaced by `/project`. */ +const lcov = readFileSync(fixture("lcov.info"), "utf8"); +/** + * The JaCoCo XML report of `elide test --coverage` (Elide 1.6.0-rc.2) on `samples/ktjvm` with `classify` added and + * `coverage.jvm.reports { Coverage.xmlReport() }` in the manifest. + */ +const jacoco = readFileSync(fixture("jacoco.xml"), "utf8"); + +describe("parseLcov", () => { + test("reads the guest report Elide writes", () => { + expect(parseLcov(lcov)).toEqual([ + { path: "/project/src/math.ts", lines: [{ line: 1, hits: 1, branches: [] }] }, + { path: "/project/src/math.test.ts", lines: [{ line: 1, hits: 1, branches: [] }] }, + ]); + }); + + test("reads branches, and a branch whose block never ran as not taken", () => { + const report = ["SF:/p/src/./a.ts", "DA:1,3", "DA:2,0", "BRDA:1,0,0,2", "BRDA:1,0,1,-", "BRDA:2,1,0,0", "LF:2", "LH:1", "end_of_record"].join("\n"); + expect(parseLcov(report)).toEqual([ + { + path: path.normalize("/p/src/a.ts"), + lines: [ + { line: 1, hits: 3, branches: [{ hits: 2 }, { hits: 0 }] }, + { line: 2, hits: 0, branches: [{ hits: 0 }] }, + ], + }, + ]); + }); + + test("ignores an unterminated record and lines outside a record", () => { + expect(parseLcov("DA:1,1\nSF:/a.ts\nDA:1,1\n")).toEqual([]); + }); +}); + +describe("parseJacocoXml", () => { + const roots = ["/project/src/main", "/project/src/test"]; + const files = new Set(["/project/src/main/sample/Main.kt", "/project/src/test/sample/MainTest.kt"]); + const resolve = sourceResolver(roots, (p) => files.has(p)); + + test("maps instruction and branch counters to line hits and branches", () => { + const [main, tests] = parseJacocoXml(jacoco, resolve); + expect(main!.path).toBe("/project/src/main/sample/Main.kt"); + expect(main!.lines.map((l) => [l.line, l.hits, l.branches.map((b) => b.hits)])).toEqual([ + [4, 0, []], + [5, 0, []], + [6, 0, []], + [8, 1, []], + [11, 1, [1, 0]], + [12, 0, [0, 0]], + [13, 1, []], + ]); + expect(tests!.path).toBe("/project/src/test/sample/MainTest.kt"); + expect(tests!.lines.every((l) => l.hits === 1)).toBe(true); + }); + + test("drops sources no source root holds", () => { + expect(parseJacocoXml(jacoco, () => undefined)).toEqual([]); + }); + + test("anything but a JaCoCo report decodes to nothing", () => { + expect(parseJacocoXml("", resolve)).toEqual([]); + }); +}); + +describe("mergeCoverage", () => { + test("adds up the hits of a line and of the branch at the same position", () => { + const merged = mergeCoverage([ + { path: "/a.kt", lines: [{ line: 2, hits: 1, branches: [{ hits: 1 }, { hits: 0 }] }] }, + { path: "/b.kt", lines: [{ line: 1, hits: 0, branches: [] }] }, + { path: "/a.kt", lines: [{ line: 1, hits: 0, branches: [] }, { line: 2, hits: 1, branches: [{ hits: 0 }, { hits: 1 }, { hits: 0 }] }] }, + ]); + expect(merged).toEqual([ + { + path: "/a.kt", + lines: [ + { line: 1, hits: 0, branches: [] }, + { line: 2, hits: 2, branches: [{ hits: 1 }, { hits: 1 }, { hits: 0 }] }, + ], + }, + { path: "/b.kt", lines: [{ line: 1, hits: 0, branches: [] }] }, + ]); + }); +}); + +describe("loadCoverage", () => { + const dirs: string[] = []; + afterAll(() => { + for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); + }); + + /** A project with a Kotlin source, laid out the way `elide test --coverage` leaves its reports. */ + const project = (layout: "1.6" | "1.5") => { + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "elide-cov-"))); + dirs.push(root); + mkdirSync(path.join(root, "src", "main", "sample"), { recursive: true }); + mkdirSync(path.join(root, "src", "test", "sample"), { recursive: true }); + writeFileSync(path.join(root, "src", "main", "sample", "Main.kt"), ""); + writeFileSync(path.join(root, "src", "math.ts"), ""); + const jvm = path.join(root, ".dev", layout === "1.6" ? "reports" : "artifacts", "coverage", "jvm", "jvm-test"); + mkdirSync(jvm, { recursive: true }); + copyFileSync(fixture("jacoco.xml"), path.join(jvm, "jvm-test.xml")); + writeFileSync(path.join(jvm, "jvm-test.exec"), ""); + const js = path.join(root, ".dev", "reports", "coverage", "js"); + mkdirSync(js, { recursive: true }); + writeFileSync(path.join(js, "lcov.info"), lcov.replaceAll("/project", root)); + return root; + }; + const sourceRoots = (root: string) => [path.join(root, "src", "main"), path.join(root, "src", "test")]; + + for (const layout of ["1.6", "1.5"] as const) { + test(`finds guest LCOV and JVM reports in the Elide ${layout} layout`, async () => { + const root = project(layout); + const { files, reports } = await loadCoverage(root, sourceRoots(root)); + expect(reports.jacocoExec.map((f) => path.basename(f))).toEqual(["jvm-test.exec"]); + // MainTest.kt does not exist in this project, so only Main.kt resolves. + expect(files.map((f) => path.relative(root, f.path)).sort()).toEqual([path.join("src", "main", "sample", "Main.kt"), path.join("src", "math.test.ts"), path.join("src", "math.ts")]); + }); + } + + test("reports older than the run are not its coverage", async () => { + const root = project("1.6"); + const past = new Date(Date.now() - 60_000); + const js = path.join(root, ".dev", "reports", "coverage", "js", "lcov.info"); + utimesSync(js, past, past); + const found = await findCoverageReports(root, Date.now() - 30_000); + expect(found.lcov).toEqual([]); + expect(found.jacocoXml).toHaveLength(1); + }); + + test("restates guest paths under the symlinked root the editor opened", async () => { + const real = project("1.6"); + const link = path.join(realpathSync(mkdtempSync(path.join(tmpdir(), "elide-cov-link-"))), "project"); + dirs.push(path.dirname(link)); + symlinkSync(real, link, "dir"); + const { files } = await loadCoverage(link, sourceRoots(link)); + expect(files.map((f) => f.path).filter((p) => p.endsWith(".ts")).sort()).toEqual([path.join(link, "src", "math.test.ts"), path.join(link, "src", "math.ts")]); + }); +}); diff --git a/packages/core/test/fixtures/coverage/jacoco.xml b/packages/core/test/fixtures/coverage/jacoco.xml new file mode 100644 index 0000000..c4e76f6 --- /dev/null +++ b/packages/core/test/fixtures/coverage/jacoco.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/core/test/fixtures/coverage/lcov.info b/packages/core/test/fixtures/coverage/lcov.info new file mode 100644 index 0000000..b220ad9 --- /dev/null +++ b/packages/core/test/fixtures/coverage/lcov.info @@ -0,0 +1,16 @@ +TN: +SF:/project/src/math.ts +DA:1,1 +FNF:3 +FNH:2 +LF:1 +LH:1 +end_of_record +TN: +SF:/project/src/math.test.ts +DA:1,1 +FNF:2 +FNH:2 +LF:1 +LH:1 +end_of_record diff --git a/packages/vscode/README.md b/packages/vscode/README.md index 5ff710b..1dcb8a8 100644 --- a/packages/vscode/README.md +++ b/packages/vscode/README.md @@ -29,7 +29,8 @@ Elide's build, run, test, and debug commands are available from the editor. `workspace.json`; each editor uses its own. - **Tasks.** `elide build`, `elide test`, `elide install`, and one run task per manifest entrypoint. - **Test Explorer.** JUnit tests of the project's test source roots in the **Testing** view, run and debugged through - `elide test --reporter=tap` with live results, failure messages and stack-trace locations. + `elide test --reporter=tap` with live results, failure messages and stack-trace locations. **Run with Coverage** + shows the run's LCOV and JaCoCo XML reports in the editor's coverage gutter and Test Coverage view. - **Elide sidebar.** An activity-bar view with each project's entrypoints, tasks, build targets (`elide build --inspect`), source sets and dependencies; run, debug, build and sync straight from the tree. - **New project wizard.** `Elide: New Project…` lists the templates of the installed CLI (`elide init --templates`), diff --git a/packages/vscode/src/testing.ts b/packages/vscode/src/testing.ts index dc50643..7a55b93 100644 --- a/packages/vscode/src/testing.ts +++ b/packages/vscode/src/testing.ts @@ -7,11 +7,13 @@ import { elideInvocationArgs, isPathUnder, jvmTestNamePattern, + loadCoverage, mergeElideInvocationOptions, normalizePath, parseTapLabel, resolveElideDistribution, scanJvmTestSource, + type FileCoverageData, type ScannedTestClass, type TapDiagnostics, type TapEvent, @@ -33,12 +35,15 @@ const STDERR_TAIL_LINES = 20; type Outcome = "passed" | "failed" | "skipped" | "errored"; -/** Item ids grouped by outcome, filled while a run reports results. */ -export type TestRunSummary = Record; +/** + * Item ids grouped by outcome, filled while a run reports results; a coverage run adds the covered and total + * statements of every file it published. + */ +export type TestRunSummary = Record & { coverage?: Record }; /** What `activate` exposes for the extension-host integration test. */ export interface ElideTestApi { - runAll(): Promise; + runAll(options?: { coverage?: boolean }): Promise; } /** Item ids: `p:` project, `c::` class, `m::#` test method. */ @@ -65,7 +70,8 @@ interface RunGroup { * * Discovery is static: the test source roots of the project model are scanned for JUnit declarations, so the tree is * populated without compiling or running anything. A run shells out to `elide test --reporter=tap` and maps the TAP - * stream back onto those items; the Debug profile runs the same command under a JDWP agent and attaches. + * stream back onto those items; the Debug profile runs the same command under a JDWP agent and attaches, and the + * Coverage profile adds `--coverage` and publishes the reports the run leaves under `.dev`. */ export class ElideTestController implements vscode.Disposable { private readonly controller = vscode.tests.createTestController("elide", "Elide"); @@ -73,6 +79,9 @@ export class ElideTestController implements vscode.Disposable { private readonly rescans = new Map(); /** Discovery is serialized: a refresh triggered while one runs waits for it instead of racing its item updates. */ private queue: Promise = Promise.resolve(); + /** Statement details of each published file, read back when the editor asks for them. */ + private readonly coverageDetails = new WeakMap(); + private readonly coverageProfile: vscode.TestRunProfile; constructor( private readonly workspace: ElideWorkspace, @@ -84,6 +93,8 @@ export class ElideTestController implements vscode.Disposable { const handler = (request: vscode.TestRunRequest, token: vscode.CancellationToken) => void this.run(request, token); this.controller.createRunProfile("Run", vscode.TestRunProfileKind.Run, handler, true); this.controller.createRunProfile("Debug", vscode.TestRunProfileKind.Debug, handler, true); + this.coverageProfile = this.controller.createRunProfile("Run with Coverage", vscode.TestRunProfileKind.Coverage, handler, true); + this.coverageProfile.loadDetailedCoverage = async (_run, file) => this.coverageDetails.get(file) ?? []; const watcher = vscode.workspace.createFileSystemWatcher(SOURCE_GLOB); this.subscriptions.push( @@ -108,14 +119,14 @@ export class ElideTestController implements vscode.Disposable { } /** Run every discovered test of every project, reporting the outcome per item id (used by the integration test). */ - async runAll(): Promise { + async runAll(options: { coverage?: boolean } = {}): Promise { await this.refreshAll(); const include = [...this.controller.items].map(([, item]) => item); - const summary: TestRunSummary = { passed: [], failed: [], skipped: [], errored: [] }; + const summary: TestRunSummary = { passed: [], failed: [], skipped: [], errored: [], ...(options.coverage ? { coverage: {} } : {}) }; if (include.length === 0) return summary; const cancellation = new vscode.CancellationTokenSource(); try { - await this.run(new vscode.TestRunRequest(include), cancellation.token, summary); + await this.run(new vscode.TestRunRequest(include, undefined, options.coverage ? this.coverageProfile : undefined), cancellation.token, summary); } finally { cancellation.dispose(); } @@ -320,9 +331,10 @@ export class ElideTestController implements vscode.Disposable { } const pattern = group.wholeProject ? undefined : jvmTestNamePattern(group.targets); + const coverage = request.profile?.kind === vscode.TestRunProfileKind.Coverage; // `elide.flags` and `elide.test.options` apply here too, except for the reporter: this run parses TAP. const invocation = mergeElideInvocationOptions(configuredInvocation(settings, "test"), { - options: { ...(pattern ? { "test-name-pattern": pattern } : {}), reporter: "tap" }, + options: { ...(pattern ? { "test-name-pattern": pattern } : {}), reporter: "tap", ...(coverage ? { coverage: true } : {}) }, }); const argv = elideInvocationArgs("test", invocation); this.ui.log(`test: ${dist.bin} ${argv.join(" ")} (cwd ${project.root})`); @@ -396,6 +408,8 @@ export class ElideTestController implements vscode.Disposable { }; const debug = request.profile?.kind === vscode.TestRunProfileKind.Debug; + // Report mtimes can be truncated to the second; anything older than the run belongs to an earlier one. + const startedAt = Math.floor(Date.now() / 1000) * 1000; let failure: string | undefined; if (debug) { const attachType = await resolveAttachType(settings); @@ -444,6 +458,7 @@ export class ElideTestController implements vscode.Disposable { } parser.end(); + if (coverage && !token.isCancellationRequested) await this.publishCoverage(run, group, startedAt, summary); if (token.isCancellationRequested || failure === undefined) return; // A failing test already exits non-zero: only a run that reported nothing at all is an error of its own. if (sawResult) { @@ -454,6 +469,47 @@ export class ElideTestController implements vscode.Disposable { for (const leaf of leaves) this.settle(run, summary, "errored", leaf, message); } + /** + * Publish the coverage the run left in each project it covered: guest LCOV, and JVM coverage when the manifest asks + * for a JaCoCo XML report. JaCoCo execution data alone cannot be read without the JaCoCo analyzer, so its presence + * only earns a hint. + */ + private async publishCoverage(run: vscode.TestRun, group: RunGroup, startedAt: number, summary?: TestRunSummary): Promise { + let published = 0; + let reports = 0; + for (const item of group.coveredItems) { + const project = this.workspace.projectAt(item.id.slice(2)); + if (!project) continue; + let loaded; + try { + loaded = await loadCoverage(project.root, sourceDirs(project), startedAt); + } catch (e) { + this.ui.log(`coverage: failed to read the reports of ${project.root}: ${describe(e)}`); + continue; + } + reports += loaded.reports.lcov.length + loaded.reports.jacocoXml.length + loaded.reports.jacocoExec.length; + for (const file of loaded.files) { + const details = statementsOf(file); + const coverage = vscode.FileCoverage.fromDetails(vscode.Uri.file(file.path), details); + this.coverageDetails.set(coverage, details); + run.addCoverage(coverage); + if (summary?.coverage) summary.coverage[file.path] = { covered: coverage.statementCoverage.covered, total: coverage.statementCoverage.total }; + published++; + } + if (loaded.reports.jacocoExec.length > 0 && loaded.reports.jacocoXml.length === 0) { + run.appendOutput( + terminalText( + `JVM coverage of ${project.model?.name ?? project.root} was recorded as JaCoCo execution data only; add ` + + "`Coverage.xmlReport()` to `coverage.jvm.reports` in elide.pkl (`Jvm.xmlReport()` to `testing.coverage.jvm.reports` " + + "before Elide 1.6) to show it in the editor.", + ), + ); + } + } + this.ui.log(`coverage: ${published} file(s) published`); + if (reports === 0) run.appendOutput(terminalText("No coverage report was found under .dev/reports/coverage.")); + } + private settle(run: vscode.TestRun, summary: TestRunSummary | undefined, outcome: Outcome, item: vscode.TestItem, message?: vscode.TestMessage): void { switch (outcome) { case "passed": @@ -524,6 +580,24 @@ function testDirs(project: ElideProject): string[] { return [...dirs]; } +/** Absolute source and test source roots of a synced project, where JaCoCo's package-relative sources are looked up. */ +function sourceDirs(project: ElideProject): string[] { + const dirs = new Set(); + for (const module of project.model?.modules ?? []) { + for (const contentRoot of module.contentRoots) { + for (const sourceRoot of contentRoot.sourceRoots) if (sourceRoot.kind === "source" || sourceRoot.kind === "test") dirs.add(sourceRoot.path); + } + } + return [...dirs]; +} + +/** One statement per covered line, with its branches; the reports number lines from 1. */ +function statementsOf(file: FileCoverageData): vscode.StatementCoverage[] { + return file.lines.map( + (l) => new vscode.StatementCoverage(l.hits, new vscode.Position(l.line - 1, 0), l.branches.map((b) => new vscode.BranchCoverage(b.hits))), + ); +} + /** Text of a file, preferring the editor's buffer so unsaved declarations are discovered too. */ async function readSource(uri: vscode.Uri): Promise { const open = vscode.workspace.textDocuments.find((d) => d.uri.fsPath === uri.fsPath); diff --git a/packages/vscode/test/integration/index.ts b/packages/vscode/test/integration/index.ts index cb13f46..732c1be 100644 --- a/packages/vscode/test/integration/index.ts +++ b/packages/vscode/test/integration/index.ts @@ -3,11 +3,11 @@ * JetBrains Kotlin LSP import → hovers/diagnostics, then manifest-change resync, tasks, and JDWP debugging. */ import assert from "node:assert/strict"; -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync, appendFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { resolveElideDistribution } from "@elide/ide-core"; +import { compareElideVersions, parseElideVersion, resolveElideDistribution } from "@elide/ide-core"; import * as vscode from "vscode"; import type { ElideExtensionApi as ElideApi } from "../../src/extension.js"; import { errorsOf, hoverAt, log, waitFor } from "./helpers.js"; @@ -284,6 +284,33 @@ export async function run(): Promise { ); log("test explorer ok"); + // 4f'. Coverage: with a JaCoCo XML report requested, a run with coverage publishes the JVM coverage of the main + // source, where `main` is never called by the test and `greeting` is. Elide 1.6 moved the setting from + // `testing.coverage` to a top-level `coverage` block. + const manifestSource = readFileSync(manifest, "utf8"); + const elideVersion = parseElideVersion(execFileSync(resolveElideDistribution({}).bin, ["--version"], { encoding: "utf8" })); + const legacyCoverage = elideVersion !== undefined && compareElideVersions(elideVersion, { major: 1, minor: 6, patch: 0 }) < 0; + appendFileSync( + manifest, + legacyCoverage + ? `\ntesting {\n coverage {\n jvm {\n reports {\n import("elide:Jvm.pkl").xmlReport()\n }\n }\n }\n}\n` + : `\ncoverage {\n jvm {\n reports {\n import("elide:Coverage.pkl").xmlReport()\n }\n }\n}\n`, + ); + const covered = await waitFor( + "coverage of Main.kt published", + async () => { + const summary = await api.runAll({ coverage: true }); + return summary.coverage?.[mainKt.fsPath] ? summary : undefined; + }, + 180_000, + 2_000, + ); + writeFileSync(manifest, manifestSource); + log("runAll with coverage:", JSON.stringify(covered.coverage)); + const mainCoverage = covered.coverage![mainKt.fsPath]!; + assert.ok(mainCoverage.covered > 0 && mainCoverage.covered < mainCoverage.total, `partial coverage of Main.kt, got ${JSON.stringify(mainCoverage)}`); + log("coverage ok"); + // 4g. Sidebar: the activity-bar view focuses, the tree exposes the project with its four groups, and the lazily // loaded build targets come from `elide build --inspect`. await vscode.commands.executeCommand("elide.projects.focus");