diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index e93d5fbdb260..675bf65b6e8a 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -7,7 +7,7 @@ on: jobs: sync: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: write pull-requests: write diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index 15bf0783160e..834201716be4 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -16,7 +16,7 @@ permissions: jobs: build: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 env: REGISTRY: ghcr.io/${{ github.repository_owner }} TAG: "24.04" diff --git a/.github/workflows/docs-locale-sync.yml b/.github/workflows/docs-locale-sync.yml index 5f921e8bb717..2e0b6a097360 100644 --- a/.github/workflows/docs-locale-sync.yml +++ b/.github/workflows/docs-locale-sync.yml @@ -11,7 +11,7 @@ jobs: sync-locales: if: false #if: github.actor != 'opencode-agent[bot]' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: write steps: diff --git a/.github/workflows/docs-update.yml b/.github/workflows/docs-update.yml index 4767dec53999..9ef26de3f822 100644 --- a/.github/workflows/docs-update.yml +++ b/.github/workflows/docs-update.yml @@ -11,7 +11,7 @@ env: jobs: update-docs: if: github.repository == 'sst/opencode' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: id-token: write contents: write diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index 4648a2d0c3d3..e448b9f6c1d3 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -7,7 +7,7 @@ on: jobs: check-duplicates: if: github.event.action == 'opened' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read issues: write @@ -119,7 +119,7 @@ jobs: recheck-compliance: if: github.event.action == 'edited' && contains(github.event.issue.labels.*.name, 'needs:compliance') - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read issues: write diff --git a/.github/workflows/generate.yml b/.github/workflows/generate.yml index 324cfec02001..c435dac41f2e 100644 --- a/.github/workflows/generate.yml +++ b/.github/workflows/generate.yml @@ -7,7 +7,7 @@ on: jobs: generate: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: write pull-requests: write diff --git a/.github/workflows/nix-eval.yml b/.github/workflows/nix-eval.yml index 75332695a1ad..7ac0a4b22ff0 100644 --- a/.github/workflows/nix-eval.yml +++ b/.github/workflows/nix-eval.yml @@ -16,7 +16,7 @@ permissions: jobs: nix-eval: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 timeout-minutes: 15 steps: - name: Checkout repository diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index ce1d9237fde5..dbab39ba907f 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -30,9 +30,9 @@ jobs: matrix: include: - system: x86_64-linux - runner: blacksmith-4vcpu-ubuntu-2404 + runner: ubuntu-24.04 - system: aarch64-linux - runner: blacksmith-4vcpu-ubuntu-2404-arm + runner: ubuntu-24.04-arm - system: x86_64-darwin runner: macos-15-intel - system: aarch64-darwin @@ -91,7 +91,7 @@ jobs: update-hashes: needs: compute-hash if: github.event_name != 'pull_request' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - name: Checkout repository diff --git a/.github/workflows/notify-discord.yml b/.github/workflows/notify-discord.yml index 0b2b1cde051b..37905a7a1c88 100644 --- a/.github/workflows/notify-discord.yml +++ b/.github/workflows/notify-discord.yml @@ -6,7 +6,7 @@ on: jobs: notify: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - name: Send nicely-formatted embed to Discord uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0 diff --git a/.github/workflows/opencode.yml b/.github/workflows/opencode.yml index 3469c21917f8..84bb4d171750 100644 --- a/.github/workflows/opencode.yml +++ b/.github/workflows/opencode.yml @@ -13,7 +13,7 @@ jobs: startsWith(github.event.comment.body, '/oc') || contains(github.event.comment.body, ' /opencode') || startsWith(github.event.comment.body, '/opencode') - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: id-token: write contents: read diff --git a/.github/workflows/pr-management.yml b/.github/workflows/pr-management.yml index b6aa4e589d89..ecb1c7b72d32 100644 --- a/.github/workflows/pr-management.yml +++ b/.github/workflows/pr-management.yml @@ -6,7 +6,7 @@ on: jobs: check-duplicates: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: write diff --git a/.github/workflows/publish-github-action.yml b/.github/workflows/publish-github-action.yml index e5ca91b5618a..9cb419cc3118 100644 --- a/.github/workflows/publish-github-action.yml +++ b/.github/workflows/publish-github-action.yml @@ -14,7 +14,7 @@ permissions: jobs: publish: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: diff --git a/.github/workflows/publish-vscode.yml b/.github/workflows/publish-vscode.yml index 00c7e260482e..102265fae913 100644 --- a/.github/workflows/publish-vscode.yml +++ b/.github/workflows/publish-vscode.yml @@ -13,7 +13,7 @@ permissions: jobs: publish: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 037020c03af1..feac2ce580af 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,7 +33,7 @@ permissions: jobs: version: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 if: github.repository == 'anomalyco/opencode' steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 @@ -70,7 +70,7 @@ jobs: build-cli: needs: version - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 if: github.repository == 'anomalyco/opencode' steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 @@ -121,7 +121,7 @@ jobs: needs: - build-cli - version - runs-on: blacksmith-4vcpu-windows-2025 + runs-on: windows-2025 if: github.repository == 'anomalyco/opencode' env: AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} @@ -242,17 +242,16 @@ jobs: target: aarch64-apple-darwin platform_flag: --mac --arm64 bun_install_flags: --os=darwin --cpu=arm64 - # github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain - host: "windows-2025" target: aarch64-pc-windows-msvc platform_flag: --win --arm64 - - host: "blacksmith-4vcpu-windows-2025" + - host: "windows-2025" target: x86_64-pc-windows-msvc platform_flag: --win - - host: "blacksmith-4vcpu-ubuntu-2404" + - host: "ubuntu-24.04" target: x86_64-unknown-linux-gnu platform_flag: --linux - - host: "blacksmith-4vcpu-ubuntu-2404-arm" + - host: "ubuntu-24.04-arm" target: aarch64-unknown-linux-gnu platform_flag: --linux --arm64 runs-on: ${{ matrix.settings.host }} @@ -412,7 +411,7 @@ jobs: - sign-cli-windows - build-electron if: always() && !failure() && !cancelled() - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 diff --git a/.github/workflows/release-github-action.yml b/.github/workflows/release-github-action.yml index 4a1d7218bb2c..2436841286cd 100644 --- a/.github/workflows/release-github-action.yml +++ b/.github/workflows/release-github-action.yml @@ -14,7 +14,7 @@ permissions: jobs: release: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 00a4fba8ca13..b2e3f0afa12f 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -10,7 +10,7 @@ jobs: github.event.issue.pull_request && startsWith(github.event.comment.body, '/review') && contains(fromJson('["OWNER","MEMBER"]'), github.event.comment.author_association) - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read pull-requests: write diff --git a/.github/workflows/stats.yml b/.github/workflows/stats.yml index bc97cfcd7188..2842b9644a2b 100644 --- a/.github/workflows/stats.yml +++ b/.github/workflows/stats.yml @@ -10,7 +10,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: stats: if: github.repository == 'anomalyco/opencode' - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: write diff --git a/.github/workflows/storybook.yml b/.github/workflows/storybook.yml index be2e099d0ed9..d61e872301df 100644 --- a/.github/workflows/storybook.yml +++ b/.github/workflows/storybook.yml @@ -28,7 +28,7 @@ concurrency: jobs: build: name: storybook build - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c69de1d93b0d..146d1980e245 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,9 +28,9 @@ jobs: matrix: settings: - name: linux - host: blacksmith-4vcpu-ubuntu-2404 + host: ubuntu-24.04 - name: windows - host: blacksmith-4vcpu-windows-2025 + host: windows-2025 runs-on: ${{ matrix.settings.host }} defaults: run: @@ -63,9 +63,33 @@ jobs: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}- turbo-${{ runner.os }}- + - name: Install ripgrep (Windows) + # RipgrepBinary prefers a PATH-resolved rg.exe; without one, the first + # ripgrep test pays a cold download + Expand-Archive that exceeds the + # 5s per-test timeout on hosted runners. Version pinned to match + # packages/core/src/ripgrep/binary.ts. + if: runner.os == 'Windows' + run: | + curl -fsSL --retry 3 -o "$RUNNER_TEMP/rg.zip" https://github.com/BurntSushi/ripgrep/releases/download/15.1.0/ripgrep-15.1.0-x86_64-pc-windows-msvc.zip + 7z e "$RUNNER_TEMP/rg.zip" -o"$RUNNER_TEMP/ripgrep" rg.exe -r -y + "$RUNNER_TEMP/ripgrep/rg.exe" --version + echo "$RUNNER_TEMP/ripgrep" >> "$GITHUB_PATH" + + - name: Cache downloaded tool binaries + # Ripgrep tests download rg into ~/.cache/opencode/bin on first use; + # on GitHub-hosted windows runners that download has blown the test + # timeout. Cache it so only version bumps re-download. + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 + with: + path: ~/.cache/opencode/bin + key: opencode-bin-${{ runner.os }}-${{ hashFiles('packages/core/src/ripgrep/binary.ts') }} - name: Run unit tests - timeout-minutes: 20 - run: GITHUB_ACTIONS=false bun turbo test + # opencode#test alone takes ~20 minutes on GitHub-hosted windows runners. + # --log-order=stream keeps per-task output visible if the step is ever + # killed; turbo's CI default (grouped) buffers a task's output until it + # finishes, so a timeout eats the entire log (LAC-2717). + timeout-minutes: 35 + run: GITHUB_ACTIONS=false bun turbo test --log-order=stream env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} @@ -86,9 +110,9 @@ jobs: matrix: settings: - name: linux - host: blacksmith-4vcpu-ubuntu-2404 + host: ubuntu-24.04 - name: windows - host: blacksmith-4vcpu-windows-2025 + host: windows-2025 runs-on: ${{ matrix.settings.host }} env: PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml index 27852a12ce4d..9b40891276f8 100644 --- a/.github/workflows/triage.yml +++ b/.github/workflows/triage.yml @@ -6,7 +6,7 @@ on: jobs: triage: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 permissions: contents: read issues: write diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index fc9a52797c1d..fc39b3adea51 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -9,7 +9,7 @@ on: jobs: typecheck: - runs-on: blacksmith-4vcpu-ubuntu-2404 + runs-on: ubuntu-24.04 steps: - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 diff --git a/packages/core/src/filesystem/search.ts b/packages/core/src/filesystem/search.ts index 72c9128cfd16..b1966b72a782 100644 --- a/packages/core/src/filesystem/search.ts +++ b/packages/core/src/filesystem/search.ts @@ -31,7 +31,13 @@ export const ripgrepLayer = Layer.effect( files: [] as string[], directories: [] as string[], } + const files = new Set() const directories = new Set() + // The scan fills the find() index incrementally. A transient spawn failure + // or hung rg process would otherwise kill this forked fiber silently and + // leave find() empty forever (LAC-2693), so bound each attempt, retry, and + // log a scan that never completes. Dedupe keeps retries from re-adding + // entries already indexed by an interrupted attempt. yield* ripgrep .find({ cwd: location.directory, @@ -39,13 +45,21 @@ export const ripgrepLayer = Layer.effect( limit: location.vcs ? Number.MAX_SAFE_INTEGER : 100_000, onEntry: (entry) => Effect.sync(() => { + if (files.has(entry.path)) return + files.add(entry.path) state.files.push(entry.path) const parts = entry.path.split("/") parts.slice(0, -1).forEach((_, index) => directories.add(parts.slice(0, index + 1).join("/") + path.sep)) state.directories = Array.from(directories) }), }) - .pipe(Effect.orDie, Effect.asVoid, Effect.forkIn(scope)) + .pipe( + Effect.timeout("120 seconds"), + Effect.retry({ times: 2 }), + Effect.catch((error) => Effect.logWarning("file index scan failed", { error })), + Effect.asVoid, + Effect.forkIn(scope), + ) return Service.of({ glob: (input) => Effect.gen(function* () { diff --git a/packages/core/test/filesystem/search.test.ts b/packages/core/test/filesystem/search.test.ts index 6c47c85e9635..f74bbb6f1889 100644 --- a/packages/core/test/filesystem/search.test.ts +++ b/packages/core/test/filesystem/search.test.ts @@ -10,6 +10,10 @@ import { testEffect } from "../lib/effect" const it = testEffect(LayerNode.compile(Ripgrep.node)) +// first Ripgrep use may download and extract the rg binary, which exceeds the +// default 5s test timeout on Windows CI runners (PowerShell Expand-Archive) +const RG_DOWNLOAD = { timeout: 120_000 } + const withTmp = (f: (directory: AbsolutePath) => Effect.Effect) => Effect.acquireRelease( Effect.promise(() => tmpdir()), @@ -26,6 +30,7 @@ describe("Ripgrep", () => { expect(result.map((item) => item.path)).toEqual([RelativePath.make("src/match.ts")]) }), ), + RG_DOWNLOAD, ) it.live("greps files with include filtering", () => @@ -40,5 +45,6 @@ describe("Ripgrep", () => { expect(result[0]?.submatches[0]?.text).toBe("needle") }), ), + RG_DOWNLOAD, ) }) diff --git a/packages/core/test/lib/effect.ts b/packages/core/test/lib/effect.ts index 131ec5cc6bc2..c7df26384e86 100644 --- a/packages/core/test/lib/effect.ts +++ b/packages/core/test/lib/effect.ts @@ -19,24 +19,26 @@ const run = (value: Body, layer: Layer.Layer return yield* exit }).pipe(Effect.runPromise) -const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer) => { +const make = (testLayer: Layer.Layer, liveLayer: Layer.Layer, defaults?: number | TestOptions) => { + const options = (opts?: number | TestOptions) => opts ?? defaults + const effect = (name: string, value: Body, opts?: number | TestOptions) => - test(name, () => run(value, testLayer), opts) + test(name, () => run(value, testLayer), options(opts)) effect.only = (name: string, value: Body, opts?: number | TestOptions) => - test.only(name, () => run(value, testLayer), opts) + test.only(name, () => run(value, testLayer), options(opts)) effect.skip = (name: string, value: Body, opts?: number | TestOptions) => - test.skip(name, () => run(value, testLayer), opts) + test.skip(name, () => run(value, testLayer), options(opts)) const live = (name: string, value: Body, opts?: number | TestOptions) => - test(name, () => run(value, liveLayer), opts) + test(name, () => run(value, liveLayer), options(opts)) live.only = (name: string, value: Body, opts?: number | TestOptions) => - test.only(name, () => run(value, liveLayer), opts) + test.only(name, () => run(value, liveLayer), options(opts)) live.skip = (name: string, value: Body, opts?: number | TestOptions) => - test.skip(name, () => run(value, liveLayer), opts) + test.skip(name, () => run(value, liveLayer), options(opts)) return { effect, live } } @@ -49,5 +51,5 @@ const liveEnv = TestConsole.layer export const it = make(testEnv, liveEnv) -export const testEffect = (layer: Layer.Layer) => - make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv)) +export const testEffect = (layer: Layer.Layer, defaults?: number | TestOptions) => + make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv), defaults) diff --git a/packages/core/test/project-copy.test.ts b/packages/core/test/project-copy.test.ts index d37f6302074a..15e24f4ffd13 100644 --- a/packages/core/test/project-copy.test.ts +++ b/packages/core/test/project-copy.test.ts @@ -19,6 +19,10 @@ import { testEffect } from "./lib/effect" const it = testEffect( AppNodeBuilder.build(LayerNode.group([ProjectCopy.node, Database.node, EventV2.node, ProjectDirectories.node])), + // These tests chain many git subprocess ops; on GitHub-hosted Windows + // runners each op costs 100ms-2.8s under full-suite load, exceeding bun's + // 5s default per-test timeout (LAC-2717). + { timeout: 30_000 }, ) function abs(input: string) { diff --git a/packages/core/test/project.test.ts b/packages/core/test/project.test.ts index fa709a8b2bf5..dafecb0e3828 100644 --- a/packages/core/test/project.test.ts +++ b/packages/core/test/project.test.ts @@ -12,6 +12,10 @@ import { testEffect } from "./lib/effect" const it = testEffect(AppNodeBuilder.build(ProjectV2.node)) +// Every test spawns several git subprocesses via initRepo; on loaded +// GitHub-hosted windows runners a single spawn can blow the 5s default. +const GIT_SPAWN = { timeout: 30_000 } + function remoteID(remote: string) { return ProjectV2.ID.make(Hash.fast(`git-remote:${remote}`)) } @@ -54,6 +58,7 @@ describe("ProjectV2.resolve", () => { expect(result.previous).toBeUndefined() expect(result.vcs).toBeUndefined() }), + GIT_SPAWN, ) it.live("returns git global for repo with no commits and no remote", () => @@ -72,6 +77,7 @@ describe("ProjectV2.resolve", () => { expect(result.previous).toBeUndefined() expect(result.vcs?.type).toBe("git") }), + GIT_SPAWN, ) it.live("falls back to root commit when origin is missing", () => @@ -90,6 +96,7 @@ describe("ProjectV2.resolve", () => { expect(result.previous).toBeUndefined() expect(result.vcs?.type).toBe("git") }), + GIT_SPAWN, ) it.live("prefers normalized origin over root commit", () => @@ -108,6 +115,7 @@ describe("ProjectV2.resolve", () => { expect(result.directory).toBe(yield* real(tmp.path)) expect(result.vcs?.type).toBe("git") }), + GIT_SPAWN, ) it.live("normalizes ssh and https remotes to the same id", () => @@ -130,6 +138,7 @@ describe("ProjectV2.resolve", () => { expect(a.id).toBe(remoteID("github.com/owner/repo")) expect(b.id).toBe(a.id) }), + GIT_SPAWN, ) it.live("ignores file remotes and falls back to root commit", () => @@ -145,6 +154,7 @@ describe("ProjectV2.resolve", () => { expect(result.id).toBe(ProjectV2.ID.make(yield* Effect.promise(() => rootCommit(tmp.path)))) }), + GIT_SPAWN, ) it.live("returns previous cached id from common dir", () => @@ -162,6 +172,7 @@ describe("ProjectV2.resolve", () => { expect(result.previous).toBe(ProjectV2.ID.make("old-id")) expect(result.id).toBe(remoteID("github.com/owner/repo")) }), + GIT_SPAWN, ) it.live("does not write the cache while resolving", () => @@ -177,6 +188,7 @@ describe("ProjectV2.resolve", () => { expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, ".git", "opencode")).exists())).toBe(false) }), + GIT_SPAWN, ) it.live("resolves from nested directories to repo root", () => @@ -193,6 +205,7 @@ describe("ProjectV2.resolve", () => { expect(result.directory).toBe(yield* real(tmp.path)) }), + GIT_SPAWN, ) it.live("linked worktree returns opened worktree directory and previous from common dir", () => @@ -217,5 +230,6 @@ describe("ProjectV2.resolve", () => { expect(result.id).toBe(remoteID("github.com/owner/repo")) expect(result.vcs?.type).toBe("git") }), + GIT_SPAWN, ) }) diff --git a/packages/core/test/repository-cache.test.ts b/packages/core/test/repository-cache.test.ts index 2dd0ce250288..198ad81bbc49 100644 --- a/packages/core/test/repository-cache.test.ts +++ b/packages/core/test/repository-cache.test.ts @@ -12,7 +12,10 @@ import { git, gitRemote } from "./fixture/git" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" -const it = testEffect(Layer.empty) +// These tests chain many git subprocess ops; on GitHub-hosted Windows runners +// each op costs 100ms-2.8s under full-suite load, exceeding bun's 5s default +// per-test timeout (LAC-2717). +const it = testEffect(Layer.empty, { timeout: 30_000 }) describe("RepositoryCache", () => { it.live("replaces a stale cache directory before cloning", () => diff --git a/packages/core/test/ripgrep.test.ts b/packages/core/test/ripgrep.test.ts index 3abce1c02d6d..fe8ef62a097b 100644 --- a/packages/core/test/ripgrep.test.ts +++ b/packages/core/test/ripgrep.test.ts @@ -10,6 +10,12 @@ import { testEffect } from "./lib/effect" const it = testEffect(LayerNode.compile(Ripgrep.node)) +// first Ripgrep use may download and extract the rg binary; on a cold cache a +// loaded Windows CI runner has been observed to need over 120s for the +// download plus PowerShell Expand-Archive (CI caches ~/.cache/opencode/bin, +// so this budget only applies on cache misses) +const RG_DOWNLOAD = { timeout: 240_000 } + describe("Ripgrep", () => { it.live("keeps ignored files out of catch-all find results", () => Effect.acquireUseRelease( @@ -29,6 +35,7 @@ describe("Ripgrep", () => { }), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), + RG_DOWNLOAD, ) it.live("never includes git metadata", () => @@ -61,5 +68,6 @@ describe("Ripgrep", () => { }), (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ), + RG_DOWNLOAD, ) }) diff --git a/packages/core/test/snapshot.test.ts b/packages/core/test/snapshot.test.ts index 55e4ead7ed2e..7913361daa86 100644 --- a/packages/core/test/snapshot.test.ts +++ b/packages/core/test/snapshot.test.ts @@ -15,8 +15,13 @@ import { Hash } from "@opencode-ai/core/util/hash" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" +// These tests chain many git subprocess ops; on GitHub-hosted Windows +// runners each op costs 100ms-2.8s under full-suite load, exceeding bun's +// 5s default per-test timeout (LAC-2717). +const it = testEffect(Layer.empty, { timeout: 30_000 }) + describe("Snapshot", () => { - testEffect(Layer.empty).live("captures and restores Location-scoped changes", () => + it.live("captures and restores Location-scoped changes", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => @@ -70,7 +75,7 @@ describe("Snapshot", () => { ), ) - testEffect(Layer.empty).live("treats capture outside Git as unavailable", () => + it.live("treats capture outside Git as unavailable", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => @@ -86,7 +91,7 @@ describe("Snapshot", () => { ), ) - testEffect(Layer.empty).live("isolates snapshot indexes by canonical Git worktree", () => + it.live("isolates snapshot indexes by canonical Git worktree", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => @@ -134,7 +139,7 @@ describe("Snapshot", () => { ), ) - testEffect(Layer.empty).live("checks out a legacy revert snapshot without removing unrelated files", () => + it.live("checks out a legacy revert snapshot without removing unrelated files", () => Effect.acquireUseRelease( Effect.promise(() => tmpdir()), (tmp) => diff --git a/packages/opencode/plugin/shell-mode/command-check.ts b/packages/opencode/plugin/shell-mode/command-check.ts index 78e1d676b0bb..129db80e7c64 100644 --- a/packages/opencode/plugin/shell-mode/command-check.ts +++ b/packages/opencode/plugin/shell-mode/command-check.ts @@ -7,6 +7,7 @@ import { Shell } from "@opencode-ai/core/shell" import { which } from "@opencode-ai/core/util/which" import { spawn as nodeSpawn } from "node:child_process" +import { realpathSync } from "node:fs" import path from "path" /** @@ -444,7 +445,25 @@ function commandExists(cmd: string): boolean { if (SHELL_BUILTINS.has(cmd)) return true // PATH executables (synchronous, no shell spawn) - if (which(cmd) !== null) return true + const found = which(cmd) + if (found !== null) return process.platform !== "win32" || matchesCanonicalCase(cmd, found) return false } + +/** + * Windows PATH lookup is case-insensitive, which erases the casing signal this + * heuristic relies on: a capitalized first word ("Help me fix this bug") is a + * strong natural-language marker, and on POSIX it already fails the lookup. + * Require the typed name to match the executable's on-disk casing so routing + * behaves the same across platforms (LAC-2693). + */ +function matchesCanonicalCase(cmd: string, found: string): boolean { + try { + const base = path.win32.basename(realpathSync.native(found)) + const name = base.slice(0, base.length - path.win32.extname(base).length) + return cmd === name || cmd === base + } catch { + return true + } +} diff --git a/packages/opencode/plugin/shell-mode/cwd.ts b/packages/opencode/plugin/shell-mode/cwd.ts index 68a63ee0b098..ce81124e0642 100644 --- a/packages/opencode/plugin/shell-mode/cwd.ts +++ b/packages/opencode/plugin/shell-mode/cwd.ts @@ -27,13 +27,17 @@ export const CwdEvent = { /** * Get the current working directory. * Returns Instance.directory if not explicitly set. + * + * The ambient instance context is only entered by CLI `bootstrap()`; Effect-based + * callers (tools, session prompt) run with `InstanceRef` instead and must pass the + * instance directory as `fallback` or getCwd degrades to process.cwd(). */ -export function getCwd(): string { +export function getCwd(fallback?: string): string { if (currentCwd === null) { try { return instanceContext.use().directory } catch { - return process.cwd() + return fallback ?? process.cwd() } } return currentCwd @@ -83,3 +87,36 @@ export function setCwd(dir: string): void { export function resetCwd(): void { currentCwd = null } + +export interface CwdSentinelResult { + exitCode: number | null + cwd: string | null +} + +/** + * Parse the payload that follows the cwd sentinel in wrapped shell output: + * ":", or legacy "" with no exit code. + * + * Shells always report their working directory as an absolute path. A + * non-absolute value means the wrapper template was not expanded by the + * shell that ran it (e.g. cmd.exe echoing a bash-style "$(pwd -P ...)" + * literally, or bash echoing "%CD%"). Such values must never reach + * setCwd — the poisoned cwd is shared process-wide and makes every + * subsequent spawn fail its cwd access check (LAC-2693). + */ +export function parseCwdSentinelPayload(payload: string): CwdSentinelResult { + let exitCode: number | null = null + let cwd: string | null = payload || null + const colonIndex = payload.indexOf(":") + if (colonIndex !== -1) { + const code = parseInt(payload.slice(0, colonIndex), 10) + // A non-numeric prefix means the colon belongs to the cwd itself + // (e.g. a bare "D:\foo" drive path), not an exit-code separator. + if (!isNaN(code)) { + exitCode = code + cwd = payload.slice(colonIndex + 1).trim() || null + } + } + if (cwd && !path.win32.isAbsolute(cwd) && !path.posix.isAbsolute(cwd)) cwd = null + return { exitCode, cwd } +} diff --git a/packages/opencode/plugin/shell-mode/index.ts b/packages/opencode/plugin/shell-mode/index.ts index e6caf6866d18..e83bf6a4c4c8 100644 --- a/packages/opencode/plugin/shell-mode/index.ts +++ b/packages/opencode/plugin/shell-mode/index.ts @@ -7,7 +7,7 @@ export { ExecutionMode, ModeController, getModeController, getModeDisplay, type ModeDisplay } from "./mode" export { shouldRouteToShell } from "./command-check" -export { getCwd, setCwd, resetCwd, CwdEvent } from "./cwd" +export { getCwd, setCwd, resetCwd, parseCwdSentinelPayload, type CwdSentinelResult, CwdEvent } from "./cwd" export { execute as SessionShellExecute, dispose as SessionShellDispose, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9b8d28724c20..747b246fa0f4 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -36,7 +36,7 @@ import { Permission } from "@/permission" import { SessionStatus } from "./status" import { LLM } from "./llm" import { Shell } from "@opencode-ai/core/shell" -import { getCwd, setCwd, detectNaturalLanguage } from "@shell-mode" +import { getCwd, setCwd, parseCwdSentinelPayload, detectNaturalLanguage } from "@shell-mode" import { ShellID } from "@/tool/shell/id" import { FSUtil } from "@opencode-ai/core/fs-util" import { Truncate } from "@/tool/truncate" @@ -274,7 +274,7 @@ export const layer = Layer.effect( mode: task.agent, agent: task.agent, variant: lastUser.model.variant, - path: { cwd: getCwd(), root: ctx.worktree }, + path: { cwd: getCwd(ctx.directory), root: ctx.worktree }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: taskModel.id, @@ -494,7 +494,7 @@ export const layer = Layer.effect( mode: input.agent, agent: input.agent, cost: 0, - path: { cwd: getCwd(), root: ctx.worktree }, + path: { cwd: getCwd(ctx.directory), root: ctx.worktree }, time: { created: Date.now() }, role: "assistant", tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, @@ -517,7 +517,7 @@ export const layer = Layer.effect( }, } yield* sessions.updatePart(part) - return { msg, part, cwd: getCwd() } + return { msg, part, cwd: getCwd(ctx.directory) } }).pipe(Effect.ensuring(markReady)) const cfg = yield* config.get() @@ -600,14 +600,20 @@ export const layer = Layer.effect( let output = "" let aborted = false - const getDisplayOutput = () => { - const sentinelIndex = output.lastIndexOf(cwdSentinel) - if (sentinelIndex === -1) return output - let lineStart = sentinelIndex - while (lineStart > 0 && output[lineStart - 1] !== "\n") lineStart-- - return output.slice(0, lineStart).trimEnd() + // Remove the sentinel line but keep everything around it: output with no + // trailing newline shares the sentinel's line, and stderr chunks from the + // merged stream can arrive after the sentinel was written to stdout. + const stripSentinel = (raw: string) => { + const sentinelIndex = raw.lastIndexOf(cwdSentinel) + if (sentinelIndex === -1) return raw + const afterSentinel = raw.slice(sentinelIndex + cwdSentinel.length) + const newlineIndex = afterSentinel.indexOf("\n") + const rest = newlineIndex === -1 ? "" : afterSentinel.slice(newlineIndex + 1) + return (raw.slice(0, sentinelIndex) + rest).trimEnd() } + const getDisplayOutput = () => stripSentinel(output) + const finish = Effect.uninterruptible( Effect.gen(function* () { if (aborted) { @@ -621,18 +627,10 @@ export const layer = Layer.effect( const afterSentinel = output.slice(sentinelIndex + cwdSentinel.length) const newlineIndex = afterSentinel.indexOf("\n") const payload = (newlineIndex !== -1 ? afterSentinel.slice(0, newlineIndex) : afterSentinel).trim() - const colonIndex = payload.indexOf(":") - if (colonIndex !== -1) { - const code = parseInt(payload.slice(0, colonIndex), 10) - if (!isNaN(code)) commandExitCode = code - const newCwd = payload.slice(colonIndex + 1).trim() - if (newCwd) setCwd(newCwd) - } else if (payload) { - setCwd(payload) - } - let lineStart = sentinelIndex - while (lineStart > 0 && output[lineStart - 1] !== "\n") lineStart-- - cleanOutput = output.slice(0, lineStart).trimEnd() + const parsed = parseCwdSentinelPayload(payload) + if (parsed.exitCode !== null) commandExitCode = parsed.exitCode + if (parsed.cwd) setCwd(parsed.cwd) + cleanOutput = stripSentinel(output) } const hint = detectNaturalLanguage(input.command, cleanOutput, commandExitCode) @@ -1298,7 +1296,7 @@ export const layer = Layer.effect( mode: agent.name, agent: agent.name, variant: lastUser.model.variant, - path: { cwd: getCwd(), root: ctx.worktree }, + path: { cwd: getCwd(ctx.directory), root: ctx.worktree }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, modelID: model.id, diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index c8059faeb8fa..9c9b75340d59 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -66,7 +66,7 @@ export const layer = Layer.effect( `You are powered by the model named ${model.api.id}. The exact model ID is ${model.providerID}/${model.api.id}`, `Here is some useful information about the environment you are running in:`, ``, - ` Working directory: ${getCwd()}`, + ` Working directory: ${getCwd(ctx.directory)}`, ` Workspace root folder: ${ctx.worktree}`, ` Is directory a git repo: ${ctx.project.vcs === "git" ? "yes" : "no"}`, ` Platform: ${process.platform}`, diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index ba7d2e25a964..91011122196d 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -1,7 +1,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { httpClient, path } from "@opencode-ai/core/effect/app-node-platform" import { NodePath } from "@effect/platform-node" -import { Effect, Layer, Path, Schema, Context } from "effect" +import { Effect, Layer, Path, Schedule, Schema, Context } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -34,6 +34,14 @@ export const layer: Layer.Layer + process.platform === "win32" + ? fs.rename(from, to).pipe(Effect.retry({ times: 5, schedule: Schedule.spaced(100) })) + : fs.rename(from, to) + const download = Effect.fn("Discovery.download")(function* (url: string, dest: string) { if (yield* fs.exists(dest).pipe(Effect.orDie)) return true @@ -106,11 +114,11 @@ export const layer: Layer.Layer Effect.gen(function* () { - if (cached) yield* fs.rename(backup, root).pipe(Effect.ignore) + if (cached) yield* rename(backup, root).pipe(Effect.ignore) return yield* Effect.fail(error) }), ), diff --git a/packages/opencode/src/tool/shell.ts b/packages/opencode/src/tool/shell.ts index a47aa2ebd449..30a038cda6a0 100644 --- a/packages/opencode/src/tool/shell.ts +++ b/packages/opencode/src/tool/shell.ts @@ -610,9 +610,8 @@ export const ShellTool = Tool.define( execute: (params: Parameters, ctx: Tool.Context) => Effect.gen(function* () { const instanceCtx = yield* InstanceState.context - const cwd = params.workdir - ? yield* resolvePath(params.workdir, getCwd(), shell) - : getCwd() + const base = getCwd(instanceCtx.directory) + const cwd = params.workdir ? yield* resolvePath(params.workdir, base, shell) : base if (params.timeout !== undefined && params.timeout < 0) { throw new Error(`Invalid timeout value: ${params.timeout}. Timeout must be a positive number.`) } diff --git a/packages/opencode/test/cli/acp/lifecycle.test.ts b/packages/opencode/test/cli/acp/lifecycle.test.ts index 9f2558ea2f58..932001098c83 100644 --- a/packages/opencode/test/cli/acp/lifecycle.test.ts +++ b/packages/opencode/test/cli/acp/lifecycle.test.ts @@ -18,7 +18,9 @@ describe("opencode acp lifecycle subprocess", () => { const acp = yield* opencode.acp() acp.close() - const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(5))) + // Generous window: on windows CI runners the CLI's bun startup alone + // can exceed 5s, and it only handles the stdin EOF once booted (LAC-2693) + const code = yield* Effect.promise(() => acp.exited).pipe(Effect.timeout(Duration.seconds(30))) expect(code).toBe(0) }), 60_000, diff --git a/packages/opencode/test/cli/cmd/tui/execution-mode-color-bar.test.ts b/packages/opencode/test/cli/cmd/tui/execution-mode-color-bar.test.ts index c11720034b6e..edd8c5c370d5 100644 --- a/packages/opencode/test/cli/cmd/tui/execution-mode-color-bar.test.ts +++ b/packages/opencode/test/cli/cmd/tui/execution-mode-color-bar.test.ts @@ -19,7 +19,7 @@ import { getModeDisplay, getModeController, ExecutionMode } from "@shell-mode" // const PROMPT_SRC = readFileSync( - join(import.meta.dir, "../../../../src/cli/cmd/tui/component/prompt/index.tsx"), + join(import.meta.dir, "../../../../../tui/src/component/prompt/index.tsx"), "utf-8", ) diff --git a/packages/opencode/test/cli/run/run-process.test.ts b/packages/opencode/test/cli/run/run-process.test.ts index bd5847e2723c..a7199d650b7a 100644 --- a/packages/opencode/test/cli/run/run-process.test.ts +++ b/packages/opencode/test/cli/run/run-process.test.ts @@ -8,6 +8,14 @@ import { Effect } from "effect" import { reply } from "../../lib/llm-server" import { cliIt } from "../../lib/cli-process" +// Per-test (bun) timeout. Concurrent tests share the harness spawn gate +// (see spawnGate in test/lib/cli-process.ts), so a test's wall clock includes +// time queued behind other tests' subprocesses — the budget must cover the +// whole suite's serialized throughput on a loaded CI runner, not one spawn. +// Fail-fast for a genuinely hung subprocess comes from the per-spawn 30s +// timeout (which starts only once the spawn holds a permit), not from this. +const testTimeout = 120_000 + describe("opencode run (non-interactive subprocess)", () => { // Happy path: prompt completes, output reaches stdout, process exits 0. // If this fails, all the others likely will too — debug here first. @@ -20,7 +28,7 @@ describe("opencode run (non-interactive subprocess)", () => { opencode.expectExit(result, 0) expect(result.stdout).toBe("hello from the test llm\n") }), - 60_000, + testTimeout, ) cliIt.concurrent( @@ -42,7 +50,7 @@ describe("opencode run (non-interactive subprocess)", () => { opencode.expectExit(result, 0) expect(result.stdout).toBe("before tool\nafter tool\n") }), - 60_000, + testTimeout, ) cliIt.concurrent( @@ -59,26 +67,27 @@ describe("opencode run (non-interactive subprocess)", () => { opencode.expectExit(plain, 0) expect(plain.stdout).toBe("visible\n") }), - 60_000, + testTimeout, ) // Regression for #27371: an unknown model used to hang the process forever // waiting on a session.status === idle event that never arrived. The fix // makes the SDK call surface an error promptly so the process exits nonzero. // We assert nonzero exit AND wall-clock under the harness timeout — a hang - // would expire the timeout and produce a different (signal-killed) failure. + // would run to the 30s cutoff and fail the duration assertion. The budget + // must absorb cold CLI startup on loaded 2-core CI runners (observed >15s). cliIt.concurrent( "exits nonzero promptly when the model is unknown (regression for #27371)", ({ opencode }) => Effect.gen(function* () { const result = yield* opencode.run("say hi", { model: "test/nonexistent-model", - timeoutMs: 15_000, + timeoutMs: 30_000, }) expect(result.exitCode).not.toBe(0) - expect(result.durationMs).toBeLessThan(15_000) + expect(result.durationMs).toBeLessThan(30_000) }), - 30_000, + testTimeout, ) // The test provider's SSE error item is interpreted by the SDK as an unknown @@ -100,7 +109,7 @@ describe("opencode run (non-interactive subprocess)", () => { expect(result.stdout).toBe("partial response\n") expect(result.stderr).not.toContain("upstream provider exploded mid-stream") }), - 60_000, + testTimeout, ) // --format json puts one JSON object per line on stdout for each emitted @@ -137,7 +146,7 @@ describe("opencode run (non-interactive subprocess)", () => { .every((line) => line.length > 0), ).toBe(true) }), - 60_000, + testTimeout, ) cliIt.concurrent( @@ -160,7 +169,7 @@ describe("opencode run (non-interactive subprocess)", () => { }) expect(result.stdout.split("\n").filter(Boolean)).toHaveLength(1) }), - 30_000, + testTimeout, ) cliIt.concurrent( @@ -209,7 +218,7 @@ describe("opencode run (non-interactive subprocess)", () => { .every((line) => line.startsWith("{")), ).toBe(true) }), - 60_000, + testTimeout, ) cliIt.concurrent( @@ -238,7 +247,7 @@ describe("opencode run (non-interactive subprocess)", () => { expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" })) expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "unknown" })) }), - 60_000, + testTimeout, ) cliIt.concurrent( @@ -274,7 +283,7 @@ describe("opencode run (non-interactive subprocess)", () => { expect(explicitlyDenied.stdout).toContain("continued after explicit denial") expect(yield* Effect.promise(() => Bun.file(`${home}/explicitly-denied`).exists())).toBe(false) }), - 60_000, + testTimeout, ) cliIt.live( @@ -296,7 +305,7 @@ describe("opencode run (non-interactive subprocess)", () => { expect(input).toContain(sentinel) expect(input).not.toContain(`file://${source}`) }), - 60_000, + testTimeout, ) cliIt.concurrent( @@ -310,7 +319,7 @@ describe("opencode run (non-interactive subprocess)", () => { expect(result.exitCode).not.toBe(0) expect(result.stderr).toContain("Cannot attach local directory without a shared filesystem") }), - 30_000, + testTimeout, ) cliIt.live( @@ -326,6 +335,6 @@ describe("opencode run (non-interactive subprocess)", () => { expect(result.exitCode).not.toBe(0) expect(result.durationMs).toBeLessThan(30_000) }), - 30_000, + testTimeout, ) }) diff --git a/packages/opencode/test/fixture/app.ts b/packages/opencode/test/fixture/app.ts new file mode 100644 index 000000000000..d366ed452f71 --- /dev/null +++ b/packages/opencode/test/fixture/app.ts @@ -0,0 +1,16 @@ +import { Effect } from "effect" +import { AppRuntime, type AppServices } from "../../src/effect/app-runtime" +import { InstanceRef } from "../../src/effect/instance-ref" +import { context as instanceContext } from "../../src/project/instance-context" + +/** + * Run an AppRuntime effect with InstanceRef taken from the ambient test instance. + * `attach()` only reads InstanceRef off the current Effect fiber, so plain async + * test code inside `provideTestInstance` must provide it explicitly. + * + * Kept separate from fixture.ts so tests that never touch AppRuntime don't load + * the full app module graph. + */ +export function runTestApp(effect: Effect.Effect): Promise { + return AppRuntime.runPromise(effect.pipe(Effect.provideService(InstanceRef, instanceContext.use()))) +} diff --git a/packages/opencode/test/fixture/fixture.ts b/packages/opencode/test/fixture/fixture.ts index f9898ede0d7d..c7b748d207f7 100644 --- a/packages/opencode/test/fixture/fixture.ts +++ b/packages/opencode/test/fixture/fixture.ts @@ -11,7 +11,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import type { Config } from "@/config/config" import { InstanceRef } from "../../src/effect/instance-ref" import { InstanceBootstrap } from "../../src/project/bootstrap-service" -import type { InstanceContext } from "../../src/project/instance-context" +import { context as instanceContext, type InstanceContext } from "../../src/project/instance-context" +import { resetCwd } from "@shell-mode" import { InstanceRuntime } from "../../src/project/instance-runtime" import { InstanceStore } from "../../src/project/instance-store" import { TestLLMServer } from "../lib/llm-server" @@ -27,7 +28,9 @@ export async function provideTestInstance(input: { const ctx = await InstanceRuntime.load({ directory: input.directory }) try { if (input.init) await Effect.runPromise(input.init.pipe(Effect.provideService(InstanceRef, ctx))) - return await input.fn(ctx) + // Enter the ambient instance context like production `bootstrap()` does, + // so code that reads it (e.g. shell-mode getCwd/setCwd) sees the test instance. + return await instanceContext.provide(ctx, () => input.fn(ctx)) } finally { await InstanceRuntime.disposeInstance(ctx) } @@ -203,6 +206,12 @@ export const withTmpdirInstance = (self: Effect.Effect) => Effect.gen(function* () { const directory = yield* tmpdirScoped(options) + // Shell-mode cwd is a module singleton; clear any cwd left by a previous + // test so commands run in this instance's directory, not a disposed tmpdir. + yield* Effect.acquireRelease( + Effect.sync(() => resetCwd()), + () => Effect.sync(() => resetCwd()), + ) return yield* self.pipe(Effect.provideService(TestInstance, { directory }), provideInstanceEffect(directory)) }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)) diff --git a/packages/opencode/test/lib/cli-process.ts b/packages/opencode/test/lib/cli-process.ts index 6de9033ffe38..be4d7f6a362e 100644 --- a/packages/opencode/test/lib/cli-process.ts +++ b/packages/opencode/test/lib/cli-process.ts @@ -20,9 +20,11 @@ import { test, type TestOptions } from "bun:test" import { FSUtil } from "@opencode-ai/core/fs-util" import { AppProcess } from "@opencode-ai/core/process" -import { Deferred, Duration, Effect, Layer, Queue, Schedule, Scope, Stream } from "effect" +import { Deferred, Duration, Effect, Layer, Queue, Schedule, Scope, Semaphore, Stream } from "effect" import { FetchHttpClient, HttpClient } from "effect/unstable/http" import { ChildProcess } from "effect/unstable/process" +import fs from "node:fs" +import os from "node:os" import path from "node:path" import { TestLLMServer } from "./llm-server" import { testProviderConfig } from "./test-provider" @@ -33,6 +35,32 @@ const cliEntry = path.join(opencodeRoot, "src/index.ts") export const testModelID = "test/test-model" +// Cold-starting `bun run src/index.ts` transpiles the whole dependency graph. +// With every cliIt.concurrent test spawning subprocesses at once, unbounded +// fan-out starves small CI runners (4 vCPU on GitHub-hosted ubuntu) until every +// child blows its 30s timeout with empty stdout (LAC-2715). Two mitigations: +// +// 1. spawnGate caps concurrent short-lived spawns near the core count. The +// per-spawn timer starts after the permit is acquired, so queue wait is not +// charged against timeoutMs or durationMs. +// 2. All children share one Bun transpiler cache. isolatedEnv points HOME and +// XDG_CACHE_HOME at a fresh tmpdir per test, which would otherwise leave +// every child's transpiler cache cold. The cache is content-addressed, so +// sharing it does not leak state between tests. +const spawnGate = Semaphore.makeUnsafe(Math.max(2, Math.min(4, Math.floor(os.availableParallelism() / 2)))) +// Suffix the cache dir with the username: os.tmpdir() is shared, and another +// user's 0755 cache dir would EACCES our transpiler-cache writes. userInfo() +// can throw in containers without a passwd entry, hence the fallback chain. +const cacheOwner = (() => { + try { + return os.userInfo().username + } catch { + return process.env["USER"] || process.env["USERNAME"] || "default" + } +})() +const sharedTranspilerCache = path.join(os.tmpdir(), `opencode-test-bun-transpiler-cache-${cacheOwner}`) +fs.mkdirSync(sharedTranspilerCache, { recursive: true }) + // Wrap a Bun subprocess pipe (or any ReadableStream) as a Stream. // Centralizes the `evaluate` + `onError` boilerplate and tags errors with the // stream name so a stderr/stdout failure is greppable in logs. @@ -65,6 +93,7 @@ function isolatedEnv(home: string, configJson: string): Record { XDG_DATA_HOME: path.join(home, ".local/share"), XDG_STATE_HOME: path.join(home, ".local/state"), XDG_CACHE_HOME: path.join(home, ".cache"), + BUN_RUNTIME_TRANSPILER_CACHE_PATH: sharedTranspilerCache, OPENCODE_CONFIG_CONTENT: configJson, OPENCODE_DISABLE_PROJECT_CONFIG: "1", OPENCODE_PURE: "1", @@ -203,47 +232,57 @@ export function withCliFixture( const env = isolatedEnv(home, configJson) const spawn = Effect.fn("opencode.spawn")(function* (args: string[], opts?: SpawnOpts) { - const start = Date.now() - const timeoutMs = opts?.timeoutMs ?? 30_000 - // stdin: "ignore" so the child doesn't see a piped stdin and block - // on `Bun.stdin.text()` (see src/cli/cmd/run.ts — non-TTY stdin is - // consumed as the prompt). The old Process.run wrapper defaulted to - // ignore; ChildProcess.make defaults to pipe, so we set it explicitly. - const command = ChildProcess.make("bun", ["run", "--conditions=browser", cliEntry, ...args], { - cwd: home, - env: { ...env, ...opts?.env }, - extendEnv: true, - stdin: "ignore", - }) - // Pass timeout to appProc.run rather than wrapping with - // Effect.timeoutOrElse externally: AppProcess.run is itself scoped, so - // its built-in timeout triggers the acquireRelease kill finalizer - // inside cross-spawn-spawner *before* surfacing the AppProcessError — - // guaranteeing the child is dead by the time the test continues. - // External timeoutOrElse interrupts the run fiber but races the - // scope close, which can leak the child past the test boundary. - // - // Catch AppProcessError (timeout OR spawn failure) and synthesize a - // non-zero result so the test sees it via the usual `expectExit` - // path rather than as an unhandled Effect failure. - const result = yield* appProc.run(command, { timeout: Duration.millis(timeoutMs) }).pipe( - Effect.catchTag("AppProcessError", (err) => - Effect.succeed({ - command: err.command, - exitCode: err.exitCode ?? -1, - stdout: Buffer.alloc(0), - stderr: Buffer.from((err.stderr ?? String(err.cause ?? err.message)) + "\n"), - stdoutTruncated: false, - stderrTruncated: false, - } satisfies AppProcess.RunResult), - ), + return yield* spawnGate.withPermits(1)( + Effect.gen(function* () { + const start = Date.now() + const timeoutMs = opts?.timeoutMs ?? 30_000 + // stdin: "ignore" so the child doesn't see a piped stdin and block + // on `Bun.stdin.text()` (see src/cli/cmd/run.ts — non-TTY stdin is + // consumed as the prompt). The old Process.run wrapper defaulted to + // ignore; ChildProcess.make defaults to pipe, so we set it explicitly. + const command = ChildProcess.make("bun", ["run", "--conditions=browser", cliEntry, ...args], { + cwd: home, + env: { ...env, ...opts?.env }, + extendEnv: true, + stdin: "ignore", + }) + // Pass timeout to appProc.run rather than wrapping with + // Effect.timeoutOrElse externally: AppProcess.run is itself scoped, so + // its built-in timeout triggers the acquireRelease kill finalizer + // inside cross-spawn-spawner *before* surfacing the AppProcessError — + // guaranteeing the child is dead by the time the test continues. + // External timeoutOrElse interrupts the run fiber but races the + // scope close, which can leak the child past the test boundary. + // + // Catch AppProcessError (timeout OR spawn failure) and synthesize a + // non-zero result so the test sees it via the usual `expectExit` + // path rather than as an unhandled Effect failure. AppProcess drops + // the child's collected stderr on timeout, so annotate the synthetic + // message with timing to make CI failures diagnosable. + const result = yield* appProc.run(command, { timeout: Duration.millis(timeoutMs) }).pipe( + Effect.catchTag("AppProcessError", (err) => + Effect.succeed({ + command: err.command, + exitCode: err.exitCode ?? -1, + stdout: Buffer.alloc(0), + stderr: Buffer.from( + (err.stderr ?? + `${String(err.cause ?? err.message)} after ${Date.now() - start}ms (timeout ${timeoutMs}ms)`) + + "\n", + ), + stdoutTruncated: false, + stderrTruncated: false, + } satisfies AppProcess.RunResult), + ), + ) + return { + exitCode: result.exitCode, + stdout: normalizeLines(result.stdout.toString()), + stderr: normalizeLines(result.stderr.toString()), + durationMs: Date.now() - start, + } + }), ) - return { - exitCode: result.exitCode, - stdout: normalizeLines(result.stdout.toString()), - stderr: normalizeLines(result.stderr.toString()), - durationMs: Date.now() - start, - } }) const runArgs = (message: string, opts?: RunOpts) => { diff --git a/packages/opencode/test/plugin/cwd.test.ts b/packages/opencode/test/plugin/cwd.test.ts index 6038eb7833ef..30b59caa01a1 100644 --- a/packages/opencode/test/plugin/cwd.test.ts +++ b/packages/opencode/test/plugin/cwd.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test" import os from "os" import path from "path" -import { getCwd, setCwd, resetCwd, CwdEvent } from "../../plugin/shell-mode/cwd" +import { getCwd, setCwd, resetCwd, parseCwdSentinelPayload, CwdEvent } from "../../plugin/shell-mode/cwd" import { GlobalBus, type GlobalEvent } from "../../src/bus/global" import { provideTestInstance, disposeAllInstances, tmpdir } from "../fixture/fixture" @@ -47,11 +47,13 @@ describe("setCwd / getCwd — unit", () => { }) }) + // Expected values go through path.resolve so they match the platform's + // path syntax (win32 resolves "/workspace" + "subdir" to "D:\workspace\subdir"). test("relative path resolves against current cwd", async () => { await withInstance(async () => { setCwd("/workspace") setCwd("subdir") - expect(getCwd()).toBe("/workspace/subdir") + expect(getCwd()).toBe(path.resolve("/workspace", "subdir")) }) }) @@ -60,7 +62,7 @@ describe("setCwd / getCwd — unit", () => { setCwd("/workspace") setCwd("a") setCwd("b") - expect(getCwd()).toBe("/workspace/a/b") + expect(getCwd()).toBe(path.resolve("/workspace", "a", "b")) }) }) @@ -68,7 +70,7 @@ describe("setCwd / getCwd — unit", () => { await withInstance(async () => { setCwd("/workspace/a/b") setCwd("..") - expect(getCwd()).toBe("/workspace/a") + expect(getCwd()).toBe(path.resolve("/workspace/a/b", "..")) }) }) }) @@ -105,7 +107,7 @@ describe("tool path resolution — LAC-742 regression", () => { await withInstance(async () => { setCwd("/tmp") expect(getCwd()).toBe("/tmp") - expect(path.resolve(getCwd(), "relative-file.txt")).toBe("/tmp/relative-file.txt") + expect(path.resolve(getCwd(), "relative-file.txt")).toBe(path.resolve("/tmp", "relative-file.txt")) }) }) @@ -123,6 +125,45 @@ describe("tool path resolution — LAC-742 regression", () => { }) }) +// LAC-2693 regression: on Windows, a shell that does not understand the +// wrapper template echoes it literally (cmd.exe printing "$(pwd -P ...)"). +// That text must never reach setCwd — the poisoned cwd is process-wide and +// makes every later spawn fail its cwd access check. +describe("parseCwdSentinelPayload — LAC-2693 regression", () => { + test("parses exit code and posix cwd", () => { + expect(parseCwdSentinelPayload("0:/home/user/project")).toEqual({ exitCode: 0, cwd: "/home/user/project" }) + }) + + test("parses exit code and windows drive cwd", () => { + expect(parseCwdSentinelPayload("1:D:\\a\\lash\\lash")).toEqual({ exitCode: 1, cwd: "D:\\a\\lash\\lash" }) + }) + + test("accepts legacy payload without exit code", () => { + expect(parseCwdSentinelPayload("/tmp")).toEqual({ exitCode: null, cwd: "/tmp" }) + }) + + test("treats non-numeric prefix with drive colon as cwd", () => { + expect(parseCwdSentinelPayload("D:\\foo")).toEqual({ exitCode: null, cwd: "D:\\foo" }) + }) + + test("rejects unexpanded posix substitution echoed by cmd.exe", () => { + expect(parseCwdSentinelPayload("$__oc_exit:$(pwd -P 2>/dev/null || pwd)")).toEqual({ + exitCode: null, + cwd: null, + }) + expect(parseCwdSentinelPayload("0:$(pwd -P")).toEqual({ exitCode: 0, cwd: null }) + }) + + test("rejects unexpanded cmd variables echoed by a posix shell", () => { + expect(parseCwdSentinelPayload("%ERRORLEVEL%:%CD%")).toEqual({ exitCode: null, cwd: null }) + }) + + test("handles empty and cwd-less payloads", () => { + expect(parseCwdSentinelPayload("")).toEqual({ exitCode: null, cwd: null }) + expect(parseCwdSentinelPayload("0:")).toEqual({ exitCode: 0, cwd: null }) + }) +}) + // LAC-742 regression: CwdEvent.Updated bus event describe("CwdEvent.Updated — LAC-742 regression", () => { test("published when cwd changes", async () => { diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 16b4789b0725..4ee1a5689038 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -31,6 +31,18 @@ afterAll(async () => { await rm(30) }) +// Seed the ripgrep binary from the user's real cache before redirecting +// XDG_CACHE_HOME below — the per-PID redirect otherwise starts cold, so every +// test run re-downloads rg mid-suite; on windows CI that download has blown +// test timeouts (LAC-2693). The CI workflow caches the real bin dir. +{ + const rg = `rg${process.platform === "win32" ? ".exe" : ""}` + const realCache = process.env["XDG_CACHE_HOME"] ?? path.join(os.homedir(), ".cache") + const testBin = path.join(dir, "cache", "opencode", "bin") + await fs.mkdir(testBin, { recursive: true }) + await fs.copyFile(path.join(realCache, "opencode", "bin", rg), path.join(testBin, rg)).catch(() => {}) +} + process.env["XDG_DATA_HOME"] = path.join(dir, "share") process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config") diff --git a/packages/opencode/test/project/instance-bootstrap.test.ts b/packages/opencode/test/project/instance-bootstrap.test.ts index 5009d6b500b0..e3f86a14f103 100644 --- a/packages/opencode/test/project/instance-bootstrap.test.ts +++ b/packages/opencode/test/project/instance-bootstrap.test.ts @@ -25,6 +25,12 @@ afterEach(async () => { await disposeAllInstances() }) +// bootstrapFixture configures a plugin, so plugin.init() waits for the +// background `@opencode-ai/plugin` npm install (Config.waitForDependencies). +// That network round-trip can exceed the suite-wide 30s timeout on loaded +// GitHub-hosted windows runners. +const PLUGIN_INSTALL = { timeout: 120_000 } + const bootstrapFixture = Effect.gen(function* () { const dir = yield* tmpdirScoped({ git: true }) const marker = path.join(dir, "config-hook-fired") @@ -71,6 +77,7 @@ it.live("InstanceStore.provide runs InstanceBootstrap before effect", () => expect(existsSync(tmp.marker)).toBe(true) }), + PLUGIN_INSTALL, ) it.live("CLI bootstrap runs InstanceBootstrap before callback", () => @@ -81,6 +88,7 @@ it.live("CLI bootstrap runs InstanceBootstrap before callback", () => expect(existsSync(tmp.marker)).toBe(true) }), + PLUGIN_INSTALL, ) it.live("CLI bootstrap disposes the instance when the callback rejects", () => @@ -96,6 +104,7 @@ it.live("CLI bootstrap disposes the instance when the callback rejects", () => if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toMatchObject({ message: "boom" }) yield* Fiber.join(disposed) }), + PLUGIN_INSTALL, ) it.live("InstanceStore.reload runs InstanceBootstrap", () => @@ -107,4 +116,5 @@ it.live("InstanceStore.reload runs InstanceBootstrap", () => expect(existsSync(tmp.marker)).toBe(true) }), + PLUGIN_INSTALL, ) diff --git a/packages/opencode/test/server/httpapi-file.test.ts b/packages/opencode/test/server/httpapi-file.test.ts index ed882ade4652..2a9ce2a769fa 100644 --- a/packages/opencode/test/server/httpapi-file.test.ts +++ b/packages/opencode/test/server/httpapi-file.test.ts @@ -52,32 +52,40 @@ describe("file HttpApi", () => { expect(await status.json()).toEqual([]) }) - test("serves search endpoints", async () => { - await using tmp = await tmpdir({ git: true }) - await Bun.write(path.join(tmp.path, "hello.txt"), "needle") + test( + "serves search endpoints", + async () => { + await using tmp = await tmpdir({ git: true }) + await Bun.write(path.join(tmp.path, "hello.txt"), "needle") - const [text, symbols] = await Promise.all([ - request(FilePaths.findText, tmp.path, { pattern: "needle" }), - request(FilePaths.findSymbol, tmp.path, { query: "hello" }), - ]) - const files = await Effect.runPromise( - pollWithTimeout( - Effect.promise(async () => { - const response = await request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }) - const body = await response.json() - return body.includes("hello.txt") ? { response, body } : undefined - }), - "file search index was not ready", - ), - ) + const [text, symbols] = await Promise.all([ + request(FilePaths.findText, tmp.path, { pattern: "needle" }), + request(FilePaths.findSymbol, tmp.path, { query: "hello" }), + ]) + const files = await Effect.runPromise( + pollWithTimeout( + Effect.promise(async () => { + const response = await request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }) + const body = await response.json() + return body.includes("hello.txt") ? { response, body } : undefined + }), + "file search index was not ready", + // Index build regularly exceeds the 5s default on windows runners (LAC-2693) + "60 seconds", + ), + ) - expect(text.status).toBe(200) - expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 })) + expect(text.status).toBe(200) + expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 })) - expect(files.response.status).toBe(200) - expect(files.body).toContain("hello.txt") + expect(files.response.status).toBe(200) + expect(files.body).toContain("hello.txt") - expect(symbols.status).toBe(200) - expect(await symbols.json()).toEqual([]) - }) + expect(symbols.status).toBe(200) + expect(await symbols.json()).toEqual([]) + }, + // Must exceed the poll window above — the suite-wide --timeout 30000 would + // otherwise fire before the index-ready poll can complete (LAC-2693) + 90_000, + ) }) diff --git a/packages/opencode/test/server/httpapi-sdk.test.ts b/packages/opencode/test/server/httpapi-sdk.test.ts index 63cc3edb2c18..dca3a6578920 100644 --- a/packages/opencode/test/server/httpapi-sdk.test.ts +++ b/packages/opencode/test/server/httpapi-sdk.test.ts @@ -394,6 +394,8 @@ describe("HttpApi SDK", () => { Effect.map((result) => (result.data?.data.length ? result : undefined)), ), "SDK file search index was not ready", + // Index build regularly exceeds the 5s default on windows runners (LAC-2693) + "30 seconds", ) const url = new URL(request!.url) diff --git a/packages/opencode/test/session/prompt-missing-file.test.ts b/packages/opencode/test/session/prompt-missing-file.test.ts index d3b08bf31fa1..9feaba8b08b8 100644 --- a/packages/opencode/test/session/prompt-missing-file.test.ts +++ b/packages/opencode/test/session/prompt-missing-file.test.ts @@ -2,19 +2,19 @@ import path from "path" import { describe, expect, test } from "bun:test" import { Session } from "../../src/session/session" import { SessionPrompt } from "../../src/session/prompt" -import { AppRuntime } from "../../src/effect/app-runtime" +import { runTestApp } from "../fixture/app" import { provideTestInstance, tmpdir } from "../fixture/fixture" function sessionCreate(input?: Session.CreateInput) { - return AppRuntime.runPromise(Session.Service.use((svc) => svc.create(input))) + return runTestApp(Session.Service.use((svc) => svc.create(input))) } function sessionRemove(id: Session.Info["id"]) { - return AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(id))) + return runTestApp(Session.Service.use((svc) => svc.remove(id))) } function sessionPrompt(input: SessionPrompt.PromptInput) { - return AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt(input))) + return runTestApp(SessionPrompt.Service.use((svc) => svc.prompt(input))) } describe("session.prompt missing file", () => { diff --git a/packages/opencode/test/session/prompt-special-chars.test.ts b/packages/opencode/test/session/prompt-special-chars.test.ts index b695af6ef46e..c553b9d50eb4 100644 --- a/packages/opencode/test/session/prompt-special-chars.test.ts +++ b/packages/opencode/test/session/prompt-special-chars.test.ts @@ -3,23 +3,23 @@ import { describe, expect, test } from "bun:test" import { fileURLToPath } from "url" import { Session } from "../../src/session/session" import { SessionPrompt } from "../../src/session/prompt" -import { AppRuntime } from "../../src/effect/app-runtime" +import { runTestApp } from "../fixture/app" import { provideTestInstance, tmpdir } from "../fixture/fixture" function sessionCreate(input?: Session.CreateInput) { - return AppRuntime.runPromise(Session.Service.use((svc) => svc.create(input))) + return runTestApp(Session.Service.use((svc) => svc.create(input))) } function sessionRemove(id: Session.Info["id"]) { - return AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(id))) + return runTestApp(Session.Service.use((svc) => svc.remove(id))) } function resolvePromptParts(template: string) { - return AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.resolvePromptParts(template))) + return runTestApp(SessionPrompt.Service.use((svc) => svc.resolvePromptParts(template))) } function sessionPrompt(input: SessionPrompt.PromptInput) { - return AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt(input))) + return runTestApp(SessionPrompt.Service.use((svc) => svc.prompt(input))) } describe("session.prompt special characters", () => { diff --git a/packages/opencode/test/session/prompt-variant.test.ts b/packages/opencode/test/session/prompt-variant.test.ts index e0790ce45ae4..f1f260229288 100644 --- a/packages/opencode/test/session/prompt-variant.test.ts +++ b/packages/opencode/test/session/prompt-variant.test.ts @@ -3,19 +3,19 @@ import { Session } from "../../src/session/session" import { SessionPrompt } from "../../src/session/prompt" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" -import { AppRuntime } from "../../src/effect/app-runtime" +import { runTestApp } from "../fixture/app" import { provideTestInstance, tmpdir } from "../fixture/fixture" function sessionCreate(input?: Session.CreateInput) { - return AppRuntime.runPromise(Session.Service.use((svc) => svc.create(input))) + return runTestApp(Session.Service.use((svc) => svc.create(input))) } function sessionRemove(id: Session.Info["id"]) { - return AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(id))) + return runTestApp(Session.Service.use((svc) => svc.remove(id))) } function sessionPrompt(input: SessionPrompt.PromptInput) { - return AppRuntime.runPromise(SessionPrompt.Service.use((svc) => svc.prompt(input))) + return runTestApp(SessionPrompt.Service.use((svc) => svc.prompt(input))) } describe("session.prompt agent variant", () => { diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index bb98a867faee..9a9164a2bd27 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -1707,7 +1707,8 @@ it.instance( expect(yield* llm.calls).toBe(1) }), { git: true }, - 10_000, + // git-fixture boot + shell spawn exceed 10s on windows runners (LAC-2693) + 30_000, ) it.instance( @@ -1746,7 +1747,8 @@ it.instance( expect(yield* llm.calls).toBe(1) }), { git: true }, - 10_000, + // git-fixture boot + shell spawn exceed 10s on windows runners (LAC-2693) + 30_000, ) unix( diff --git a/packages/opencode/test/tool/external-directory.test.ts b/packages/opencode/test/tool/external-directory.test.ts index 69a48bad7a93..547f33f24bf6 100644 --- a/packages/opencode/test/tool/external-directory.test.ts +++ b/packages/opencode/test/tool/external-directory.test.ts @@ -115,10 +115,10 @@ describe("tool.assertExternalDirectory", () => { yield* Effect.promise(() => Bun.write(path.join(outerTmp, "outside.txt"), "x")) const target = path.join(outerTmp, "outside.txt") - const alt = target - .replace(/^[A-Za-z]:/, "") - .replaceAll("\\", "/") - .toLowerCase() + // Keep the drive letter: a drive-less absolute path resolves against + // the current drive, which differs from TEMP's drive on GitHub-hosted + // runners (repo on D:, TEMP on C:) — LAC-2693. + const alt = target.replaceAll("\\", "/").toLowerCase() yield* assertExternalDirectoryEffect(ctx, alt) diff --git a/packages/opencode/test/tool/read.test.ts b/packages/opencode/test/tool/read.test.ts index 67205f56e384..058f6b081f5d 100644 --- a/packages/opencode/test/tool/read.test.ts +++ b/packages/opencode/test/tool/read.test.ts @@ -189,10 +189,10 @@ describe("tool.read external_directory permission", () => { const { items, next } = asks() const target = path.join(dir, "test.txt") - const alt = target - .replace(/^[A-Za-z]:/, "") - .replaceAll("\\", "/") - .toLowerCase() + // Keep the drive letter: a drive-less absolute path resolves against + // the current drive, which differs from TEMP's drive on GitHub-hosted + // runners (repo on D:, TEMP on C:) — LAC-2693. + const alt = target.replaceAll("\\", "/").toLowerCase() yield* exec(dir, { filePath: alt }, next) const read = items.find((item) => item.permission === "read") diff --git a/turbo.json b/turbo.json index 5e93640b1fad..9d0696ea4b3e 100644 --- a/turbo.json +++ b/turbo.json @@ -15,7 +15,8 @@ }, "@opencode-ai/core#test": { "dependsOn": ["^build"], - "outputs": [] + "outputs": [], + "passThroughEnv": ["*"] }, "@opencode-ai/app#test": { "dependsOn": ["^build"],