Skip to content

Commit ba46d1f

Browse files
[Chore] Use cacheable extension test lanes in CI (#1620)
* chore(ci): use cacheable extension test lanes * fix(ci): prepare tree-sitter WASMs for coverage * fix(ci): require covered lines in LCOV guard * fix(ci): publish coverage prerequisites atomically * test(ci): enforce coverage lane contract * fix(ci): isolate coverage contract cache * fix(ci): verify cached WASM restoration * fix(ci): support coverage verifier on Windows * fix(ci): require complete LCOV line summaries * fix(ci): validate LCOV line totals * fix(ci): reject duplicate LCOV summaries * fix(ci): reject empty LCOV source paths * fix(ci): preserve verified WASM artifacts * test(ci): cover LCOV line count placement * fix(ci): make WASM publication transactional * fix(ci): preserve WASMs through interruption * fix(ci): retain interrupted WASM recovery * test(ci): preserve recovery across retries * test(ci): remove duplicate recovery assertion * fix(ci): protect WASM transaction boundaries * fix(ci): preserve WASMs during cache verification * fix(ci): make WASM snapshot acquisition atomic * fix(ci): make WASM snapshot restore retryable * fix(ci): separate verifier cache cleanup * refactor(ci): simplify generated WASM recovery * refactor(ci): isolate cached WASM test assets * refactor(ci): load test WASMs from dependency * test(ci): strengthen WASM dependency contract --------- Co-authored-by: Roomote <roomote@roomote.dev>
1 parent 4fe5a1f commit ba46d1f

8 files changed

Lines changed: 224 additions & 18 deletions

File tree

‎.github/workflows/code-qa.yml‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,20 @@ jobs:
150150
restore-keys: |
151151
${{ runner.os }}-turbo-${{ hashFiles('**/pnpm-lock.yaml') }}-
152152
${{ runner.os }}-turbo-
153-
- name: Run non-core coverage
154-
run: pnpm turbo run test:coverage --filter="!@roo-code/core" --log-order grouped --output-logs new-only
153+
- name: Run non-extension package coverage
154+
run: pnpm turbo run test:coverage --filter="!@roo-code/core" --filter="!zoo-code" --log-order grouped --output-logs new-only
155+
- name: Run extension unit coverage
156+
run: pnpm turbo run test:coverage:unit --filter="zoo-code" --log-order grouped --output-logs new-only
157+
- name: Verify extension coverage contract
158+
run: pnpm --dir src run verify:coverage-contract
159+
- name: Run extension dist smoke test
160+
run: pnpm turbo run test:dist --filter="zoo-code" --log-order grouped --output-logs new-only
155161
- name: Run core unit coverage
156162
run: pnpm turbo run test:coverage:unit --filter="@roo-code/core" --log-order grouped --output-logs new-only
157163
- name: Run core integration coverage
158164
run: pnpm turbo run test:coverage:integration --filter="@roo-code/core" --log-order grouped --output-logs new-only
165+
- name: Verify extension unit coverage report
166+
run: node src/scripts/verify-lcov.mjs src/coverage/unit/lcov.info
159167
- name: Save Turbo cache
160168
if: steps.turbo-cache.outputs.cache-hit != 'true'
161169
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
@@ -177,7 +185,7 @@ jobs:
177185
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
178186
with:
179187
files: >-
180-
src/coverage/lcov.info,
188+
src/coverage/unit/lcov.info,
181189
packages/cloud/coverage/lcov.info,
182190
packages/telemetry/coverage/lcov.info,
183191
apps/cli/coverage/lcov.info
@@ -214,7 +222,7 @@ jobs:
214222
with:
215223
name: coverage-reports-${{ matrix.name }}
216224
path: |
217-
src/coverage/lcov.info
225+
src/coverage/unit/lcov.info
218226
webview-ui/coverage/lcov.info
219227
packages/cloud/coverage/lcov.info
220228
packages/telemetry/coverage/lcov.info

‎src/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,7 @@
442442
"lint": "eslint . --ext=ts --max-warnings=0",
443443
"check-types": "tsc --noEmit",
444444
"test": "vitest run",
445+
"verify:coverage-contract": "node scripts/verify-coverage-contract.mjs",
445446
"test:unit": "vitest run --config vitest.unit.config.ts",
446447
"test:dist": "vitest run --config vitest.dist.config.ts",
447448
"test:coverage": "vitest run --coverage",
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { spawnSync } from "node:child_process"
2+
import process from "node:process"
3+
4+
const pnpm = process.platform === "win32" ? process.env.npm_execpath : "pnpm"
5+
if (!pnpm) throw new Error("pnpm executable path is unavailable")
6+
const command = process.platform === "win32" ? process.execPath : pnpm
7+
const args = process.platform === "win32" ? [pnpm] : []
8+
const result = spawnSync(
9+
command,
10+
[...args, "turbo", "run", "test:coverage:unit", "test:dist", "--filter=zoo-code", "--dry=json"],
11+
{ encoding: "utf8" },
12+
)
13+
if (result.status !== 0) {
14+
const details = [result.error?.message, result.signal, result.stderr, result.stdout].filter(Boolean).join("\n")
15+
throw new Error(details || `pnpm exited with status ${result.status ?? "unknown"}`)
16+
}
17+
18+
const graph = JSON.parse(result.stdout)
19+
const coverageTask = graph.tasks.find(({ taskId }) => taskId === "zoo-code#test:coverage:unit")
20+
const distTask = graph.tasks.find(({ taskId }) => taskId === "zoo-code#test:dist")
21+
if (!coverageTask) throw new Error("Unit coverage task missing")
22+
if (graph.tasks.some(({ taskId }) => taskId === "zoo-code#prepare:tree-sitter-wasms"))
23+
throw new Error("Removed WASM preparation task remains in the graph")
24+
if (coverageTask.dependencies.includes("zoo-code#bundle")) throw new Error("Unit coverage must not depend on bundle")
25+
if (!coverageTask.dependencies.includes("@roo-code/types#build"))
26+
throw new Error("Unit coverage must depend on the types build")
27+
if (!Object.hasOwn(coverageTask.inputs, "package.json")) throw new Error("Unit coverage must hash package.json")
28+
if (!coverageTask.hashOfExternalDependencies) throw new Error("Unit coverage must hash external dependencies")
29+
if (!distTask?.dependencies.includes("zoo-code#bundle")) throw new Error("Dist smoke test must depend on bundle")

‎src/scripts/verify-lcov.mjs‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import fs from "node:fs"
2+
import process from "node:process"
3+
import { fileURLToPath } from "node:url"
4+
5+
export function verifyLcov(content) {
6+
let inRecord = false
7+
let anyCovered = false
8+
let linesFound
9+
let linesHit
10+
11+
for (const line of content.split(/\r?\n/)) {
12+
if (line.startsWith("SF:")) {
13+
if (inRecord) throw new Error("LCOV source record is not terminated")
14+
if (!line.slice(3)) throw new Error("LCOV source path is empty")
15+
inRecord = true
16+
linesFound = undefined
17+
linesHit = undefined
18+
} else if (line.startsWith("LF:")) {
19+
if (!inRecord) throw new Error("LCOV line count is outside a source record")
20+
if (linesFound !== undefined) throw new Error("LCOV source record has duplicate line counts")
21+
const found = line.slice(3)
22+
if (!/^\d+$/.test(found)) throw new Error("LCOV line count is not a decimal integer")
23+
linesFound = BigInt(found)
24+
} else if (line.startsWith("LH:")) {
25+
if (!inRecord) throw new Error("LCOV hit count is outside a source record")
26+
if (linesHit !== undefined) throw new Error("LCOV source record has duplicate hit counts")
27+
const hits = line.slice(3)
28+
if (!/^\d+$/.test(hits)) throw new Error("LCOV hit count is not a decimal integer")
29+
linesHit = BigInt(hits)
30+
} else if (line === "end_of_record") {
31+
if (!inRecord) throw new Error("LCOV terminator is outside a source record")
32+
if (linesFound === undefined || linesHit === undefined)
33+
throw new Error("LCOV source record has incomplete line summaries")
34+
if (linesHit > linesFound) throw new Error("LCOV hit count exceeds lines found")
35+
if (linesHit > 0n) anyCovered = true
36+
inRecord = false
37+
}
38+
}
39+
40+
if (inRecord) throw new Error("LCOV source record is not terminated")
41+
if (!anyCovered) throw new Error("LCOV report has no covered lines")
42+
}
43+
44+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
45+
verifyLcov(fs.readFileSync(process.argv[2], "utf8"))
46+
}

‎src/scripts/verify-lcov.spec.mjs‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { verifyLcov } from "./verify-lcov.mjs"
4+
5+
describe("verifyLcov", () => {
6+
it("accepts complete records with covered lines", () => {
7+
expect(() => verifyLcov("SF:file.ts\nLF:1\nLH:1\nend_of_record\n")).not.toThrow()
8+
})
9+
10+
it.each([
11+
["an empty source path", "SF:\nLF:1\nLH:1\nend_of_record\n"],
12+
["an unterminated record", "SF:file.ts\nLH:1\n"],
13+
["a zero-hit report", "SF:file.ts\nLF:1\nLH:0\nend_of_record\n"],
14+
["a line count outside a record", "LF:1\n"],
15+
["a hit count outside a record", "LH:1\n"],
16+
["a terminator outside a record", "end_of_record\n"],
17+
["consecutive source records", "SF:first.ts\nSF:second.ts\nLF:1\nLH:1\nend_of_record\n"],
18+
["a record without lines found", "SF:file.ts\nLH:1\nend_of_record\n"],
19+
["an infinite line count", "SF:file.ts\nLF:Infinity\nLH:1\nend_of_record\n"],
20+
["a fractional line count", "SF:file.ts\nLF:1.5\nLH:1\nend_of_record\n"],
21+
["an exponential line count", "SF:file.ts\nLF:1e3\nLH:1\nend_of_record\n"],
22+
["an infinite hit count", "SF:file.ts\nLH:Infinity\nend_of_record\n"],
23+
["a fractional hit count", "SF:file.ts\nLH:1.5\nend_of_record\n"],
24+
["an exponential hit count", "SF:file.ts\nLH:1e3\nend_of_record\n"],
25+
["duplicate line counts", "SF:file.ts\nLF:1\nLF:0\nLH:0\nend_of_record\n"],
26+
["duplicate hit counts", "SF:file.ts\nLF:1\nLH:1\nLH:0\nend_of_record\n"],
27+
["more hit lines than found lines", "SF:file.ts\nLF:0\nLH:1\nend_of_record\n"],
28+
])("rejects %s", (_, content) => {
29+
expect(() => verifyLcov(content)).toThrow()
30+
})
31+
})

‎src/services/tree-sitter/__tests__/helpers.ts‎

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import * as path from "path"
44
import tsxQuery from "../queries/tsx"
55
import { Parser, Language } from "web-tree-sitter"
66

7+
import { loadTestGrammar } from "./wasm"
8+
79
vi.mock("fs/promises")
810
export const mockedFs = vi.mocked(fs)
911

@@ -34,16 +36,6 @@ export async function initializeTreeSitter() {
3436
// Initialize directly using the default export or the module itself
3537
await Parser.init()
3638

37-
// Override the Parser.Language.load to use dist directory
38-
const originalLoad = Language.load
39-
40-
Language.load = async (wasmPath: string) => {
41-
const filename = path.basename(wasmPath)
42-
const correctPath = path.join(process.cwd(), "dist", filename)
43-
// console.log(`Redirecting WASM load from ${wasmPath} to ${correctPath}`)
44-
return originalLoad(correctPath)
45-
}
46-
4739
initializedTreeSitter = { Parser, Language }
4840
}
4941

@@ -84,8 +76,7 @@ export async function testParseSourceCodeDefinitions(
8476
const parser = new Parser()
8577

8678
// Load language and configure parser
87-
const wasmPath = path.join(process.cwd(), `dist/${wasmFile}`)
88-
const lang = await Language.load(wasmPath)
79+
const lang = await loadTestGrammar(path.basename(wasmFile))
8980
parser.setLanguage(lang)
9081

9182
// Create a real query
@@ -113,8 +104,7 @@ export async function testParseSourceCodeDefinitions(
113104
export async function inspectTreeStructure(content: string, language: string = "typescript"): Promise<string> {
114105
const { Parser, Language } = await initializeTreeSitter()
115106
const parser = new Parser()
116-
const wasmPath = path.join(process.cwd(), `dist/tree-sitter-${language}.wasm`)
117-
const lang = await Language.load(wasmPath)
107+
const lang = await loadTestGrammar(`tree-sitter-${language}.wasm`)
118108
parser.setLanguage(lang)
119109

120110
// Parse the content
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import fs from "fs"
2+
import os from "os"
3+
import path from "path"
4+
import { afterEach, beforeAll, describe, expect, it } from "vitest"
5+
import { Parser } from "web-tree-sitter"
6+
7+
import { loadTestGrammar } from "./wasm"
8+
9+
const requiredGrammars = [
10+
"c",
11+
"cpp",
12+
"c_sharp",
13+
"css",
14+
"dart",
15+
"elisp",
16+
"elixir",
17+
"embedded_template",
18+
"go",
19+
"html",
20+
"java",
21+
"javascript",
22+
"json",
23+
"kotlin",
24+
"lua",
25+
"ocaml",
26+
"php",
27+
"python",
28+
"ruby",
29+
"rust",
30+
"scala",
31+
"solidity",
32+
"swift",
33+
"systemrdl",
34+
"tlaplus",
35+
"toml",
36+
"tsx",
37+
"typescript",
38+
"vue",
39+
"zig",
40+
]
41+
42+
async function captureLoadFailure(filename: string, directory: string) {
43+
let error: unknown
44+
try {
45+
await loadTestGrammar(filename, directory)
46+
} catch (caught) {
47+
error = caught
48+
}
49+
if (!(error instanceof Error) || !(error.cause instanceof Error)) {
50+
throw new Error("Expected a contextual grammar load error with an Error cause")
51+
}
52+
expect(error.message).toContain(error.cause.message)
53+
return error
54+
}
55+
56+
describe("dependency-owned Tree-sitter grammars", () => {
57+
const temporaryDirectories: string[] = []
58+
59+
beforeAll(() => Parser.init())
60+
afterEach(() => temporaryDirectories.splice(0).forEach((directory) => fs.rmSync(directory, { recursive: true })))
61+
62+
it.each(requiredGrammars)("loads tree-sitter-%s.wasm", async (grammar) => {
63+
const language = await loadTestGrammar(`tree-sitter-${grammar}.wasm`)
64+
expect(() => new Parser().setLanguage(language)).not.toThrow()
65+
})
66+
67+
it("reports a missing dependency artifact with its filename and resolved path", async () => {
68+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "tree-sitter-wasm-missing-"))
69+
temporaryDirectories.push(directory)
70+
71+
const error = await captureLoadFailure("tree-sitter-missing.wasm", directory)
72+
expect(error.message).toContain(
73+
`Failed to load Tree-sitter grammar tree-sitter-missing.wasm from ${path.join(directory, "tree-sitter-missing.wasm")}`,
74+
)
75+
})
76+
77+
it("reports a malformed dependency artifact with its filename and resolved path", async () => {
78+
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "tree-sitter-wasm-malformed-"))
79+
temporaryDirectories.push(directory)
80+
fs.writeFileSync(path.join(directory, "tree-sitter-malformed.wasm"), "not wasm")
81+
82+
const error = await captureLoadFailure("tree-sitter-malformed.wasm", directory)
83+
expect(error.message).toContain(
84+
`Failed to load Tree-sitter grammar tree-sitter-malformed.wasm from ${path.join(directory, "tree-sitter-malformed.wasm")}`,
85+
)
86+
})
87+
})
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import path from "path"
2+
import { Language } from "web-tree-sitter"
3+
4+
export const TEST_WASM_DIR = path.join(__dirname, "../../../node_modules/tree-sitter-wasms/out")
5+
6+
export async function loadTestGrammar(filename: string, directory = TEST_WASM_DIR) {
7+
const wasmPath = path.join(directory, filename)
8+
try {
9+
return await Language.load(wasmPath)
10+
} catch (error) {
11+
const detail = error instanceof Error ? error.message : String(error)
12+
throw new Error(`Failed to load Tree-sitter grammar ${filename} from ${wasmPath}: ${detail}`, { cause: error })
13+
}
14+
}

0 commit comments

Comments
 (0)