diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index cc5835e081..8c2d45d605 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -150,12 +150,20 @@ jobs: restore-keys: | ${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}- ${{ runner.os }}-turbo- - - name: Run non-core coverage - run: pnpm turbo run test:coverage --filter="!@roo-code/core" --log-order grouped --output-logs new-only + - name: Run non-extension package coverage + run: pnpm turbo run test:coverage --filter="!@roo-code/core" --filter="!zoo-code" --log-order grouped --output-logs new-only + - name: Run extension unit coverage + run: pnpm turbo run test:coverage:unit --filter="zoo-code" --log-order grouped --output-logs new-only + - name: Verify extension coverage contract + run: pnpm --dir src run verify:coverage-contract + - name: Run extension dist smoke test + run: pnpm turbo run test:dist --filter="zoo-code" --log-order grouped --output-logs new-only - name: Run core unit coverage run: pnpm turbo run test:coverage:unit --filter="@roo-code/core" --log-order grouped --output-logs new-only - name: Run core integration coverage run: pnpm turbo run test:coverage:integration --filter="@roo-code/core" --log-order grouped --output-logs new-only + - name: Verify extension unit coverage report + run: node src/scripts/verify-lcov.mjs src/coverage/unit/lcov.info - name: Save Turbo cache if: steps.turbo-cache.outputs.cache-hit != 'true' uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 @@ -177,7 +185,7 @@ jobs: uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: files: >- - src/coverage/lcov.info, + src/coverage/unit/lcov.info, packages/cloud/coverage/lcov.info, packages/telemetry/coverage/lcov.info, apps/cli/coverage/lcov.info @@ -214,7 +222,7 @@ jobs: with: name: coverage-reports-${{ matrix.name }} path: | - src/coverage/lcov.info + src/coverage/unit/lcov.info webview-ui/coverage/lcov.info packages/cloud/coverage/lcov.info packages/telemetry/coverage/lcov.info diff --git a/.gitignore b/.gitignore index 3961778d5e..e0ce0c8507 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ mock/ # Builds bin/ *.vsix +/src/generated/ # Local prompts and rules /local-prompts diff --git a/src/package.json b/src/package.json index 7467b50b7b..edf97d6f47 100644 --- a/src/package.json +++ b/src/package.json @@ -442,6 +442,8 @@ "lint": "eslint . --ext=ts --max-warnings=0", "check-types": "tsc --noEmit", "test": "vitest run", + "prepare:tree-sitter-wasms": "node scripts/copy-tree-sitter-wasms.mjs", + "verify:coverage-contract": "node scripts/verify-coverage-contract.mjs", "test:unit": "vitest run --config vitest.unit.config.ts", "test:dist": "vitest run --config vitest.dist.config.ts", "test:coverage": "vitest run --coverage", diff --git a/src/scripts/copy-tree-sitter-wasms.mjs b/src/scripts/copy-tree-sitter-wasms.mjs new file mode 100644 index 0000000000..84448e6f4b --- /dev/null +++ b/src/scripts/copy-tree-sitter-wasms.mjs @@ -0,0 +1,30 @@ +import fs from "node:fs" +import path from "node:path" +import process from "node:process" +import { fileURLToPath } from "node:url" + +const srcDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..") +const wasmDir = path.join(srcDir, "node_modules", "tree-sitter-wasms", "out") +const generatedDir = path.join(srcDir, "generated", "tree-sitter-wasms") +const wasmPattern = /^tree-sitter-.*\.wasm$/ + +export async function prepareTreeSitterWasms(sourceDir, destinationDir, { filesystem = fs.promises } = {}) { + const sourceFiles = (await filesystem.readdir(sourceDir)).filter((filename) => wasmPattern.test(filename)).sort() + if (sourceFiles.length === 0) throw new Error("WASM source set is empty") + + await filesystem.rm(destinationDir, { recursive: true, force: true }) + await filesystem.mkdir(destinationDir, { recursive: true }) + try { + for (const filename of sourceFiles) { + await filesystem.copyFile(path.join(sourceDir, filename), path.join(destinationDir, filename)) + } + } catch (error) { + await filesystem.rm(destinationDir, { recursive: true, force: true }) + throw error + } + return { sourceFiles } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + await prepareTreeSitterWasms(wasmDir, generatedDir) +} diff --git a/src/scripts/copy-tree-sitter-wasms.spec.mjs b/src/scripts/copy-tree-sitter-wasms.spec.mjs new file mode 100644 index 0000000000..663ea023e8 --- /dev/null +++ b/src/scripts/copy-tree-sitter-wasms.spec.mjs @@ -0,0 +1,57 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { prepareTreeSitterWasms } from "./copy-tree-sitter-wasms.mjs" + +describe("prepareTreeSitterWasms", () => { + let root + let source + let destination + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "tree-sitter-wasms-")) + source = path.join(root, "source") + destination = path.join(root, "generated", "tree-sitter-wasms") + fs.mkdirSync(source) + fs.writeFileSync(path.join(source, "tree-sitter-a.wasm"), "a") + }) + + afterEach(() => fs.rmSync(root, { recursive: true, force: true })) + + it("replaces pre-existing generated output without filesystem rename", async () => { + fs.writeFileSync(path.join(source, "ignored.txt"), "ignored") + fs.mkdirSync(destination, { recursive: true }) + fs.writeFileSync(path.join(destination, "tree-sitter-stale.wasm"), "stale") + const filesystem = { + ...fs.promises, + rename: async () => { + throw new Error("rename must not be used") + }, + } + + await prepareTreeSitterWasms(source, destination, { filesystem }) + + expect(fs.readdirSync(destination)).toEqual(["tree-sitter-a.wasm"]) + expect(fs.readFileSync(path.join(destination, "tree-sitter-a.wasm"), "utf8")).toBe("a") + }) + + it("removes partial task output after a copy failure and rebuilds cleanly", async () => { + fs.writeFileSync(path.join(source, "tree-sitter-b.wasm"), "b") + let copies = 0 + const filesystem = { + ...fs.promises, + copyFile: async (...args) => { + if (++copies === 2) throw new Error("copy failed") + return fs.promises.copyFile(...args) + }, + } + + await expect(prepareTreeSitterWasms(source, destination, { filesystem })).rejects.toThrow("copy failed") + expect(fs.existsSync(destination)).toBe(false) + + await prepareTreeSitterWasms(source, destination) + expect(fs.readdirSync(destination)).toEqual(["tree-sitter-a.wasm", "tree-sitter-b.wasm"]) + }) +}) diff --git a/src/scripts/verify-coverage-contract.mjs b/src/scripts/verify-coverage-contract.mjs new file mode 100644 index 0000000000..b750e6a0fb --- /dev/null +++ b/src/scripts/verify-coverage-contract.mjs @@ -0,0 +1,98 @@ +import { spawnSync } from "node:child_process" +import fs from "node:fs" +import path from "node:path" +import process from "node:process" +import { fileURLToPath } from "node:url" + +import { assertMatchingFiles } from "./verify-wasm-files.mjs" + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..") +const pnpm = process.platform === "win32" ? process.env.npm_execpath : "pnpm" +if (!pnpm) throw new Error("pnpm executable path is unavailable") +const pnpmPrefix = process.platform === "win32" ? [pnpm] : [] +const run = (args, options = {}) => { + const { includeStderr = true, ...spawnOptions } = options + const command = process.platform === "win32" ? process.execPath : pnpm + const result = spawnSync(command, [...pnpmPrefix, ...args], { cwd: root, encoding: "utf8", ...spawnOptions }) + if (result.status !== 0) { + const details = [ + result.error?.message, + result.signal ? `terminated by ${result.signal}` : undefined, + result.stderr, + result.stdout, + ] + .filter(Boolean) + .join("\n") + throw new Error(details || `pnpm exited with status ${result.status ?? "unknown"}`) + } + return `${result.stdout || ""}${includeStderr ? result.stderr || "" : ""}` +} + +const graph = JSON.parse( + run(["turbo", "run", "test:coverage:unit", "--filter=zoo-code", "--dry=json"], { includeStderr: false }), +) +const coverageTask = graph.tasks.find(({ taskId }) => taskId === "zoo-code#test:coverage:unit") +const preparationTask = graph.tasks.find(({ taskId }) => taskId === "zoo-code#prepare:tree-sitter-wasms") +if (!coverageTask?.dependencies.includes("zoo-code#prepare:tree-sitter-wasms")) + throw new Error("WASM prerequisite missing") +if (coverageTask.dependencies.includes("zoo-code#bundle")) throw new Error("Unit coverage must not depend on bundle") +if (JSON.stringify(preparationTask?.outputs) !== JSON.stringify(["generated/tree-sitter-wasms/**"])) + throw new Error("WASM prerequisite outputs changed") + +const generated = path.join(root, "src", "generated", "tree-sitter-wasms") +const cacheDir = path.join(root, ".turbo", "coverage-contract") +fs.rmSync(cacheDir, { recursive: true, force: true }) + +try { + run(["turbo", "run", "prepare:tree-sitter-wasms", "--filter=zoo-code", "--cache-dir=.turbo/coverage-contract"]) + run(["--dir", "src", "exec", "vitest", "run", "services/tree-sitter/__tests__"], { stdio: "inherit" }) + + const source = fs + .readdirSync(path.join(root, "src", "node_modules", "tree-sitter-wasms", "out")) + .filter((filename) => /^tree-sitter-.*\.wasm$/.test(filename)) + .sort() + const prepared = fs + .readdirSync(generated) + .filter((filename) => /^tree-sitter-.*\.wasm$/.test(filename)) + .sort() + if (source.length === 0) throw new Error("Dependency contains no tree-sitter WASMs") + if (JSON.stringify(source) !== JSON.stringify(prepared)) + throw new Error("Prepared WASM set does not match dependency") + assertMatchingFiles( + path.join(root, "src", "node_modules", "tree-sitter-wasms", "out"), + generated, + source, + "Prepared WASM content does not match dependency", + ) + + fs.rmSync(generated, { recursive: true, force: true }) + const warmGraph = JSON.parse( + run( + [ + "turbo", + "run", + "prepare:tree-sitter-wasms", + "--filter=zoo-code", + "--cache-dir=.turbo/coverage-contract", + "--dry=json", + ], + { includeStderr: false }, + ), + ) + const warmTask = warmGraph.tasks.find(({ taskId }) => taskId === "zoo-code#prepare:tree-sitter-wasms") + if (warmTask?.cache.status !== "HIT") throw new Error("WASM prerequisite is not available in the isolated cache") + run(["turbo", "run", "prepare:tree-sitter-wasms", "--filter=zoo-code", "--cache-dir=.turbo/coverage-contract"]) + const restored = fs + .readdirSync(generated) + .filter((filename) => /^tree-sitter-.*\.wasm$/.test(filename)) + .sort() + if (JSON.stringify(source) !== JSON.stringify(restored)) throw new Error("WASM cache did not restore exact outputs") + assertMatchingFiles( + path.join(root, "src", "node_modules", "tree-sitter-wasms", "out"), + generated, + source, + "WASM cache restored corrupted output", + ) +} finally { + fs.rmSync(cacheDir, { recursive: true, force: true }) +} diff --git a/src/scripts/verify-lcov.mjs b/src/scripts/verify-lcov.mjs new file mode 100644 index 0000000000..c4220dbf00 --- /dev/null +++ b/src/scripts/verify-lcov.mjs @@ -0,0 +1,46 @@ +import fs from "node:fs" +import process from "node:process" +import { fileURLToPath } from "node:url" + +export function verifyLcov(content) { + let inRecord = false + let anyCovered = false + let linesFound + let linesHit + + for (const line of content.split(/\r?\n/)) { + if (line.startsWith("SF:")) { + if (inRecord) throw new Error("LCOV source record is not terminated") + if (!line.slice(3)) throw new Error("LCOV source path is empty") + inRecord = true + linesFound = undefined + linesHit = undefined + } else if (line.startsWith("LF:")) { + if (!inRecord) throw new Error("LCOV line count is outside a source record") + if (linesFound !== undefined) throw new Error("LCOV source record has duplicate line counts") + const found = line.slice(3) + if (!/^\d+$/.test(found)) throw new Error("LCOV line count is not a decimal integer") + linesFound = BigInt(found) + } else if (line.startsWith("LH:")) { + if (!inRecord) throw new Error("LCOV hit count is outside a source record") + if (linesHit !== undefined) throw new Error("LCOV source record has duplicate hit counts") + const hits = line.slice(3) + if (!/^\d+$/.test(hits)) throw new Error("LCOV hit count is not a decimal integer") + linesHit = BigInt(hits) + } else if (line === "end_of_record") { + if (!inRecord) throw new Error("LCOV terminator is outside a source record") + if (linesFound === undefined || linesHit === undefined) + throw new Error("LCOV source record has incomplete line summaries") + if (linesHit > linesFound) throw new Error("LCOV hit count exceeds lines found") + if (linesHit > 0n) anyCovered = true + inRecord = false + } + } + + if (inRecord) throw new Error("LCOV source record is not terminated") + if (!anyCovered) throw new Error("LCOV report has no covered lines") +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + verifyLcov(fs.readFileSync(process.argv[2], "utf8")) +} diff --git a/src/scripts/verify-lcov.spec.mjs b/src/scripts/verify-lcov.spec.mjs new file mode 100644 index 0000000000..849c722979 --- /dev/null +++ b/src/scripts/verify-lcov.spec.mjs @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest" + +import { verifyLcov } from "./verify-lcov.mjs" + +describe("verifyLcov", () => { + it("accepts complete records with covered lines", () => { + expect(() => verifyLcov("SF:file.ts\nLF:1\nLH:1\nend_of_record\n")).not.toThrow() + }) + + it.each([ + ["an empty source path", "SF:\nLF:1\nLH:1\nend_of_record\n"], + ["an unterminated record", "SF:file.ts\nLH:1\n"], + ["a zero-hit report", "SF:file.ts\nLF:1\nLH:0\nend_of_record\n"], + ["a line count outside a record", "LF:1\n"], + ["a hit count outside a record", "LH:1\n"], + ["a terminator outside a record", "end_of_record\n"], + ["consecutive source records", "SF:first.ts\nSF:second.ts\nLF:1\nLH:1\nend_of_record\n"], + ["a record without lines found", "SF:file.ts\nLH:1\nend_of_record\n"], + ["an infinite line count", "SF:file.ts\nLF:Infinity\nLH:1\nend_of_record\n"], + ["a fractional line count", "SF:file.ts\nLF:1.5\nLH:1\nend_of_record\n"], + ["an exponential line count", "SF:file.ts\nLF:1e3\nLH:1\nend_of_record\n"], + ["an infinite hit count", "SF:file.ts\nLH:Infinity\nend_of_record\n"], + ["a fractional hit count", "SF:file.ts\nLH:1.5\nend_of_record\n"], + ["an exponential hit count", "SF:file.ts\nLH:1e3\nend_of_record\n"], + ["duplicate line counts", "SF:file.ts\nLF:1\nLF:0\nLH:0\nend_of_record\n"], + ["duplicate hit counts", "SF:file.ts\nLF:1\nLH:1\nLH:0\nend_of_record\n"], + ["more hit lines than found lines", "SF:file.ts\nLF:0\nLH:1\nend_of_record\n"], + ])("rejects %s", (_, content) => { + expect(() => verifyLcov(content)).toThrow() + }) +}) diff --git a/src/scripts/verify-wasm-files.mjs b/src/scripts/verify-wasm-files.mjs new file mode 100644 index 0000000000..73ef3737da --- /dev/null +++ b/src/scripts/verify-wasm-files.mjs @@ -0,0 +1,14 @@ +import fs from "node:fs" +import path from "node:path" + +export function assertMatchingFiles(expectedDir, actualDir, filenames, message, filesystem = fs) { + for (const filename of filenames) { + if ( + !filesystem + .readFileSync(path.join(expectedDir, filename)) + .equals(filesystem.readFileSync(path.join(actualDir, filename))) + ) { + throw new Error(`${message}: ${filename}`) + } + } +} diff --git a/src/scripts/verify-wasm-files.spec.mjs b/src/scripts/verify-wasm-files.spec.mjs new file mode 100644 index 0000000000..3beedc642d --- /dev/null +++ b/src/scripts/verify-wasm-files.spec.mjs @@ -0,0 +1,29 @@ +import fs from "node:fs" +import os from "node:os" +import path from "node:path" +import { afterEach, beforeEach, describe, expect, it } from "vitest" + +import { assertMatchingFiles } from "./verify-wasm-files.mjs" + +describe("assertMatchingFiles", () => { + let root + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "verify-wasm-files-")) + }) + + afterEach(() => fs.rmSync(root, { recursive: true, force: true })) + + it("rejects a restored WASM with corrupted content", () => { + const expected = path.join(root, "expected") + const actual = path.join(root, "actual") + fs.mkdirSync(expected) + fs.mkdirSync(actual) + fs.writeFileSync(path.join(expected, "tree-sitter-a.wasm"), "expected") + fs.writeFileSync(path.join(actual, "tree-sitter-a.wasm"), "corrupted") + + expect(() => assertMatchingFiles(expected, actual, ["tree-sitter-a.wasm"], "WASM mismatch")).toThrow( + "WASM mismatch: tree-sitter-a.wasm", + ) + }) +}) diff --git a/src/services/tree-sitter/__tests__/helpers.ts b/src/services/tree-sitter/__tests__/helpers.ts index 3f9f4c247c..348cdd1f2d 100644 --- a/src/services/tree-sitter/__tests__/helpers.ts +++ b/src/services/tree-sitter/__tests__/helpers.ts @@ -34,12 +34,12 @@ export async function initializeTreeSitter() { // Initialize directly using the default export or the module itself await Parser.init() - // Override the Parser.Language.load to use dist directory + // Use the cacheable test prerequisite rather than the bundled extension output. const originalLoad = Language.load Language.load = async (wasmPath: string) => { const filename = path.basename(wasmPath) - const correctPath = path.join(process.cwd(), "dist", filename) + const correctPath = path.join(process.cwd(), "generated", "tree-sitter-wasms", filename) // console.log(`Redirecting WASM load from ${wasmPath} to ${correctPath}`) return originalLoad(correctPath) } @@ -84,7 +84,7 @@ export async function testParseSourceCodeDefinitions( const parser = new Parser() // Load language and configure parser - const wasmPath = path.join(process.cwd(), `dist/${wasmFile}`) + const wasmPath = path.join(process.cwd(), "generated", "tree-sitter-wasms", wasmFile) const lang = await Language.load(wasmPath) parser.setLanguage(lang) @@ -113,7 +113,7 @@ export async function testParseSourceCodeDefinitions( export async function inspectTreeStructure(content: string, language: string = "typescript"): Promise { const { Parser, Language } = await initializeTreeSitter() const parser = new Parser() - const wasmPath = path.join(process.cwd(), `dist/tree-sitter-${language}.wasm`) + const wasmPath = path.join(process.cwd(), "generated", "tree-sitter-wasms", `tree-sitter-${language}.wasm`) const lang = await Language.load(wasmPath) parser.setLanguage(lang) diff --git a/src/services/tree-sitter/__tests__/languageParser.spec.ts b/src/services/tree-sitter/__tests__/languageParser.spec.ts index fe4dcdb2d9..e6c254b0d5 100644 --- a/src/services/tree-sitter/__tests__/languageParser.spec.ts +++ b/src/services/tree-sitter/__tests__/languageParser.spec.ts @@ -4,7 +4,7 @@ import * as path from "path" import { loadRequiredLanguageParsers } from "../languageParser" // Path to the directory containing the WASM files. -const WASM_DIR = path.join(__dirname, "../../../node_modules/tree-sitter-wasms/out") +const WASM_DIR = path.join(__dirname, "../../../generated/tree-sitter-wasms") describe("loadRequiredLanguageParsers", () => { it("should load Python parser for .py files", async () => { diff --git a/src/turbo.json b/src/turbo.json index 024971987f..2357ece1ef 100644 --- a/src/turbo.json +++ b/src/turbo.json @@ -3,20 +3,23 @@ "extends": ["//"], "tasks": { "test": { - "dependsOn": ["$TURBO_EXTENDS$", "bundle"] + "dependsOn": ["$TURBO_EXTENDS$", "bundle", "prepare:tree-sitter-wasms"] }, "test:unit": { - "dependsOn": ["@roo-code/types#build"], + "dependsOn": ["@roo-code/types#build", "prepare:tree-sitter-wasms"], "inputs": ["$TURBO_DEFAULT$", "!__tests__/dist_assets.spec.ts"] }, "test:dist": { "dependsOn": ["bundle"] }, + "prepare:tree-sitter-wasms": { + "outputs": ["generated/tree-sitter-wasms/**"] + }, "test:coverage": { - "dependsOn": ["$TURBO_EXTENDS$", "bundle"] + "dependsOn": ["$TURBO_EXTENDS$", "bundle", "prepare:tree-sitter-wasms"] }, "test:coverage:unit": { - "dependsOn": ["@roo-code/types#build"], + "dependsOn": ["@roo-code/types#build", "prepare:tree-sitter-wasms"], "inputs": ["$TURBO_DEFAULT$", "!__tests__/dist_assets.spec.ts"], "outputs": ["coverage/unit/**"] },