diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7410b0cdf..cdec8ac65 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -347,9 +347,9 @@ jobs: needs: changes if: needs.changes.outputs.docs_only != 'true' runs-on: windows-latest - # Windows unit jobs are advisory and package-scoped. The 20-minute budget - # gives each matrix child room for Windows runner setup and install overhead - # while still surfacing the package that timed out. + # Windows unit jobs are advisory, package-scoped or shard-scoped. The + # 20-minute budget applies per matrix child; opencode has three parallel + # shards, so a full stall can consume up to three Windows runner slots. timeout-minutes: 20 continue-on-error: true strategy: @@ -357,12 +357,88 @@ jobs: matrix: include: - package: app + uses_turbo: true command: bun turbo test:ci --filter=@opencode-ai/app report_path: packages/app/.artifacts/unit/junit.xml - - package: opencode - command: bun turbo test:ci --filter=opencode - report_path: packages/opencode/.artifacts/unit/junit.xml + # The opencode-* package values below are synthetic shard names for + # readable advisory checks and matrix keys, not package.json names. + # Direct bun test keeps opencode's test:ci timeout while allowing + # shard-local paths and reporter output. Shards are balanced by + # observed runtime, not directory count. + # Session also carries plugin, permission, util, skill, and root + # tests to keep the Windows shard runtimes close. + - package: opencode-session + uses_turbo: false + command: >- + cd packages/opencode && bun test + --timeout 30000 + --reporter=junit + --reporter-outfile=.artifacts/unit/junit-windows-session.xml + test/session + test/plugin + test/permission + test/util + test/skill + test/index-runtime-namespace.test.ts + test/npm.test.ts + test/permission-task.test.ts + report_path: packages/opencode/.artifacts/unit/junit-windows-session.xml + # Config and project cover configuration, project discovery, file, + # and GitHub workflow tests. + - package: opencode-config-project + uses_turbo: false + command: >- + cd packages/opencode && bun test + --timeout 30000 + --reporter=junit + --reporter-outfile=.artifacts/unit/junit-windows-config-project.xml + test/config + test/project + test/file + test/github + report_path: packages/opencode/.artifacts/unit/junit-windows-config-project.xml + # Server tools carries many smaller and faster directories, which is + # why it has more entries than the other two opencode shards. + - package: opencode-server-tools + uses_turbo: false + command: >- + cd packages/opencode && bun test + --timeout 30000 + --reporter=junit + --reporter-outfile=.artifacts/unit/junit-windows-server-tools.xml + test/server + test/snapshot + test/tool + test/mcp + test/question + test/effect + test/agent + test/git + test/storage + test/provider + test/pty + test/share + test/script + test/memory + test/lsp + test/fixture + test/acp + test/bus + test/cli + test/global + test/format + test/account + test/sync + test/filesystem + test/patch + test/shell + test/control-plane + test/ide + test/installation + test/auth + report_path: packages/opencode/.artifacts/unit/junit-windows-server-tools.xml - package: desktop + uses_turbo: true command: bun turbo test:ci --filter=@opencode-ai/desktop-electron report_path: packages/desktop-electron/.artifacts/unit/junit.xml permissions: @@ -396,10 +472,10 @@ jobs: bun-${{ runner.os }}- - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # actions/cache@v5 + if: matrix.uses_turbo with: path: .turbo/cache - # Keep Turbo caches package-scoped because each matrix child runs a - # different test filter; restore keys reuse only that package's cache. + # Only app and desktop invoke Turbo in this Windows matrix. key: turbo-${{ runner.os }}-unit-windows-${{ matrix.package }}-${{ hashFiles('turbo.json', '**/package.json', 'bun.lock') }}-${{ github.sha }} restore-keys: | turbo-${{ runner.os }}-unit-windows-${{ matrix.package }}-${{ hashFiles('turbo.json', '**/package.json', 'bun.lock') }}- @@ -407,6 +483,10 @@ jobs: - run: bun install --frozen-lockfile + - name: Prepare unit artifact directory + if: matrix.uses_turbo == false + run: mkdir -p "$(dirname "${{ matrix.report_path }}")" + - name: unit id: unit continue-on-error: true diff --git a/packages/opencode/test/github/ci-workflow.test.ts b/packages/opencode/test/github/ci-workflow.test.ts index 2e3abbaf1..e8e0bab45 100644 --- a/packages/opencode/test/github/ci-workflow.test.ts +++ b/packages/opencode/test/github/ci-workflow.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "bun:test" +import { existsSync, readdirSync, statSync } from "node:fs" import path from "node:path" import { parseWorkflow, readWorkflow } from "./workflow-parser" const repoRoot = path.join(import.meta.dir, "../../../..") const workflowPath = path.join(repoRoot, ".github", "workflows", "ci.yml") +const opencodeTestRoot = path.join(repoRoot, "packages", "opencode", "test") const pinned = { checkout: "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd", @@ -19,7 +21,7 @@ const windowsUnitJobName = "unit-windows" // Suffixes drive readable job and artifact names; commands use package.json names verbatim. // `opencode` is intentionally unscoped because that is its actual package name. -const unitPackages = [ +const linuxUnitPackages = [ { suffix: "app", command: "bun turbo test:ci --filter=@opencode-ai/app", @@ -37,14 +39,57 @@ const unitPackages = [ }, ] as const -const linuxUnitJobs = unitPackages.map((pkg) => ({ +const windowsOpencodeShards = [ + // Intentional dual source with ci.yml: this pins the exact shard command + // contract while the coverage test expands these paths to catch workflow + // drift and missing opencode tests. Update ci.yml and this list together. + { + suffix: "opencode-session", + usesTurbo: false, + command: + "cd packages/opencode && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-windows-session.xml test/session test/plugin test/permission test/util test/skill test/index-runtime-namespace.test.ts test/npm.test.ts test/permission-task.test.ts", + reportPath: "packages/opencode/.artifacts/unit/junit-windows-session.xml", + }, + { + suffix: "opencode-config-project", + usesTurbo: false, + command: + "cd packages/opencode && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-windows-config-project.xml test/config test/project test/file test/github", + reportPath: "packages/opencode/.artifacts/unit/junit-windows-config-project.xml", + }, + { + suffix: "opencode-server-tools", + usesTurbo: false, + command: + "cd packages/opencode && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit-windows-server-tools.xml test/server test/snapshot test/tool test/mcp test/question test/effect test/agent test/git test/storage test/provider test/pty test/share test/script test/memory test/lsp test/fixture test/acp test/bus test/cli test/global test/format test/account test/sync test/filesystem test/patch test/shell test/control-plane test/ide test/installation test/auth", + reportPath: "packages/opencode/.artifacts/unit/junit-windows-server-tools.xml", + }, +] as const + +const windowsUnitPackages = [ + { + suffix: "app", + usesTurbo: true, + command: "bun turbo test:ci --filter=@opencode-ai/app", + reportPath: "packages/app/.artifacts/unit/junit.xml", + }, + ...windowsOpencodeShards, + { + suffix: "desktop", + usesTurbo: true, + command: "bun turbo test:ci --filter=@opencode-ai/desktop-electron", + reportPath: "packages/desktop-electron/.artifacts/unit/junit.xml", + }, +] as const + +const linuxUnitJobs = linuxUnitPackages.map((pkg) => ({ ...pkg, jobName: `unit-${pkg.suffix}`, checkName: `unit results (${pkg.suffix})`, artifactName: `unit-${pkg.suffix}-${runAttempt}`, })) -const windowsUnitJobs = unitPackages.map((pkg) => ({ +const windowsUnitJobs = windowsUnitPackages.map((pkg) => ({ ...pkg, jobName: `unit-windows-${pkg.suffix}`, artifactName: `unit-windows-${pkg.suffix}-${runAttempt}`, @@ -63,6 +108,47 @@ function stepByName(job: string, name: string) { return steps(job).find((step) => step.name === name) } +function toPosix(relativePath: string) { + return relativePath.split(path.sep).join("/") +} + +function isTestFile(filePath: string) { + return /\.(test|spec)\.(ts|tsx|js|mjs|cjs)$/.test(filePath) +} + +function listOpencodeTestFiles(dir = opencodeTestRoot): string[] { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) return listOpencodeTestFiles(fullPath) + if (!entry.isFile() || !isTestFile(entry.name)) return [] + + return [`test/${toPosix(path.relative(opencodeTestRoot, fullPath))}`] + }) +} + +function expandOpencodeTestPath(testPath: string): string[] { + const fullPath = path.join(repoRoot, "packages", "opencode", testPath) + if (!existsSync(fullPath)) { + throw new Error(`Windows opencode shard path does not exist: ${testPath}`) + } + if (statSync(fullPath).isDirectory()) { + return listOpencodeTestFiles(fullPath) + } + return [testPath] +} + +function testPathArgs(command: string) { + const testArgs = command.split(" bun test ")[1] + if (!testArgs) { + throw new Error(`Windows opencode shard command does not invoke bun test: ${command}`) + } + return testArgs.split(/\s+/).filter((arg) => arg.startsWith("test/")) +} + +function isWindowsOpencodeShard(item: Record): item is { command: string; package: string } { + return item.uses_turbo === false && typeof item.package === "string" && typeof item.command === "string" +} + describe("ci workflow", () => { test("pins third-party actions and disables checkout credential persistence", () => { const workflow = readWorkflow(workflowPath) @@ -148,10 +234,9 @@ describe("ci workflow", () => { } }) - test("splits Windows unit signals by package without publishing advisory check runs", () => { + test("keeps Windows unit package and shard signals advisory", () => { const parsed = parseWorkflow(workflowPath) const job = parsed.jobs?.[windowsUnitJobName] - const matrixIncludes = job?.strategy?.matrix?.include ?? [] expect(job?.name).toBe("unit-windows-${{ matrix.package }}") expect(job?.needs).toBe("changes") @@ -162,6 +247,10 @@ describe("ci workflow", () => { expect(job?.strategy?.["fail-fast"]).toBe(false) expect(job?.permissions).toEqual({ contents: "read" }) expect(job?.defaults?.run?.shell).toBe("bash") + expect(stepByName(windowsUnitJobName, "Prepare unit artifact directory")?.run).toBe( + 'mkdir -p "$(dirname "${{ matrix.report_path }}")"', + ) + expect(stepByName(windowsUnitJobName, "Prepare unit artifact directory")?.if).toBe("matrix.uses_turbo == false") expect(stepByName(windowsUnitJobName, "unit")?.id).toBe("unit") expect(stepByName(windowsUnitJobName, "unit")?.["continue-on-error"]).toBe(true) expect(stepByName(windowsUnitJobName, "unit")?.run).toContain("${{ matrix.command }}") @@ -175,7 +264,8 @@ describe("ci workflow", () => { ) expect(stepByName(windowsUnitJobName, "Upload unit artifacts")?.with?.path).toBe("${{ matrix.report_path }}") - const turboCacheStep = steps(windowsUnitJobName).filter((step) => step.uses?.startsWith("actions/cache@"))[1] + const turboCacheStep = steps(windowsUnitJobName).find((step) => step.with?.path === ".turbo/cache") + expect(turboCacheStep?.if).toBe("matrix.uses_turbo") expect(turboCacheStep?.with?.key).toBe( "turbo-${{ runner.os }}-unit-windows-${{ matrix.package }}-${{ hashFiles('turbo.json', '**/package.json', 'bun.lock') }}-${{ github.sha }}", ) @@ -183,21 +273,90 @@ describe("ci workflow", () => { "turbo-${{ runner.os }}-unit-windows-${{ matrix.package }}-${{ hashFiles('turbo.json', '**/package.json', 'bun.lock') }}-\n" + "turbo-${{ runner.os }}-unit-windows-${{ matrix.package }}-\n", ) + }) + + test("defines Windows unit packages and opencode shards", () => { + const parsed = parseWorkflow(workflowPath) + const job = parsed.jobs?.[windowsUnitJobName] + const matrixIncludes = job?.strategy?.matrix?.include ?? [] expect(matrixIncludes).toEqual( - windowsUnitJobs.map(({ jobName, command, reportPath }) => ({ + windowsUnitJobs.map(({ jobName, usesTurbo, command, reportPath }) => ({ package: jobName.replace("unit-windows-", ""), + uses_turbo: usesTurbo, command, report_path: reportPath, })), ) - for (const { jobName, command, reportPath, artifactName } of windowsUnitJobs) { + for (const { jobName, artifactName } of windowsUnitJobs) { expect(parsed.jobs?.[jobName]).toBeUndefined() expect(artifactName).toBe(`unit-windows-${jobName.replace("unit-windows-", "")}-${runAttempt}`) } }) + test("covers each opencode test file exactly once across Windows opencode shards", () => { + const parsed = parseWorkflow(workflowPath) + const matrixIncludes = parsed.jobs?.[windowsUnitJobName]?.strategy?.matrix?.include ?? [] + const opencodeShards = matrixIncludes.filter(isWindowsOpencodeShard) + const allTestFiles = listOpencodeTestFiles().sort() + const allTestFilesSet = new Set(allTestFiles) + const coverage = new Map() + const extra: string[] = [] + const shardFileCounts = new Map() + + // This repeats names instead of deriving from windowsOpencodeShards so the + // coverage test pins the public advisory check names explicitly. + expect(opencodeShards.map((item) => item.package)).toEqual([ + "opencode-session", + "opencode-config-project", + "opencode-server-tools", + ]) + + for (const item of opencodeShards) { + const testPaths = testPathArgs(item.command) + let fileCount = 0 + for (const testPath of testPaths) { + const expanded = expandOpencodeTestPath(testPath) + fileCount += expanded.length + for (const file of expanded) { + if (!allTestFilesSet.has(file)) { + extra.push(file) + continue + } + coverage.set(file, [...(coverage.get(file) ?? []), item.package]) + } + } + shardFileCounts.set(item.package, fileCount) + } + + const missing = allTestFiles.filter((file) => !coverage.has(file)) + const duplicates = [...coverage.entries()] + .filter(([, shardNames]) => shardNames.length > 1) + .map(([file, shardNames]) => ({ file, shards: shardNames })) + + if (missing.length > 0 || extra.length > 0 || duplicates.length > 0) { + const shardNames = opencodeShards.map((item) => item.package) + const smallestShard = [...shardFileCounts.entries()].sort(([, left], [, right]) => left - right)[0]?.[0] + const details = [ + "Windows opencode shard coverage drift.", + missing.length > 0 ? `Uncovered files: ${missing.join(", ")}` : undefined, + extra.length > 0 ? `Unknown shard paths: ${extra.sort().join(", ")}` : undefined, + duplicates.length > 0 ? `Duplicate coverage: ${JSON.stringify(duplicates)}` : undefined, + `Shard choices: ${shardNames.join(" | ")}`, + smallestShard ? `Suggested starting shard for uncovered files: ${smallestShard}` : undefined, + ].filter((line): line is string => typeof line === "string") + + throw new Error(details.join("\n")) + } + + expect({ duplicates, extra: extra.sort(), missing }).toEqual({ + duplicates: [], + extra: [], + missing: [], + }) + }) + test("keeps docs-only behavior and excludes Windows from the blocking aggregate", () => { const parsed = parseWorkflow(workflowPath) const check = parsed.jobs?.check @@ -210,6 +369,9 @@ describe("ci workflow", () => { expect(needs).not.toContain("unit-windows-app") expect(needs).not.toContain("unit-windows-desktop") expect(needs).not.toContain("unit-windows-opencode") + expect(needs).not.toContain("unit-windows-opencode-session") + expect(needs).not.toContain("unit-windows-opencode-config-project") + expect(needs).not.toContain("unit-windows-opencode-server-tools") expect(validate?.env?.DOCS_ONLY).toBe("${{ needs.changes.outputs.docs_only }}") expect(validate?.env?.TYPECHECK_RESULT).toBe("${{ needs.typecheck.result }}") expect(validate?.env?.UNIT_APP_RESULT).toBe("${{ needs['unit-app'].result }}")