Skip to content

Latest commit

 

History

History
204 lines (151 loc) · 7.73 KB

File metadata and controls

204 lines (151 loc) · 7.73 KB

Test Fixtures Guide

Temporary Directory Fixture

The tmpdir function in fixture/fixture.ts creates temporary directories for tests with automatic cleanup.

Basic Usage

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
})

Options

  • git?: boolean - Initialize a git repo with a root commit
  • config?: Partial<Config.Info> - Write an opencode.json config file
  • init?: (dir: string) => Promise<T> - Custom setup function, returns value accessible as tmp.extra
  • dispose?: (dir: string) => Promise<T> - Custom cleanup function

Examples

Git 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 })
  },
})

Returned Object

  • 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 using

Notes

  • Directories are created in the system temp folder with prefix opencode-test-
  • Use await using for automatic cleanup when the variable goes out of scope
  • Paths are sanitized to strip null bytes (defensive fix for CI environments)

Testing With Effects

Use testEffect(...) from test/lib/effect.ts for tests that exercise Effect services or Effect-based workflows.

Core Pattern

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.live

  • Use it.effect(...) when the test should run with TestClock and TestConsole.
  • Use it.live(...) when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior.
  • Use it.instance(...) for live Effect tests that need a scoped temporary directory and instance context.
  • Most integration-style tests in this package use it.live(...).

Effect Fixtures

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.

Style

  • Define const it = testEffect(...) near the top of the file.
  • Keep the test body inside Effect.gen(function* () { ... }).
  • Yield services directly with yield* MyService.Service or yield* MyTool.
  • Avoid custom ManagedRuntime, attach(...), or ad hoc run(...) wrappers when testEffect(...) already provides the runtime.
  • When a test needs instance-local state, prefer it.instance(...) over manual Instance.provide(...) inside Promise-style tests.

Partial Service Stubs

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.

Synchronizing With Concurrent Work

The Anti-Pattern

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.

The Fix

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.
  • Bus subscriptions — fork 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.

Example

// 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)

When Fixed Sleeps Are OK

  • Testing debounce or throttle behavior, where the sleep is the test.
  • Letting real wall-clock advance past a genuine timestamp resolution boundary (e.g. mtime granularity).
  • Simulating network latency in race-regression tests that intentionally exercise ordering.