diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 83cf9b596..50b3ad979 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -118,6 +118,7 @@ export namespace Ripgrep { "x64-darwin": { platform: "x86_64-apple-darwin", extension: "tar.gz" }, "x64-linux": { platform: "x86_64-unknown-linux-musl", extension: "tar.gz" }, "arm64-win32": { platform: "aarch64-pc-windows-msvc", extension: "zip" }, + "ia32-win32": { platform: "i686-pc-windows-msvc", extension: "zip" }, "x64-win32": { platform: "x86_64-pc-windows-msvc", extension: "zip" }, } as const @@ -158,7 +159,7 @@ export namespace Ripgrep { const config = PLATFORM[platformKey] if (!config) throw new UnsupportedPlatformError({ platform: platformKey }) - const version = "14.1.1" + const version = "15.1.0" const filename = `ripgrep-${version}-${config.platform}.${config.extension}` const url = `https://github.com/BurntSushi/ripgrep/releases/download/${version}/${filename}` diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts index 3568ff20e..7abf7ab60 100644 --- a/packages/opencode/src/npm/index.ts +++ b/packages/opencode/src/npm/index.ts @@ -7,7 +7,6 @@ import path from "path" import { readdir, rm } from "fs/promises" import { Filesystem } from "@/util/filesystem" import { Flock } from "@/util/flock" -import { Arborist } from "@npmcli/arborist" export namespace Npm { const log = Log.create({ service: "npm" }) @@ -29,6 +28,16 @@ export namespace Npm { return path.join(Global.Path.cache, "packages", sanitize(pkg)) } + async function loadArborist() { + if (process.platform === "win32") { + // Bun on Windows does not support the UV_FS_O_FILEMAP flag used by tar + // for small files. tar snapshots this env var during module init, so set + // it immediately before Arborist can import tar. + process.env.__FAKE_PLATFORM__ = "linux" + } + return import("@npmcli/arborist") + } + function resolveEntryPoint(name: string, dir: string) { let entrypoint: string | undefined try { @@ -68,6 +77,7 @@ export namespace Npm { pkg, }) + const { Arborist } = await loadArborist() const arborist = new Arborist({ path: dir, binLinks: true, @@ -108,6 +118,7 @@ export namespace Npm { log.info("checking dependencies", { dir }) const reify = async () => { + const { Arborist } = await loadArborist() const arb = new Arborist({ path: dir, binLinks: true, diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index f4ec0af83..063f55476 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -358,14 +358,22 @@ export namespace Worktree { } function cleanDirectory(target: string) { - return Effect.promise(() => - import("fs/promises") - .then((fsp) => fsp.rm(target, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })) - .catch((error) => { + // Match test preload cleanup: Windows can keep git and sqlite handles + // alive briefly after process teardown, so EBUSY needs a longer budget. + const maxRetries = process.platform === "win32" ? 30 : 5 + const remove = (remaining: number): Effect.Effect => + fs.remove(target, { recursive: true, force: true }).pipe( + Effect.catch((error) => { + if (remaining > 0) { + return Effect.sleep(100).pipe(Effect.flatMap(() => remove(remaining - 1))) + } const message = errorMessage(error) - throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) + return Effect.sync(() => { + throw new RemoveFailedError({ message: message || "Failed to remove git worktree directory" }) + }) }), - ) + ) + return remove(maxRetries) } const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) { diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 5990635aa..9d2a75d72 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -169,7 +169,12 @@ describe("cross-spawn spawner", () => { 'process.stderr.write("stderr\\n", done)', ].join("\n"), ) - const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)]) + // Drain both pipes in parallel so Windows CI does not stall when stderr + // waits behind stdout decoding. + const [stdout, stderr] = yield* Effect.all( + [decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], + { concurrency: 2 }, + ) expect(stdout).toBe("stdout") expect(stderr).toBe("stderr") }), diff --git a/packages/opencode/test/global/runtime-namespace.test.ts b/packages/opencode/test/global/runtime-namespace.test.ts index 1bd2ac051..24f9ef121 100644 --- a/packages/opencode/test/global/runtime-namespace.test.ts +++ b/packages/opencode/test/global/runtime-namespace.test.ts @@ -1,14 +1,33 @@ import { describe, expect, test } from "bun:test" import path from "path" +import { tmpdir } from "../fixture/fixture" -function readGlobalPath(namespace?: string) { +type Roots = { + data: string + cache: string + config: string + state: string +} + +function rootsFor(dir: string): Roots { + return { + data: path.join(dir, "share"), + cache: path.join(dir, "cache"), + config: path.join(dir, "config"), + state: path.join(dir, "state"), + } +} + +function readGlobalPath(roots: Roots, namespace?: string) { const script = ` - process.env.XDG_DATA_HOME = "/tmp/pawwork-runtime-test/share" - process.env.XDG_CACHE_HOME = "/tmp/pawwork-runtime-test/cache" - process.env.XDG_CONFIG_HOME = "/tmp/pawwork-runtime-test/config" - process.env.XDG_STATE_HOME = "/tmp/pawwork-runtime-test/state" + process.env.XDG_DATA_HOME = ${JSON.stringify(roots.data)} + process.env.XDG_CACHE_HOME = ${JSON.stringify(roots.cache)} + process.env.XDG_CONFIG_HOME = ${JSON.stringify(roots.config)} + process.env.XDG_STATE_HOME = ${JSON.stringify(roots.state)} if (${JSON.stringify(namespace)} !== undefined) { process.env.PAWWORK_RUNTIME_NAMESPACE = ${JSON.stringify(namespace)} + } else { + delete process.env.PAWWORK_RUNTIME_NAMESPACE } const { Global } = await import("./src/global/index.ts") console.log(JSON.stringify(Global.Path)) @@ -25,24 +44,29 @@ function readGlobalPath(namespace?: string) { return JSON.parse(Buffer.from(result.stdout).toString()) as Record } +function expectCoreRoots(paths: Record, roots: Roots, namespace: string) { + expect(paths.data).toBe(path.join(roots.data, namespace)) + expect(paths.cache).toBe(path.join(roots.cache, namespace)) + expect(paths.config).toBe(path.join(roots.config, namespace)) + expect(paths.state).toBe(path.join(roots.state, namespace)) +} + describe("Global runtime namespace", () => { - test("defaults to OpenCode namespace outside PawWork desktop", () => { - const paths = readGlobalPath() + test("defaults to OpenCode namespace outside PawWork desktop", async () => { + await using tmp = await tmpdir() + const roots = rootsFor(tmp.path) + const paths = readGlobalPath(roots) - expect(paths.data).toBe("/tmp/pawwork-runtime-test/share/opencode") - expect(paths.cache).toBe("/tmp/pawwork-runtime-test/cache/opencode") - expect(paths.config).toBe("/tmp/pawwork-runtime-test/config/opencode") - expect(paths.state).toBe("/tmp/pawwork-runtime-test/state/opencode") + expectCoreRoots(paths, roots, "opencode") }) - test("uses PawWork namespace when enabled", () => { - const paths = readGlobalPath("pawwork") + test("uses PawWork namespace when enabled", async () => { + await using tmp = await tmpdir() + const roots = rootsFor(tmp.path) + const paths = readGlobalPath(roots, "pawwork") - expect(paths.data).toBe("/tmp/pawwork-runtime-test/share/pawwork") - expect(paths.cache).toBe("/tmp/pawwork-runtime-test/cache/pawwork") - expect(paths.config).toBe("/tmp/pawwork-runtime-test/config/pawwork") - expect(paths.state).toBe("/tmp/pawwork-runtime-test/state/pawwork") - expect(paths.bin).toBe("/tmp/pawwork-runtime-test/cache/pawwork/bin") - expect(paths.log).toBe("/tmp/pawwork-runtime-test/share/pawwork/log") + expectCoreRoots(paths, roots, "pawwork") + expect(paths.bin).toBe(path.join(roots.cache, "pawwork", "bin")) + expect(paths.log).toBe(path.join(roots.data, "pawwork", "log")) }) }) diff --git a/packages/opencode/test/plugin/workspace-adaptor.test.ts b/packages/opencode/test/plugin/workspace-adaptor.test.ts index 8be3ed0e9..5c074fc55 100644 --- a/packages/opencode/test/plugin/workspace-adaptor.test.ts +++ b/packages/opencode/test/plugin/workspace-adaptor.test.ts @@ -45,6 +45,28 @@ async function waitFor(fn: () => boolean, timeout = 5_000) { throw new Error("timed out waiting for workspace status") } +async function waitForCounter(file: string, min: number) { + const read = async () => { + const text = await Bun.file(file) + .text() + .catch((error) => { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") return "0" + throw error + }) + const value = Number(text) + if (!Number.isFinite(value)) throw new Error(`invalid retry counter value: ${text}`) + return value + } + const end = Date.now() + 5_000 + let value = 0 + while (Date.now() < end) { + value = await read() + if (value > min) return value + await wait(50) + } + throw new Error(`timed out waiting for counter ${file} to exceed ${min}; last value=${value}`) +} + async function pluginProject() { return tmpdir({ git: true, @@ -362,13 +384,11 @@ describe("plugin.workspace", () => { await Instance.disposeAll() await Workspace.get(workspace.id) - await wait(100) - const first = Number(await Bun.file(tmp.extra.counter).text()) + const first = await waitForCounter(tmp.extra.counter, 0) expect(first).toBeGreaterThan(0) await Workspace.get(workspace.id) - await wait(100) - const second = Number(await Bun.file(tmp.extra.counter).text()) + const second = await waitForCounter(tmp.extra.counter, first) expect(second).toBeGreaterThan(first) const status = Workspace.status().find((item) => item.workspaceID === workspace.id) diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 66d69f32c..5cffbee4c 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1236,6 +1236,8 @@ unix( 30_000, ) +const shellQueueTimeout = process.platform === "win32" ? 10_000 : 3_000 + it.live( "loop waits while shell runs and starts after shell exits", () => @@ -1271,7 +1273,7 @@ it.live( }), { git: true, config: providerCfg }, ), - 3_000, + shellQueueTimeout, ) it.live( @@ -1311,7 +1313,7 @@ it.live( }), { git: true, config: providerCfg }, ), - 3_000, + shellQueueTimeout, ) unix(