diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a194dadfb2f7..96cc78f8def6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -75,8 +75,17 @@ jobs: "$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 + # opencode#test alone takes ~20 minutes on GitHub-hosted windows runners + timeout-minutes: 35 run: GITHUB_ACTIONS=false bun turbo test env: OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} 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/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/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/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 932fd7cccaa0..ce81124e0642 100644 --- a/packages/opencode/plugin/shell-mode/cwd.ts +++ b/packages/opencode/plugin/shell-mode/cwd.ts @@ -87,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 21577ae286bb..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" @@ -627,15 +627,9 @@ 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) - } + const parsed = parseCwdSentinelPayload(payload) + if (parsed.exitCode !== null) commandExitCode = parsed.exitCode + if (parsed.cwd) setCwd(parsed.cwd) cleanOutput = stripSentinel(output) } 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/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.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")