Files
opencode/packages/opencode/test/EFFECT_TEST_MIGRATION.md
2026-05-16 01:30:07 +05:30

6.0 KiB

Effect Test Migration

Move tests that exercise Effect services out of Promise-land and into the shared testEffect pattern.

This file is guidance, not a live inventory. Before claiming a migration, search current dev for the exact anti-pattern and update any PR notes with what you actually changed.

Target Pattern

Every Effect service test should have one local runner near the top:

const it = testEffect(layer)

Use the runner method that matches the behavior:

it.effect("pure service behavior", () =>
  Effect.gen(function* () {
    const service = yield* SomeService.Service
    expect(yield* service.run()).toEqual("ok")
  }),
)

it.instance("instance-local behavior", () =>
  Effect.gen(function* () {
    const test = yield* TestInstance
    expect(test.directory).toContain("opencode-test-")
  }),
)

it.live("live filesystem or process behavior", () =>
  Effect.gen(function* () {
    const dir = yield* tmpdirScoped()
    // real clock / fs / git / process work
  }),
)

Choosing The Runner

  • it.effect(...) — pure Effect behavior with TestClock and TestConsole.
  • it.instance(...) — service behavior that needs one scoped opencode instance.
  • it.live(...) — real time, filesystem mtimes, child processes, git, locks, servers, watchers, or OS behavior.

Most integration-style tests use it.live(...) or it.instance(...).

Layer Rules

Compose tests from open service layers when a dependency needs replacing. Do not use a closed defaultLayer and then try to override an inner dependency after it has already been provided.

Prefer small reusable fake boundary layers in test/fake/*:

AuthTest.empty
AccountTest.empty
NpmTest.noop
SkillTest.empty
ProviderTest.fake().layer

Use Layer.mock for partial service stubs. Missing methods should fail loudly if the test accidentally calls them.

Do not add generic test-layer builders until repeated local compositions prove the need.

Fixture Rules

Use Effect-aware fixtures from test/fixture/fixture.ts:

  • TestInstance inside it.instance(...) for the current temp instance.
  • tmpdirScoped(...) inside Effect.gen for extra temp directories.
  • provideInstance(dir)(effect) when one test needs to switch instance context.
  • provideTmpdirInstance((dir) => effect, options) when a live test needs custom instance setup or multiple instance scopes.
  • disposeAllInstances() in afterEach only for integration tests that intentionally touch shared instance registries.

Avoid mutable global setup. If a global mutation is unavoidable during a migration, scope it with acquire/release and treat it as temporary.

Long term, tests should not toggle process.env, Global.Path, or mutable flags when behavior can be modeled with services. Prefer layers such as RuntimeFlags.layer(...) or focused fake services.

Anti-Patterns To Remove

  • test(..., async () => Effect.runPromise(...))
  • local run(...), load(...), svc(...), or runtime.runPromise(...) wrappers that only provide a layer
  • tmpdir() plus legacy instance provision in Promise test bodies
  • custom ManagedRuntime.make(...) in test files
  • Promise try/catch around Effect failures
  • Promise.withResolvers, Bun.sleep, or setTimeout for synchronization when events, Deferred, fibers, or deterministic state checks fit
  • mutable env/global/flag changes after layers are built

Promise helpers are acceptable at non-Effect boundaries, but yield them from inside an Effect body with Effect.promise(...) rather than making them the test harness.

Conversion Recipe

  1. Identify the real service under test and whether its open layer or closed defaultLayer is appropriate.
  2. Build one top-level layer with real dependencies where relevant and fake layers at slow or external boundaries.
  3. Replace local Promise wrappers with Effect helpers.
  4. Convert test(..., async () => { ... }) to it.effect, it.instance, or it.live.
  5. Move await calls inside Effect.gen as yield* calls.
  6. Replace await using tmp = await tmpdir(...) with yield* tmpdirScoped(...) when the temp directory lives inside the Effect test.
  7. Replace Promise failure assertions with Effect.exit, Effect.flip, or focused assertion helpers.
  8. Preserve concurrency with fibers, Deferred, and Effect.all(..., { concurrency: "unbounded" }); do not accidentally serialize formerly parallel behavior.
  9. Run the focused test file and bun typecheck from packages/opencode.

Good Examples

Use current examples as patterns, but re-check them before copying because test migrations are active:

  • test/effect/instance-state.test.ts — scoped directories, instance switching, disposal, and concurrency.
  • test/bus/bus-effect.test.tsDeferred, streams, scoped fibers.
  • test/agent/plugin-agent-regression.test.ts — real service layers plus fake boundary layers.
  • test/account/service.test.ts — service-level live tests, typed errors, fake HTTP clients.

Migration Queue Policy

Do not maintain a long file checklist here. It goes stale quickly.

When looking for the next target, search for current anti-patterns:

git grep -n "Effect.runPromise\|ManagedRuntime\|Promise.withResolvers\|Bun.sleep\|withTestInstance" -- packages/opencode/test

Then choose one file or one small cluster, keep the PR focused, and mention the focused verification in the PR body.

Rough Edges To Watch

  • Failure assertions against Exit / Cause can get verbose. Add helpers only after the same shape repeats across multiple files.
  • Some tests still need Effect.promise(...) around Node/Bun APIs. Prefer Effect platform services when the surrounding code already uses them, but do not block useful migrations on perfect abstraction.
  • Layer composition can be noisy when a test needs real service subtrees plus fake boundaries. Extract small test/fake/* layers before inventing larger builders.
  • Concurrency tests can get harder to read after replacing Promise resolvers. Look for repeated patterns that deserve named helpers.