The tmpdir function in fixture/fixture.ts creates temporary directories for tests with automatic cleanup.
import { tmpdir } from "./fixture/fixture"
test("example", async () => {
await using tmp = await tmpdir()
// tmp.path is the temp directory path
// automatically cleaned up when test ends
})
git?: boolean - Initialize a git repo with a root commitconfig?: Partial<Config.Info> - Write an kirincode.json config fileinit?: (dir: string) => Promise<T> - Custom setup function, returns value accessible as tmp.extradispose?: (dir: string) => Promise<T> - Custom cleanup functionGit repository:
await using tmp = await tmpdir({ git: true })
With config file:
await using tmp = await tmpdir({
config: { model: "test/model", username: "testuser" },
})
Custom initialization (returns extra data):
await using tmp = await tmpdir<string>({
init: async (dir) => {
await Bun.write(path.join(dir, "file.txt"), "content")
return "extra data"
},
})
// Access extra data via tmp.extra
console.log(tmp.extra) // "extra data"
With cleanup:
await using tmp = await tmpdir({
init: async (dir) => {
const specialDir = path.join(dir, "special")
await fs.mkdir(specialDir)
return specialDir
},
dispose: async (dir) => {
// Custom cleanup logic
await fs.rm(path.join(dir, "special"), { recursive: true })
},
})
path: string - Absolute path to the temp directory (realpath resolved)extra: T - Value returned by the init function[Symbol.asyncDispose] - Enables automatic cleanup via await usingopencode-test-await using for automatic cleanup when the variable goes out of scopeUse testEffect(...) from test/lib/effect.ts for tests that exercise Effect services or Effect-based workflows.
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
describe("my service", () => {
it.instance("does the thing", () =>
Effect.gen(function* () {
const svc = yield* MyService.Service
const out = yield* svc.run()
expect(out).toEqual("ok")
}),
)
})
it.effect vs it.liveit.effect(...) when the test should run with TestClock and TestConsole.it.live(...) when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior.it.instance(...) for live Effect tests that need a scoped temporary directory and instance context.it.live(...).Prefer the Effect-aware helpers from fixture/fixture.ts instead of building a manual runtime in each test.
tmpdirScoped(options?) creates a scoped temp directory and cleans it up when the Effect scope closes.provideInstance(dir)(effect) is the low-level helper. It does not create a directory; it runs an Effect with InstanceRef provided for dir.provideTmpdirInstance((dir) => effect, options?) is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.provideTmpdirServer((input) => effect, options?) does the same, but also provides the test LLM server.Use it.instance(...) by default when a test only needs one temp instance. Yield TestInstance from fixture/fixture.ts when the test needs the temp directory path:
import { TestInstance } from "../fixture/fixture"
it.instance("uses the temp directory", () =>
Effect.gen(function* () {
const test = yield* TestInstance
expect(test.directory).toContain("opencode-test-")
}),
)
Use provideTmpdirInstance(...) or tmpdirScoped() plus provideInstance(...) when a test needs multiple directories, custom setup before binding, needs to switch instance context within one test, or explicitly tests instance disposal/reload lifetime.
const it = testEffect(...) near the top of the file.Effect.gen(function* () { ... }).yield* MyService.Service or yield* MyTool.ManagedRuntime, attach(...), or ad hoc run(...) wrappers when testEffect(...) already provides the runtime.it.instance(...) over manual Instance.provide(...) inside Promise-style tests.When a test only needs to override one or two methods of a service, prefer Layer.mock over a hand-rolled Layer.succeed(Service, Service.of({ ... })). Layer.mock lets you supply just the methods that matter — anything else throws an UnimplementedError defect if the test accidentally calls it, which is exactly the signal you want.
import { Effect, Layer } from "effect"
import { Account } from "@/account/account"
const failingAccountLayer = Layer.mock(Account.Service, {
orgsByAccount: () => Effect.fail(new Account.AccountServiceError({ message: "simulated upstream failure" })),
})
This is much shorter than stubbing every method with Effect.void / Effect.succeed(...) placeholders, and it keeps the test focused on the behaviour under test.
Using Effect.sleep(N) or setTimeout as a "wait for the forked fiber to be ready" hack races the scheduler. The forked fiber may not have reached the synchronization point within N ms on a slow CI host, and the test fails intermittently. See PR #27622 for a concrete flake that fell out of this exact pattern.
Wait on a published readiness signal, not wall-clock time. Available affordances:
pollWithTimeout(effect, message, duration?) from test/lib/effect.ts — repeatedly run a predicate effect until it returns a non-undefined value, with a timeout.awaitWithTimeout(effect, message, duration?) from test/lib/effect.ts — wrap any effect with Effect.timeoutOrElse and a custom error message.llm.wait(n) from test/lib/llm-server.ts — wait until the mock LLM has received n HTTP calls.SessionStatus.Service .get(sessionID) — observable per-session state ({ type: "busy" | "idle" | ... }).BackgroundJob.wait({ id, timeout }) from src/background/job.ts — wait for a background job to complete.Stream.runForEach(bus.subscribe(Event), ...) and open a Latch inside the callback to signal first-event readiness.Deferred.await(deferred).pipe(Effect.timeoutOrElse(...)) for one-shot signals.// Antipattern — race
yield * prompt.shell({ command: "sleep 30" }).pipe(Effect.forkChild)
yield * Effect.sleep(50)
yield * prompt.cancel(chat.id)
// Fix — wait for a published readiness signal
yield * prompt.shell({ command: "sleep 30" }).pipe(Effect.forkChild)
yield *
pollWithTimeout(
Effect.gen(function* () {
const s = yield* (yield* SessionStatus.Service).get(chat.id)
return s.type === "busy" ? (true as const) : undefined
}),
"session never became busy",
)
yield * prompt.cancel(chat.id)