Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,12 @@ jobs:
path: ~/.cache/opencode/bin
key: opencode-bin-${{ runner.os }}-${{ hashFiles('packages/core/src/ripgrep/binary.ts') }}
- name: Run unit tests
# opencode#test alone takes ~20 minutes on GitHub-hosted windows runners
# 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
run: GITHUB_ACTIONS=false bun turbo test --log-order=stream
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}

Expand Down
20 changes: 11 additions & 9 deletions packages/core/test/lib/effect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,24 +19,26 @@ const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer
return yield* exit
}).pipe(Effect.runPromise)

const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>) => {
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>, defaults?: number | TestOptions) => {
const options = (opts?: number | TestOptions) => opts ?? defaults

const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, testLayer), opts)
test(name, () => run(value, testLayer), options(opts))

effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, testLayer), opts)
test.only(name, () => run(value, testLayer), options(opts))

effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, testLayer), opts)
test.skip(name, () => run(value, testLayer), options(opts))

const live = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test(name, () => run(value, liveLayer), opts)
test(name, () => run(value, liveLayer), options(opts))

live.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.only(name, () => run(value, liveLayer), opts)
test.only(name, () => run(value, liveLayer), options(opts))

live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
test.skip(name, () => run(value, liveLayer), opts)
test.skip(name, () => run(value, liveLayer), options(opts))

return { effect, live }
}
Expand All @@ -49,5 +51,5 @@ const liveEnv = TestConsole.layer

export const it = make(testEnv, liveEnv)

export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
export const testEffect = <R, E>(layer: Layer.Layer<R, E>, defaults?: number | TestOptions) =>
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv), defaults)
4 changes: 4 additions & 0 deletions packages/core/test/project-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion packages/core/test/repository-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () =>
Expand Down
13 changes: 9 additions & 4 deletions packages/core/test/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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) =>
Expand All @@ -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) =>
Expand Down Expand Up @@ -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) =>
Expand Down
16 changes: 12 additions & 4 deletions packages/opencode/src/skill/discovery.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -34,6 +34,14 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | Htt
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills")

// Windows: Defender/indexer briefly holds handles on freshly written
// directories, so a single rename can fail with EPERM even though the
// swap is valid — retry like graceful-fs does.
const rename = (from: string, to: string) =>
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

Expand Down Expand Up @@ -106,11 +114,11 @@ export const layer: Layer.Layer<Service, never, FSUtil.Service | Path.Path | Htt
yield* Effect.uninterruptible(
Effect.gen(function* () {
const cached = yield* fs.exists(root).pipe(Effect.orDie)
if (cached) yield* fs.rename(root, backup)
yield* fs.rename(staging, root).pipe(
if (cached) yield* rename(root, backup)
yield* rename(staging, root).pipe(
Effect.catch((error) =>
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)
}),
),
Expand Down
3 changes: 2 additions & 1 deletion turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
},
"@opencode-ai/core#test": {
"dependsOn": ["^build"],
"outputs": []
"outputs": [],
"passThroughEnv": ["*"]
},
"@opencode-ai/app#test": {
"dependsOn": ["^build"],
Expand Down
Loading