From 4655dc84c7bcb11bed82cbff0c07c092f222eb72 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 23 May 2026 14:54:00 -0400 Subject: [PATCH 01/11] test(opencode): simplify config effect tests --- packages/opencode/test/config/config.test.ts | 1014 +++++++----------- 1 file changed, 368 insertions(+), 646 deletions(-) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 04dcde32e1..97b380e5f6 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -15,12 +15,13 @@ import { AccessToken, AccountID, OrgID } from "../../src/account/schema" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Env } from "../../src/env" import { - provideTestInstance, provideTmpdirInstance, TestInstance, tmpdir, tmpdirScoped, withTestInstance, + provideInstanceEffect, + testInstanceStoreLayer, } from "../fixture/fixture" import { InstanceRuntime } from "@/project/instance-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" @@ -92,18 +93,21 @@ const configLayer = ( ) => Config.layer.pipe( Layer.provide(testFlock), - Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(options.auth ?? AuthTest.empty), Layer.provide(options.account ?? AccountTest.empty), Layer.provideMerge(infra), Layer.provide(NpmTest.noop), Layer.provide(Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)), + Layer.provideMerge(AppFileSystem.defaultLayer), ) const layer = configLayer() const it = testEffect(layer) +const configIt = (options?: Parameters[0]) => testEffect(configLayer(options)) + +const schemaConfig = (config: object) => ({ $schema: "https://opencode.ai/config.json", ...config }) const provideCurrentInstance = (effect: Effect.Effect, ctx: InstanceContext) => effect.pipe(Effect.provideService(InstanceRef, ctx)) @@ -112,25 +116,15 @@ const load = (ctx: InstanceContext) => Effect.runPromise( Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)), ) -const saveGlobal = (config: Config.Info) => - Effect.runPromise( - Config.use.updateGlobal(config).pipe( - Effect.map((result) => result.info), +const clearEffect = (wait = false) => + Config.use + .invalidate() + .pipe( Effect.scoped, Effect.provide(layer), - ), - ) -const clear = async (wait = false) => { - await Effect.runPromise(Config.use.invalidate().pipe(Effect.scoped, Effect.provide(layer))) - if (wait) await InstanceRuntime.disposeAllInstances() -} -const listDirs = (ctx: InstanceContext) => - Effect.runPromise( - Config.Service.use((svc) => provideCurrentInstance(svc.directories(), ctx)).pipe( - Effect.scoped, - Effect.provide(layer), - ), - ) + Effect.andThen(wait ? Effect.promise(() => InstanceRuntime.disposeAllInstances()) : Effect.void), + ) +const clear = (wait = false) => Effect.runPromise(clearEffect(wait)) // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const originalTestToken = process.env.TEST_TOKEN @@ -159,9 +153,88 @@ async function writeConfig(dir: string, config: object, name = "opencode.json") } const writeConfigEffect = (dir: string, config: object, name = "opencode.json") => - Effect.promise(() => writeConfig(dir, config, name)) -const mkdirEffect = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) -const writeTextEffect = (file: string, content: string) => Effect.promise(() => Filesystem.write(file, content)) + AppFileSystem.Service.use((fs) => fs.writeFileString(path.join(dir, name), JSON.stringify(config))) +const mkdirEffect = (dir: string) => AppFileSystem.Service.use((fs) => fs.ensureDir(dir)) +const writeTextEffect = (file: string, content: string) => + AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content)) +const readTextEffect = (file: string) => AppFileSystem.Service.use((fs) => fs.readFileString(file)) +const readJsonEffect = (file: string) => AppFileSystem.Service.use((fs) => fs.readJson(file)) +const existsEffect = (file: string) => AppFileSystem.Service.use((fs) => fs.existsSafe(file)) +const chmodEffect = (file: string, mode: number) => AppFileSystem.Service.use((fs) => fs.chmod(file, mode)) + +const project = { + dir: (relative: string) => TestInstance.use((test) => mkdirEffect(path.join(test.directory, relative))), + file: (relative: string, content: string) => + TestInstance.use((test) => writeTextEffect(path.join(test.directory, relative), content)), +} + +const withGlobalConfigDir = (dir: string, effect: Effect.Effect) => + Effect.acquireUseRelease( + Effect.gen(function* () { + const previous = Global.Path.config + ;(Global.Path as { config: string }).config = dir + yield* clearEffect(true) + return previous + }), + () => effect, + (previous) => + Effect.gen(function* () { + ;(Global.Path as { config: string }).config = previous + yield* clearEffect(true) + }), + ) + +const withGlobalConfig = ( + input: { config?: object; name?: string }, + fn: (input: { dir: string }) => Effect.Effect, +) => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + if (input.config) yield* writeConfigEffect(dir, schemaConfig(input.config), input.name) + return yield* withGlobalConfigDir(dir, fn({ dir })) + }) + +const withConfigTree = ( + input: { global?: object; project?: object; local?: object }, + effect: Effect.Effect, +) => + Effect.gen(function* () { + const root = yield* tmpdirScoped() + const directory = path.join(root, "project") + const local = path.join(directory, ".opencode") + yield* mkdirEffect(local) + if (input.global) yield* writeConfigEffect(root, schemaConfig(input.global)) + if (input.project) yield* writeConfigEffect(directory, schemaConfig(input.project)) + if (input.local) yield* writeConfigEffect(local, schemaConfig(input.local)) + return yield* effect.pipe( + Effect.provideService(TestInstance, { directory }), + provideInstanceEffect(directory), + Effect.provide(testInstanceStoreLayer), + Effect.provide(CrossSpawnSpawner.defaultLayer), + ) + }) + +const wellKnown = (input: { + authUrl?: string + config?: unknown + remoteConfig?: { url: string; headers?: Record } + remote?: unknown + wellKnown?: unknown +}) => { + const seen: { wellKnown?: string; remote?: string; authorization?: string } = {} + const client = remoteConfigClient({ + seen, + wellKnown: input.wellKnown ?? { + ...(input.config !== undefined ? { config: input.config } : {}), + ...(input.remoteConfig !== undefined ? { remote_config: input.remoteConfig } : {}), + }, + remote: input.remote, + }) + return { + seen, + it: configIt({ auth: wellKnownAuth(input.authUrl ?? "https://example.com"), client }), + } +} function withProcessEnv(key: string, value: string | undefined, effect: Effect.Effect) { return withProcessEnvs({ [key]: value }, effect) @@ -224,53 +297,33 @@ it.instance("loads config with defaults when no files exist", () => }), ) -test("creates global jsonc config with schema when no global configs exist", async () => { - await using tmp = await tmpdir() - const prev = Global.Path.config - ;(Global.Path as { config: string }).config = tmp.path - await clear(true) +it.effect("creates global jsonc config with schema when no global configs exist", () => + withGlobalConfig({}, ({ dir }) => + Effect.gen(function* () { + yield* Config.use.get().pipe(provideInstanceEffect(dir)) - try { - await withTestInstance({ - directory: tmp.path, - fn: async (ctx) => { - await load(ctx) - }, - }) + const content = yield* readTextEffect(path.join(dir, "opencode.jsonc")) + expect(content).toContain('"$schema": "https://opencode.ai/config.json"') + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + ), +) - const content = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc")) - expect(content).toContain('"$schema": "https://opencode.ai/config.json"') - } finally { - ;(Global.Path as { config: string }).config = prev - await clear(true) - } -}) +it.effect("does not create global config when OPENCODE_CONFIG_DIR is set", () => + Effect.gen(function* () { + const custom = yield* tmpdirScoped() + yield* withGlobalConfig({}, ({ dir }) => + withProcessEnv( + "OPENCODE_CONFIG_DIR", + custom, + Effect.gen(function* () { + yield* Config.use.get().pipe(provideInstanceEffect(dir)) -test("does not create global config when OPENCODE_CONFIG_DIR is set", async () => { - await using tmp = await tmpdir() - await using custom = await tmpdir() - const prevConfig = Global.Path.config - const prevEnv = process.env.OPENCODE_CONFIG_DIR - ;(Global.Path as { config: string }).config = tmp.path - process.env.OPENCODE_CONFIG_DIR = custom.path - await clear(true) - - try { - await withTestInstance({ - directory: tmp.path, - fn: async (ctx) => { - await load(ctx) - }, - }) - - expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc"))).toBe(false) - } finally { - ;(Global.Path as { config: string }).config = prevConfig - if (prevEnv === undefined) delete process.env.OPENCODE_CONFIG_DIR - else process.env.OPENCODE_CONFIG_DIR = prevEnv - await clear(true) - } -}) + expect(yield* existsEffect(path.join(dir, "opencode.jsonc"))).toBe(false) + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), + ), + ) + }), +) it.instance( "loads JSON config file", @@ -309,63 +362,31 @@ it.instance("updates config and preserves empty shell sentinel", () => }), ) -test("updates global config and omits empty shell key in json", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await writeConfig(dir, { - $schema: "https://opencode.ai/config.json", - shell: "bash", - }) - }, - }) +it.effect("updates global config and omits empty shell key in json", () => + withGlobalConfig({ config: { shell: "bash" } }, ({ dir }) => + Effect.gen(function* () { + yield* Config.use.updateGlobal({ shell: "" }) - const prev = Global.Path.config - ;(Global.Path as { config: string }).config = tmp.path - await clear(true) + const writtenConfig = yield* readJsonEffect(path.join(dir, "opencode.json")) + expect(writtenConfig).not.toHaveProperty("shell") + }), + ), +) - try { - await saveGlobal({ shell: "" }) +it.effect("updates global config and omits empty shell key in jsonc", () => + withGlobalConfig({ config: { shell: "bash", model: "test/model" }, name: "opencode.jsonc" }, ({ dir }) => + Effect.gen(function* () { + yield* Config.use.updateGlobal({ shell: "" }) - const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "opencode.json")) - expect("shell" in writtenConfig).toBe(false) - } finally { - ;(Global.Path as { config: string }).config = prev - await clear(true) - } -}) - -test("updates global config and omits empty shell key in jsonc", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - await Filesystem.write( - path.join(dir, "opencode.jsonc"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - shell: "bash", - model: "test/model", - }), - ) - }, - }) - - const prev = Global.Path.config - ;(Global.Path as { config: string }).config = tmp.path - await clear(true) - - try { - await saveGlobal({ shell: "" }) - - const file = path.join(tmp.path, "opencode.jsonc") - const writtenConfig = await Filesystem.readText(file) - const parsed = ConfigParse.schema(Config.Info, ConfigParse.jsonc(writtenConfig, file), file) - expect(writtenConfig).not.toContain('"shell"') - expect(parsed.shell).toBeUndefined() - expect(parsed.model).toBe("test/model") - } finally { - ;(Global.Path as { config: string }).config = prev - await clear(true) - } -}) + const file = path.join(dir, "opencode.jsonc") + const writtenConfig = yield* readTextEffect(file) + const parsed = ConfigParse.schema(Config.Info, ConfigParse.jsonc(writtenConfig, file), file) + expect(writtenConfig).not.toContain('"shell"') + expect(parsed.shell).toBeUndefined() + expect(parsed.model).toBe("test/model") + }), + ), +) it.instance( "loads formatter boolean config", @@ -422,16 +443,14 @@ it.instance("ignores legacy tui keys in opencode config", () => it.instance("loads JSONC config file", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => - Filesystem.write( - path.join(test.directory, "opencode.jsonc"), - `{ + yield* writeTextEffect( + path.join(test.directory, "opencode.jsonc"), + `{ // This is a comment "$schema": "https://opencode.ai/config.json", "model": "test/model", "username": "testuser" }`, - ), ) const config = yield* Config.use.get() expect(config.model).toBe("test/model") @@ -484,19 +503,15 @@ it.instance("preserves env variables when adding $schema to config", () => Effect.gen(function* () { const test = yield* TestInstance // Config without $schema - should trigger auto-add - yield* Effect.promise(() => - Filesystem.write( - path.join(test.directory, "opencode.json"), - JSON.stringify({ - username: "{env:PRESERVE_VAR}", - }), - ), + yield* writeTextEffect( + path.join(test.directory, "opencode.json"), + JSON.stringify({ username: "{env:PRESERVE_VAR}" }), ) const config = yield* Config.use.get() expect(config.username).toBe("secret_value") // Read the file to verify the env variable was preserved - const content = yield* Effect.promise(() => Filesystem.readText(path.join(test.directory, "opencode.json"))) + const content = yield* readTextEffect(path.join(test.directory, "opencode.json")) expect(content).toContain("{env:PRESERVE_VAR}") expect(content).not.toContain("secret_value") expect(content).toContain("$schema") @@ -507,7 +522,7 @@ it.instance("preserves env variables when adding $schema to config", () => it.instance("handles file inclusion substitution", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "included.txt"), "test-user")) + yield* writeTextEffect(path.join(test.directory, "included.txt"), "test-user") yield* writeConfigEffect(test.directory, { $schema: "https://opencode.ai/config.json", username: "{file:included.txt}", @@ -520,9 +535,7 @@ it.instance("handles file inclusion substitution", () => it.instance("handles file inclusion with replacement tokens", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => - Filesystem.write(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`"), - ) + yield* writeTextEffect(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`") yield* writeConfigEffect(test.directory, { $schema: "https://opencode.ai/config.json", username: "{file:included.md}", @@ -532,10 +545,8 @@ it.instance("handles file inclusion with replacement tokens", () => }), ) -test("resolves env templates in account config with account token", async () => { - const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"] - - const fakeAccount = Layer.mock(Account.Service)({ +const accountTokenIt = configIt({ + account: Layer.mock(Account.Service)({ active: () => Effect.succeed( Option.some({ @@ -567,28 +578,16 @@ test("resolves env templates in account config with account token", async () => }), ), token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))), - }) - - const layer = configLayer({ account: fakeAccount }) - - try { - await provideTmpdirInstance(() => - Config.Service.use((svc) => - Effect.gen(function* () { - const config = yield* svc.get() - expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token") - }), - ), - ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) - } finally { - if (originalControlToken !== undefined) { - process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken - } else { - delete process.env["OPENCODE_CONSOLE_TOKEN"] - } - } + }), }) +accountTokenIt.instance("resolves env templates in account config with account token", () => + Effect.gen(function* () { + const config = yield* Config.use.get() + expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token") + }), +) + it.instance("validates config schema and throws on invalid fields", () => Effect.gen(function* () { const test = yield* TestInstance @@ -604,7 +603,7 @@ it.instance("validates config schema and throws on invalid fields", () => it.instance("throws error for invalid JSON", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "opencode.json"), "{ invalid json }")) + yield* writeTextEffect(path.join(test.directory, "opencode.json"), "{ invalid json }") const exit = yield* Config.use.get().pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) }), @@ -888,79 +887,37 @@ it.instance("gets config directories", () => }), ) -test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", async () => { - if (process.platform === "win32") return +it.effect("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", () => + Effect.gen(function* () { + if (process.platform === "win32") return - await using tmp = await tmpdir({ - init: async (dir) => { - const ro = path.join(dir, "readonly") - await fs.mkdir(ro, { recursive: true }) - await fs.chmod(ro, 0o555) - return ro - }, - dispose: async (dir) => { - const ro = path.join(dir, "readonly") - await fs.chmod(ro, 0o755).catch(() => {}) - return ro - }, - }) + const dir = yield* tmpdirScoped() + const readonly = path.join(dir, "readonly") + yield* mkdirEffect(readonly) + yield* chmodEffect(readonly, 0o555) + yield* Effect.addFinalizer(() => chmodEffect(readonly, 0o755).pipe(Effect.ignore)) - const prev = process.env.OPENCODE_CONFIG_DIR - process.env.OPENCODE_CONFIG_DIR = tmp.extra + yield* withProcessEnv("OPENCODE_CONFIG_DIR", readonly, Config.use.get().pipe(provideInstanceEffect(dir))) + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), +) - try { - await withTestInstance({ - directory: tmp.path, - fn: async (ctx) => { - await load(ctx) - }, - }) - } finally { - if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR - else process.env.OPENCODE_CONFIG_DIR = prev - } -}) +it.effect("installs dependencies in writable OPENCODE_CONFIG_DIR", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped() + const configDir = path.join(dir, "configdir") + yield* mkdirEffect(configDir) -test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const cfg = path.join(dir, "configdir") - await fs.mkdir(cfg, { recursive: true }) - return cfg - }, - }) + yield* withProcessEnv( + "OPENCODE_CONFIG_DIR", + configDir, + Config.Service.use((svc) => svc.get().pipe(Effect.andThen(svc.waitForDependencies()))).pipe( + provideInstanceEffect(dir), + ), + ) - const prev = process.env.OPENCODE_CONFIG_DIR - process.env.OPENCODE_CONFIG_DIR = tmp.extra - - const testLayer = configLayer() - - try { - await withTestInstance({ - directory: tmp.path, - fn: async (ctx) => { - await Effect.runPromise( - Config.Service.use((svc) => svc.get().pipe(Effect.provideService(InstanceRef, ctx))).pipe( - Effect.scoped, - Effect.provide(testLayer), - ), - ) - await Effect.runPromise( - Config.Service.use((svc) => svc.waitForDependencies().pipe(Effect.provideService(InstanceRef, ctx))).pipe( - Effect.scoped, - Effect.provide(testLayer), - ), - ) - }, - }) - - expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true) - expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json") - } finally { - if (prev === undefined) delete process.env.OPENCODE_CONFIG_DIR - else process.env.OPENCODE_CONFIG_DIR = prev - } -}) + expect(yield* readTextEffect(path.join(configDir, ".gitignore"))).toContain("package-lock.json") + }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), +) // Note: deduplication and serialization of npm installs is now handled by the // core Npm.Service (via EffectFlock). Those behaviors are tested in the core @@ -996,51 +953,24 @@ it.instance("resolves scoped npm plugins in config", () => }), ) -test("merges plugin arrays from global and local configs", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - // Create a nested project structure with local .opencode config - const projectDir = path.join(dir, "project") - const opencodeDir = path.join(projectDir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - - // Global config with plugins - await Filesystem.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: ["global-plugin-1", "global-plugin-2"], - }), - ) - - // Local .opencode config with different plugins - await Filesystem.write( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: ["local-plugin-1"], - }), - ) +it.effect("merges plugin arrays from global and local configs", () => + withConfigTree( + { + global: { plugin: ["global-plugin-1", "global-plugin-2"] }, + local: { plugin: ["local-plugin-1"] }, }, - }) + Effect.gen(function* () { + const plugins = (yield* Config.use.get()).plugin ?? [] - await provideTestInstance({ - directory: path.join(tmp.path, "project"), - fn: async (ctx) => { - const config = await load(ctx) - const plugins = config.plugin ?? [] - - // Should contain both global and local plugins expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true) expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true) expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true) - - // Should have all 3 plugins (not replaced, but merged) - const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin")) - expect(pluginNames.length).toBeGreaterThanOrEqual(3) - }, - }) -}) + expect( + plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin")).length, + ).toBeGreaterThanOrEqual(3) + }), + ), +) it.instance("does not error when only custom agent is a subagent", () => Effect.gen(function* () { @@ -1065,183 +995,78 @@ Helper subagent prompt`, }), ) -test("merges instructions arrays from global and local configs", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const projectDir = path.join(dir, "project") - const opencodeDir = path.join(projectDir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - - await Filesystem.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - instructions: ["global-instructions.md", "shared-rules.md"], - }), - ) - - await Filesystem.write( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - instructions: ["local-instructions.md"], - }), - ) +it.effect("merges instructions arrays from global and local configs", () => + withConfigTree( + { + global: { instructions: ["global-instructions.md", "shared-rules.md"] }, + local: { instructions: ["local-instructions.md"] }, }, - }) + Effect.gen(function* () { + expect((yield* Config.use.get()).instructions).toEqual([ + "global-instructions.md", + "shared-rules.md", + "local-instructions.md", + ]) + }), + ), +) - await withTestInstance({ - directory: path.join(tmp.path, "project"), - fn: async (ctx) => { - const config = await load(ctx) - const instructions = config.instructions ?? [] - - expect(instructions).toContain("global-instructions.md") - expect(instructions).toContain("shared-rules.md") - expect(instructions).toContain("local-instructions.md") - expect(instructions.length).toBe(3) +it.effect("deduplicates duplicate instructions from global and local configs", () => + withConfigTree( + { + global: { instructions: ["duplicate.md", "global-only.md"] }, + local: { instructions: ["duplicate.md", "local-only.md"] }, }, - }) -}) + Effect.gen(function* () { + expect((yield* Config.use.get()).instructions).toEqual(["duplicate.md", "global-only.md", "local-only.md"]) + }), + ), +) -test("deduplicates duplicate instructions from global and local configs", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const projectDir = path.join(dir, "project") - const opencodeDir = path.join(projectDir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - - await Filesystem.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - instructions: ["duplicate.md", "global-only.md"], - }), - ) - - await Filesystem.write( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - instructions: ["duplicate.md", "local-only.md"], - }), - ) +it.effect("deduplicates duplicate plugins from global and local configs", () => + withConfigTree( + { + global: { plugin: ["duplicate-plugin", "global-plugin-1"] }, + local: { plugin: ["duplicate-plugin", "local-plugin-1"] }, }, - }) + Effect.gen(function* () { + const plugins = (yield* Config.use.get()).plugin ?? [] - await withTestInstance({ - directory: path.join(tmp.path, "project"), - fn: async (ctx) => { - const config = await load(ctx) - const instructions = config.instructions ?? [] - - expect(instructions).toContain("global-only.md") - expect(instructions).toContain("local-only.md") - expect(instructions).toContain("duplicate.md") - - const duplicates = instructions.filter((i) => i === "duplicate.md") - expect(duplicates.length).toBe(1) - expect(instructions.length).toBe(3) - }, - }) -}) - -test("deduplicates duplicate plugins from global and local configs", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - // Create a nested project structure with local .opencode config - const projectDir = path.join(dir, "project") - const opencodeDir = path.join(projectDir, ".opencode") - await fs.mkdir(opencodeDir, { recursive: true }) - - // Global config with plugins - await Filesystem.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: ["duplicate-plugin", "global-plugin-1"], - }), - ) - - // Local .opencode config with some overlapping plugins - await Filesystem.write( - path.join(opencodeDir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: ["duplicate-plugin", "local-plugin-1"], - }), - ) - }, - }) - - await provideTestInstance({ - directory: path.join(tmp.path, "project"), - fn: async (ctx) => { - const config = await load(ctx) - const plugins = config.plugin ?? [] - - // Should contain all unique plugins expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true) expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true) - expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true) + expect(plugins.filter((p) => p.includes("duplicate-plugin")).length).toBe(1) + expect( + plugins.filter( + (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"), + ).length, + ).toBe(3) + }), + ), +) - // Should deduplicate the duplicate plugin - const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin")) - expect(duplicatePlugins.length).toBe(1) - - // Should have exactly 3 unique plugins - const pluginNames = plugins.filter( - (p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"), - ) - expect(pluginNames.length).toBe(3) +it.effect("keeps plugin origins aligned with merged plugin list", () => + withConfigTree( + { + global: { plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"] }, + local: { plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"] }, }, - }) -}) - -test("keeps plugin origins aligned with merged plugin list", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const project = path.join(dir, "project") - const local = path.join(project, ".opencode") - await fs.mkdir(local, { recursive: true }) - - await Filesystem.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: [["shared-plugin@1.0.0", { source: "global" }], "global-only@1.0.0"], - }), - ) - - await Filesystem.write( - path.join(local, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: [["shared-plugin@2.0.0", { source: "local" }], "local-only@1.0.0"], - }), - ) - }, - }) - - await provideTestInstance({ - directory: path.join(tmp.path, "project"), - fn: async (ctx) => { - const cfg = await load(ctx) - const plugins = cfg.plugin ?? [] - const origins = cfg.plugin_origins ?? [] + Effect.gen(function* () { + const config = yield* Config.use.get() + const plugins = config.plugin ?? [] + const origins = config.plugin_origins ?? [] const names = plugins.map((item) => ConfigPlugin.pluginSpecifier(item)) expect(names).toContain("shared-plugin@2.0.0") expect(names).not.toContain("shared-plugin@1.0.0") expect(names).toContain("global-only@1.0.0") expect(names).toContain("local-only@1.0.0") - expect(origins.map((item) => item.spec)).toEqual(plugins) - const hit = origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0") - expect(hit?.scope).toBe("local") - }, - }) -}) + expect(origins.find((item) => ConfigPlugin.pluginSpecifier(item.spec) === "shared-plugin@2.0.0")?.scope).toBe( + "local", + ) + }), + ), +) // Legacy tools migration tests @@ -1583,64 +1408,40 @@ it.instance("local .opencode config can override MCP from project config", () => }), ) -test("project config overrides remote well-known config", async () => { - const seen: { wellKnown?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: { - config: { - mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } }, - }, - }, - }) - - await provideTmpdirInstance( - () => - Config.Service.use((svc) => - Effect.gen(function* () { - const config = yield* svc.get() - expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode") - expect(config.mcp?.jira?.enabled).toBe(true) - }), - ), - { - git: true, - config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } }, - }, - ).pipe( - Effect.scoped, - Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })), - Effect.runPromise, - ) +const remoteProjectOverride = wellKnown({ + config: { + mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } }, + }, }) -test("wellknown URL with trailing slash is normalized", async () => { - const seen: { wellKnown?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: { - config: { - mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } }, - }, - }, - }) +remoteProjectOverride.it.instance( + "project config overrides remote well-known config", + () => + Effect.gen(function* () { + const config = yield* Config.use.get() + expect(remoteProjectOverride.seen.wellKnown).toBe("https://example.com/.well-known/opencode") + expect(config.mcp?.jira?.enabled).toBe(true) + }), + { + git: true, + config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } }, + }, +) - await provideTmpdirInstance( - () => - Config.Service.use((svc) => - Effect.gen(function* () { - yield* svc.get() - expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode") - }), - ), - { git: true }, - ).pipe( - Effect.scoped, - Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com/"), client })), - Effect.runPromise, - ) +const trailingSlashWellKnown = wellKnown({ + authUrl: "https://example.com/", + config: { + mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } }, + }, }) +trailingSlashWellKnown.it.instance("wellknown URL with trailing slash is normalized", () => + Effect.gen(function* () { + yield* Config.use.get() + expect(trailingSlashWellKnown.seen.wellKnown).toBe("https://example.com/.well-known/opencode") + }), +) + test("remote well-known config can use FetchHttpClient layer", async () => { let fetchedUrl: string | undefined const server = Bun.serve({ @@ -1659,16 +1460,14 @@ test("remote well-known config can use FetchHttpClient layer", async () => { }) try { - await provideTmpdirInstance( - () => - Config.Service.use((svc) => - Effect.gen(function* () { - const config = yield* svc.get() - expect(fetchedUrl).toBe(`${server.url.origin}/.well-known/opencode`) - expect(config.mcp?.jira?.enabled).toBe(true) - }), - ), - { git: true }, + await provideTmpdirInstance(() => + Config.Service.use((svc) => + Effect.gen(function* () { + const config = yield* svc.get() + expect(fetchedUrl).toBe(`${server.url.origin}/.well-known/opencode`) + expect(config.mcp?.jira?.enabled).toBe(true) + }), + ), ).pipe( Effect.scoped, Effect.provide( @@ -1690,142 +1489,83 @@ test("remote well-known config can use FetchHttpClient layer", async () => { } }) -test("wellknown remote_config supports templated env vars in headers", async () => { - const originalToken = process.env.TEST_TOKEN - const seen: { wellKnown?: string; remote?: string; authorization?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: { - remote_config: { - url: "https://config.example.com/opencode.json", - headers: { - Authorization: "Bearer {env:TEST_TOKEN}", - }, - }, - }, - remote: { - mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, - }, - }) - - try { - await provideTmpdirInstance( - () => - Config.Service.use((svc) => - Effect.gen(function* () { - const config = yield* svc.get() - expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode") - expect(seen.remote).toBe("https://config.example.com/opencode.json") - expect(seen.authorization).toBe("Bearer test-token") - expect(config.mcp?.confluence?.enabled).toBe(true) - }), - ), - { git: true }, - ).pipe( - Effect.scoped, - Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })), - Effect.runPromise, - ) - } finally { - if (originalToken === undefined) delete process.env.TEST_TOKEN - else process.env.TEST_TOKEN = originalToken - } +const templatedHeaderWellKnown = wellKnown({ + remoteConfig: { + url: "https://config.example.com/opencode.json", + headers: { Authorization: "Bearer {env:TEST_TOKEN}" }, + }, + remote: { + mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, + }, }) -test("wellknown token env substitution does not mutate process env", async () => { - const originalToken = process.env.TEST_TOKEN - process.env.TEST_TOKEN = "preexisting-token" - const seen: { wellKnown?: string; remote?: string; authorization?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: { - remote_config: { - url: "https://config.example.com/opencode.json", - headers: { - Authorization: "Bearer {env:TEST_TOKEN}", - }, - }, - }, - remote: { - mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, - }, - }) +templatedHeaderWellKnown.it.instance("wellknown remote_config supports templated env vars in headers", () => + Effect.gen(function* () { + const config = yield* Config.use.get() + expect(templatedHeaderWellKnown.seen.wellKnown).toBe("https://example.com/.well-known/opencode") + expect(templatedHeaderWellKnown.seen.remote).toBe("https://config.example.com/opencode.json") + expect(templatedHeaderWellKnown.seen.authorization).toBe("Bearer test-token") + expect(config.mcp?.confluence?.enabled).toBe(true) + }), +) - try { - const config = await provideTmpdirInstance(() => Config.Service.use((svc) => svc.get()), { - git: true, - config: { username: "{env:TEST_TOKEN}" }, - }).pipe( - Effect.scoped, - Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })), - Effect.runPromise, - ) - - expect(seen.authorization).toBe("Bearer test-token") - expect(config.username).toBe("test-token") - expect(process.env.TEST_TOKEN).toBe("preexisting-token") - } finally { - if (originalToken === undefined) delete process.env.TEST_TOKEN - else process.env.TEST_TOKEN = originalToken - } +const envIsolationWellKnown = wellKnown({ + remoteConfig: { + url: "https://config.example.com/opencode.json", + headers: { Authorization: "Bearer {env:TEST_TOKEN}" }, + }, + remote: { + mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, + }, }) -test("wellknown config null is treated as absent", async () => { - const seen: { wellKnown?: string; remote?: string; authorization?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: { - config: null, - remote_config: { - url: "https://config.example.com/opencode.json", - }, - }, - remote: { - mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, - }, - }) +envIsolationWellKnown.it.instance( + "wellknown token env substitution does not mutate process env", + () => + withProcessEnv( + "TEST_TOKEN", + "preexisting-token", + Effect.gen(function* () { + const config = yield* Config.use.get() + expect(envIsolationWellKnown.seen.authorization).toBe("Bearer test-token") + expect(config.username).toBe("test-token") + expect(process.env.TEST_TOKEN).toBe("preexisting-token") + }), + ), + { git: true, config: { username: "{env:TEST_TOKEN}" } }, +) - await provideTmpdirInstance( - () => - Config.Service.use((svc) => - Effect.gen(function* () { - const config = yield* svc.get() - expect(seen.remote).toBe("https://config.example.com/opencode.json") - expect(config.mcp?.confluence?.enabled).toBe(true) - }), - ), - { git: true }, - ).pipe( - Effect.scoped, - Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })), - Effect.runPromise, - ) +const nullConfigWellKnown = wellKnown({ + wellKnown: { + config: null, + remote_config: { url: "https://config.example.com/opencode.json" }, + }, + remote: { + mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, + }, }) -test("wellknown remote_config rejects non-object config responses", async () => { - const seen: { wellKnown?: string; remote?: string; authorization?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: { - remote_config: { - url: "https://config.example.com/opencode.json", - }, - }, - remote: "not an object", - }) +nullConfigWellKnown.it.instance("wellknown config null is treated as absent", () => + Effect.gen(function* () { + const config = yield* Config.use.get() + expect(nullConfigWellKnown.seen.remote).toBe("https://config.example.com/opencode.json") + expect(config.mcp?.confluence?.enabled).toBe(true) + }), +) - const exit = await provideTmpdirInstance(() => Config.Service.use((svc) => svc.get()).pipe(Effect.exit), { - git: true, - }).pipe( - Effect.scoped, - Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })), - Effect.runPromise, - ) - - expect(seen.remote).toBe("https://config.example.com/opencode.json") - expect(Exit.isFailure(exit)).toBe(true) +const invalidRemoteWellKnown = wellKnown({ + remoteConfig: { url: "https://config.example.com/opencode.json" }, + remote: "not an object", }) +invalidRemoteWellKnown.it.instance("wellknown remote_config rejects non-object config responses", () => + Effect.gen(function* () { + const exit = yield* Config.use.get().pipe(Effect.exit) + expect(invalidRemoteWellKnown.seen.remote).toBe("https://config.example.com/opencode.json") + expect(Exit.isFailure(exit)).toBe(true) + }), +) + describe("resolvePluginSpec", () => { test("keeps package specs unchanged", async () => { await using tmp = await tmpdir() @@ -1942,37 +1682,19 @@ describe("deduplicatePluginOrigins", () => { expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]) }) - test("loads auto-discovered local plugins as file urls", async () => { - await using tmp = await tmpdir({ - init: async (dir) => { - const projectDir = path.join(dir, "project") - const opencodeDir = path.join(projectDir, ".opencode") - const pluginDir = path.join(opencodeDir, "plugin") - await fs.mkdir(pluginDir, { recursive: true }) - - await Filesystem.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - plugin: ["my-plugin@1.0.0"], - }), - ) - - await Filesystem.write(path.join(pluginDir, "my-plugin.js"), "export default {}") - }, - }) - - await provideTestInstance({ - directory: path.join(tmp.path, "project"), - fn: async (ctx) => { - const config = await load(ctx) - const plugins = config.plugin ?? [] + it.effect("loads auto-discovered local plugins as file urls", () => + withConfigTree( + { global: { plugin: ["my-plugin@1.0.0"] } }, + Effect.gen(function* () { + yield* project.dir(".opencode/plugin") + yield* project.file(".opencode/plugin/my-plugin.js", "export default {}") + const plugins = (yield* Config.use.get()).plugin ?? [] expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p).startsWith("file://"))).toBe(true) - }, - }) - }) + }), + ), + ) }) describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { From 22f79f05cf1082021f17b2ebcecb666c3cbdfdae Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 23 May 2026 14:55:11 -0400 Subject: [PATCH 02/11] fix(desktop): declare scheduled primitive dependency --- bun.lock | 1 + packages/desktop/package.json | 1 + 2 files changed, 2 insertions(+) diff --git a/bun.lock b/bun.lock index 659d844823..3ef06eda27 100644 --- a/bun.lock +++ b/bun.lock @@ -274,6 +274,7 @@ "@sentry/solid": "catalog:", "@sentry/vite-plugin": "catalog:", "@solid-primitives/i18n": "2.2.1", + "@solid-primitives/scheduled": "1.5.3", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.4", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ed9041e72b..d78cd5e306 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -42,6 +42,7 @@ "@sentry/solid": "catalog:", "@sentry/vite-plugin": "catalog:", "@solid-primitives/i18n": "2.2.1", + "@solid-primitives/scheduled": "1.5.3", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.4", From 2fc0793f1ff441ea76d87c246d93f0cb35aca3a8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 23 May 2026 15:01:01 -0400 Subject: [PATCH 03/11] test(opencode): use filesystem service accessors --- packages/core/src/filesystem.ts | 39 +++++++ packages/opencode/test/config/config.test.ts | 113 ++++++++----------- 2 files changed, 88 insertions(+), 64 deletions(-) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 8a1cc3a08f..06170beed9 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -7,6 +7,43 @@ import { Effect, FileSystem, Layer, Schema, Context } from "effect" import type { PlatformError } from "effect/PlatformError" import { Glob } from "./util/glob" +type EffectMethod = (...args: ReadonlyArray) => Effect.Effect + +type ServiceUse = { + readonly [Key in keyof Shape as Shape[Key] extends EffectMethod ? Key : never]: Shape[Key] extends ( + ...args: infer Args + ) => infer Return + ? Args extends ReadonlyArray + ? Return extends Effect.Effect + ? (...args: Args) => Effect.Effect + : never + : never + : never +} + +const serviceUse = (tag: Context.Service) => { + // This is the only dynamic boundary: TypeScript knows the accessor shape, + // but Proxy property names are runtime values. + const access = new Proxy( + {}, + { + get: (_, key) => { + if (typeof key !== "string") return undefined + return (...args: unknown[]) => + tag.use((service) => { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime. + const method = service[key as keyof Shape] + if (typeof method !== "function") return Effect.die(new Error(`Service method not found: ${key}`)) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods. + return (method as (...args: unknown[]) => Effect.Effect)(...args) + }) + }, + }, + ) + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy implements the mapped accessor surface lazily. + return access as ServiceUse +} + export namespace AppFileSystem { export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { method: Schema.String, @@ -39,6 +76,8 @@ export namespace AppFileSystem { export class Service extends Context.Service()("@opencode/FileSystem") {} + export const use = serviceUse(Service) + export const layer = Layer.effect( Service, Effect.gen(function* () { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 97b380e5f6..f0778a6a54 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -140,32 +140,21 @@ afterEach(async () => { await clear(true) }) -async function writeManagedSettings(settings: object, filename = "opencode.json") { - await fs.mkdir(managedConfigDir, { recursive: true }) - await Filesystem.write(path.join(managedConfigDir, filename), JSON.stringify(settings)) -} - const writeManagedSettingsEffect = (settings: object, filename?: string) => - Effect.promise(() => writeManagedSettings(settings, filename)) + AppFileSystem.use.writeWithDirs(path.join(managedConfigDir, filename ?? "opencode.json"), JSON.stringify(settings)) async function writeConfig(dir: string, config: object, name = "opencode.json") { await Filesystem.write(path.join(dir, name), JSON.stringify(config)) } const writeConfigEffect = (dir: string, config: object, name = "opencode.json") => - AppFileSystem.Service.use((fs) => fs.writeFileString(path.join(dir, name), JSON.stringify(config))) -const mkdirEffect = (dir: string) => AppFileSystem.Service.use((fs) => fs.ensureDir(dir)) -const writeTextEffect = (file: string, content: string) => - AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content)) -const readTextEffect = (file: string) => AppFileSystem.Service.use((fs) => fs.readFileString(file)) -const readJsonEffect = (file: string) => AppFileSystem.Service.use((fs) => fs.readJson(file)) -const existsEffect = (file: string) => AppFileSystem.Service.use((fs) => fs.existsSafe(file)) -const chmodEffect = (file: string, mode: number) => AppFileSystem.Service.use((fs) => fs.chmod(file, mode)) + AppFileSystem.use.writeFileString(path.join(dir, name), JSON.stringify(config)) const project = { - dir: (relative: string) => TestInstance.use((test) => mkdirEffect(path.join(test.directory, relative))), + dir: (relative: string) => + TestInstance.use((test) => AppFileSystem.use.ensureDir(path.join(test.directory, relative))), file: (relative: string, content: string) => - TestInstance.use((test) => writeTextEffect(path.join(test.directory, relative), content)), + TestInstance.use((test) => AppFileSystem.use.writeWithDirs(path.join(test.directory, relative), content)), } const withGlobalConfigDir = (dir: string, effect: Effect.Effect) => @@ -202,7 +191,7 @@ const withConfigTree = ( const root = yield* tmpdirScoped() const directory = path.join(root, "project") const local = path.join(directory, ".opencode") - yield* mkdirEffect(local) + yield* AppFileSystem.use.ensureDir(local) if (input.global) yield* writeConfigEffect(root, schemaConfig(input.global)) if (input.project) yield* writeConfigEffect(directory, schemaConfig(input.project)) if (input.local) yield* writeConfigEffect(local, schemaConfig(input.local)) @@ -302,7 +291,7 @@ it.effect("creates global jsonc config with schema when no global configs exist" Effect.gen(function* () { yield* Config.use.get().pipe(provideInstanceEffect(dir)) - const content = yield* readTextEffect(path.join(dir, "opencode.jsonc")) + const content = yield* AppFileSystem.use.readFileString(path.join(dir, "opencode.jsonc")) expect(content).toContain('"$schema": "https://opencode.ai/config.json"') }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), ), @@ -318,7 +307,7 @@ it.effect("does not create global config when OPENCODE_CONFIG_DIR is set", () => Effect.gen(function* () { yield* Config.use.get().pipe(provideInstanceEffect(dir)) - expect(yield* existsEffect(path.join(dir, "opencode.jsonc"))).toBe(false) + expect(yield* AppFileSystem.use.existsSafe(path.join(dir, "opencode.jsonc"))).toBe(false) }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), ), ) @@ -355,10 +344,8 @@ it.instance("updates config and preserves empty shell sentinel", () => yield* Config.Service.use((svc) => svc.update(ConfigParse.schema(Config.Info, { shell: "" }, "test:config"))) - const writtenConfig = yield* Effect.promise(() => - Filesystem.readJson<{ shell?: string }>(path.join(test.directory, "config.json")), - ) - expect(writtenConfig.shell).toBe("") + const writtenConfig = yield* AppFileSystem.use.readJson(path.join(test.directory, "config.json")) + expect(writtenConfig).toMatchObject({ shell: "" }) }), ) @@ -367,7 +354,7 @@ it.effect("updates global config and omits empty shell key in json", () => Effect.gen(function* () { yield* Config.use.updateGlobal({ shell: "" }) - const writtenConfig = yield* readJsonEffect(path.join(dir, "opencode.json")) + const writtenConfig = yield* AppFileSystem.use.readJson(path.join(dir, "opencode.json")) expect(writtenConfig).not.toHaveProperty("shell") }), ), @@ -379,7 +366,7 @@ it.effect("updates global config and omits empty shell key in jsonc", () => yield* Config.use.updateGlobal({ shell: "" }) const file = path.join(dir, "opencode.jsonc") - const writtenConfig = yield* readTextEffect(file) + const writtenConfig = yield* AppFileSystem.use.readFileString(file) const parsed = ConfigParse.schema(Config.Info, ConfigParse.jsonc(writtenConfig, file), file) expect(writtenConfig).not.toContain('"shell"') expect(parsed.shell).toBeUndefined() @@ -443,7 +430,7 @@ it.instance("ignores legacy tui keys in opencode config", () => it.instance("loads JSONC config file", () => Effect.gen(function* () { const test = yield* TestInstance - yield* writeTextEffect( + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, "opencode.jsonc"), `{ // This is a comment @@ -503,7 +490,7 @@ it.instance("preserves env variables when adding $schema to config", () => Effect.gen(function* () { const test = yield* TestInstance // Config without $schema - should trigger auto-add - yield* writeTextEffect( + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, "opencode.json"), JSON.stringify({ username: "{env:PRESERVE_VAR}" }), ) @@ -511,7 +498,7 @@ it.instance("preserves env variables when adding $schema to config", () => expect(config.username).toBe("secret_value") // Read the file to verify the env variable was preserved - const content = yield* readTextEffect(path.join(test.directory, "opencode.json")) + const content = yield* AppFileSystem.use.readFileString(path.join(test.directory, "opencode.json")) expect(content).toContain("{env:PRESERVE_VAR}") expect(content).not.toContain("secret_value") expect(content).toContain("$schema") @@ -522,7 +509,7 @@ it.instance("preserves env variables when adding $schema to config", () => it.instance("handles file inclusion substitution", () => Effect.gen(function* () { const test = yield* TestInstance - yield* writeTextEffect(path.join(test.directory, "included.txt"), "test-user") + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.txt"), "test-user") yield* writeConfigEffect(test.directory, { $schema: "https://opencode.ai/config.json", username: "{file:included.txt}", @@ -535,7 +522,7 @@ it.instance("handles file inclusion substitution", () => it.instance("handles file inclusion with replacement tokens", () => Effect.gen(function* () { const test = yield* TestInstance - yield* writeTextEffect(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`") + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`") yield* writeConfigEffect(test.directory, { $schema: "https://opencode.ai/config.json", username: "{file:included.md}", @@ -603,7 +590,7 @@ it.instance("validates config schema and throws on invalid fields", () => it.instance("throws error for invalid JSON", () => Effect.gen(function* () { const test = yield* TestInstance - yield* writeTextEffect(path.join(test.directory, "opencode.json"), "{ invalid json }") + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "opencode.json"), "{ invalid json }") const exit = yield* Config.use.get().pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) }), @@ -718,8 +705,8 @@ it.instance("migrates mode field to agent field", () => it.instance("loads config from .opencode directory", () => Effect.gen(function* () { const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "agent")) - yield* writeTextEffect( + yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "agent")) + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "agent", "test.md"), `--- model: test/model @@ -741,8 +728,8 @@ Test agent prompt`, it.instance("agent markdown permission config preserves user key order", () => Effect.gen(function* () { const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "agent")) - yield* writeTextEffect( + yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "agent")) + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "agent", "ordered.md"), `--- permission: @@ -761,8 +748,8 @@ Ordered permissions`, it.instance("loads agents from .opencode/agents (plural)", () => Effect.gen(function* () { const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "agents", "nested")) - yield* writeTextEffect( + yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "agents", "nested")) + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "agents", "helper.md"), `--- model: test/model @@ -771,7 +758,7 @@ mode: subagent Helper agent prompt`, ) - yield* writeTextEffect( + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "agents", "nested", "child.md"), `--- model: test/model @@ -801,8 +788,8 @@ Nested agent prompt`, it.instance("loads commands from .opencode/command (singular)", () => Effect.gen(function* () { const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "command", "nested")) - yield* writeTextEffect( + yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "command", "nested")) + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "command", "hello.md"), `--- description: Test command @@ -810,7 +797,7 @@ description: Test command Hello from singular command`, ) - yield* writeTextEffect( + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "command", "nested", "child.md"), `--- description: Nested command @@ -835,8 +822,8 @@ Nested command template`, it.instance("loads commands from .opencode/commands (plural)", () => Effect.gen(function* () { const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "commands", "nested")) - yield* writeTextEffect( + yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "commands", "nested")) + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "commands", "hello.md"), `--- description: Test command @@ -844,7 +831,7 @@ description: Test command Hello from plural commands`, ) - yield* writeTextEffect( + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "commands", "nested", "child.md"), `--- description: Nested command @@ -873,10 +860,8 @@ it.instance("updates config and writes to file", () => svc.update(ConfigParse.schema(Config.Info, { model: "updated/model" }, "test:config")), ) - const writtenConfig = yield* Effect.promise(() => - Filesystem.readJson<{ model: string }>(path.join(test.directory, "config.json")), - ) - expect(writtenConfig.model).toBe("updated/model") + const writtenConfig = yield* AppFileSystem.use.readJson(path.join(test.directory, "config.json")) + expect(writtenConfig).toMatchObject({ model: "updated/model" }) }), ) @@ -893,9 +878,9 @@ it.effect("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR const dir = yield* tmpdirScoped() const readonly = path.join(dir, "readonly") - yield* mkdirEffect(readonly) - yield* chmodEffect(readonly, 0o555) - yield* Effect.addFinalizer(() => chmodEffect(readonly, 0o755).pipe(Effect.ignore)) + yield* AppFileSystem.use.ensureDir(readonly) + yield* AppFileSystem.use.chmod(readonly, 0o555) + yield* Effect.addFinalizer(() => AppFileSystem.use.chmod(readonly, 0o755).pipe(Effect.ignore)) yield* withProcessEnv("OPENCODE_CONFIG_DIR", readonly, Config.use.get().pipe(provideInstanceEffect(dir))) }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), @@ -905,7 +890,7 @@ it.effect("installs dependencies in writable OPENCODE_CONFIG_DIR", () => Effect.gen(function* () { const dir = yield* tmpdirScoped() const configDir = path.join(dir, "configdir") - yield* mkdirEffect(configDir) + yield* AppFileSystem.use.ensureDir(configDir) yield* withProcessEnv( "OPENCODE_CONFIG_DIR", @@ -915,7 +900,7 @@ it.effect("installs dependencies in writable OPENCODE_CONFIG_DIR", () => ), ) - expect(yield* readTextEffect(path.join(configDir, ".gitignore"))).toContain("package-lock.json") + expect(yield* AppFileSystem.use.readFileString(path.join(configDir, ".gitignore"))).toContain("package-lock.json") }).pipe(Effect.provide(testInstanceStoreLayer), Effect.provide(CrossSpawnSpawner.defaultLayer)), ) @@ -927,12 +912,12 @@ it.instance("resolves scoped npm plugins in config", () => Effect.gen(function* () { const test = yield* TestInstance const pluginDir = path.join(test.directory, "node_modules", "@scope", "plugin") - yield* mkdirEffect(pluginDir) - yield* writeTextEffect( + yield* AppFileSystem.use.ensureDir(pluginDir) + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, "package.json"), JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2), ) - yield* writeTextEffect( + yield* AppFileSystem.use.writeWithDirs( path.join(pluginDir, "package.json"), JSON.stringify( { @@ -945,7 +930,7 @@ it.instance("resolves scoped npm plugins in config", () => 2, ), ) - yield* writeTextEffect(path.join(pluginDir, "index.js"), "export default {}\n") + yield* AppFileSystem.use.writeWithDirs(path.join(pluginDir, "index.js"), "export default {}\n") yield* writeConfigEffect(test.directory, { plugin: ["@scope/plugin"] }) const config = yield* Config.use.get() @@ -975,8 +960,8 @@ it.effect("merges plugin arrays from global and local configs", () => it.instance("does not error when only custom agent is a subagent", () => Effect.gen(function* () { const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "agent")) - yield* writeTextEffect( + yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "agent")) + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "agent", "helper.md"), `--- model: test/model @@ -1387,7 +1372,7 @@ it.instance("local .opencode config can override MCP from project config", () => }, }, }) - yield* mkdirEffect(path.join(test.directory, ".opencode")) + yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode")) yield* writeConfigEffect( path.join(test.directory, ".opencode"), { @@ -1719,8 +1704,8 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { "true", Effect.gen(function* () { const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "command")) - yield* writeTextEffect( + yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "command")) + yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "command", "test-cmd.md"), "# Test Command\nThis is a test command.", ) @@ -1749,7 +1734,7 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { { OPENCODE_CONFIG_DIR: undefined, OPENCODE_DISABLE_PROJECT_CONFIG: "true" }, Effect.gen(function* () { const test = yield* TestInstance - yield* writeTextEffect(path.join(test.directory, "CUSTOM.md"), "# Custom Instructions") + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "CUSTOM.md"), "# Custom Instructions") // The relative instruction should be skipped without error const config = yield* Config.use.get() expect(config).toBeDefined() @@ -1814,7 +1799,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { it.instance("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", () => Effect.gen(function* () { const test = yield* TestInstance - yield* writeTextEffect(path.join(test.directory, "api_key.txt"), "secret_key_from_file") + yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "api_key.txt"), "secret_key_from_file") yield* withProcessEnv( "OPENCODE_CONFIG_CONTENT", JSON.stringify({ From 8890121da05b8f88f8ee9ca0bb12f79bd81bec6e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 23 May 2026 19:32:04 -0400 Subject: [PATCH 04/11] refactor(core): centralize service use helper --- .../src/effect/service-use.ts | 7 +- packages/core/src/filesystem.ts | 40 +----------- packages/opencode/src/account/account.ts | 2 +- packages/opencode/src/account/repo.ts | 2 +- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/bus/index.ts | 2 +- packages/opencode/src/config/config.ts | 2 +- .../opencode/src/control-plane/workspace.ts | 2 +- packages/opencode/src/env/index.ts | 2 +- packages/opencode/src/file/index.ts | 2 +- packages/opencode/src/file/ripgrep.ts | 2 +- packages/opencode/src/format/index.ts | 2 +- packages/opencode/src/installation/index.ts | 2 +- packages/opencode/src/mcp/auth.ts | 2 +- packages/opencode/src/mcp/index.ts | 2 +- .../opencode/src/project/instance-store.ts | 2 +- packages/opencode/src/project/project.ts | 2 +- packages/opencode/src/provider/auth.ts | 2 +- packages/opencode/src/provider/provider.ts | 2 +- packages/opencode/src/session/compaction.ts | 2 +- packages/opencode/src/session/llm.ts | 2 +- packages/opencode/src/session/session.ts | 2 +- packages/opencode/src/share/share-next.ts | 2 +- packages/opencode/src/sync/index.ts | 2 +- packages/opencode/test/config/config.test.ts | 64 +++++++++---------- 25 files changed, 60 insertions(+), 95 deletions(-) rename packages/{opencode => core}/src/effect/service-use.ts (85%) diff --git a/packages/opencode/src/effect/service-use.ts b/packages/core/src/effect/service-use.ts similarity index 85% rename from packages/opencode/src/effect/service-use.ts rename to packages/core/src/effect/service-use.ts index a93cdecbb1..89d9acf98e 100644 --- a/packages/opencode/src/effect/service-use.ts +++ b/packages/core/src/effect/service-use.ts @@ -15,6 +15,7 @@ type ServiceUse = { } export const serviceUse = (tag: Context.Service) => { + const cache = new Map Effect.Effect>() // This is the only dynamic boundary: TypeScript knows the accessor shape, // but Proxy property names are runtime values. const access = new Proxy( @@ -22,7 +23,9 @@ export const serviceUse = (tag: Context.Service { if (typeof key !== "string") return undefined - return (...args: unknown[]) => + const cached = cache.get(key) + if (cached) return cached + const accessor = (...args: unknown[]) => tag.use((service) => { // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime. const method = service[key as keyof Shape] @@ -30,6 +33,8 @@ export const serviceUse = (tag: Context.Service Effect.Effect)(...args) }) + cache.set(key, accessor) + return accessor }, }, ) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 06170beed9..d4bfe6dba4 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -3,46 +3,10 @@ import { dirname, join, relative, resolve as pathResolve } from "path" import { realpathSync } from "fs" import * as NFS from "fs/promises" import { lookup } from "mime-types" -import { Effect, FileSystem, Layer, Schema, Context } from "effect" +import { Context, Effect, FileSystem, Layer, Schema } from "effect" import type { PlatformError } from "effect/PlatformError" import { Glob } from "./util/glob" - -type EffectMethod = (...args: ReadonlyArray) => Effect.Effect - -type ServiceUse = { - readonly [Key in keyof Shape as Shape[Key] extends EffectMethod ? Key : never]: Shape[Key] extends ( - ...args: infer Args - ) => infer Return - ? Args extends ReadonlyArray - ? Return extends Effect.Effect - ? (...args: Args) => Effect.Effect - : never - : never - : never -} - -const serviceUse = (tag: Context.Service) => { - // This is the only dynamic boundary: TypeScript knows the accessor shape, - // but Proxy property names are runtime values. - const access = new Proxy( - {}, - { - get: (_, key) => { - if (typeof key !== "string") return undefined - return (...args: unknown[]) => - tag.use((service) => { - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy keys are checked at runtime. - const method = service[key as keyof Shape] - if (typeof method !== "function") return Effect.die(new Error(`Service method not found: ${key}`)) - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- ServiceUse exposes only Effect-returning methods. - return (method as (...args: unknown[]) => Effect.Effect)(...args) - }) - }, - }, - ) - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Proxy implements the mapped accessor surface lazily. - return access as ServiceUse -} +import { serviceUse } from "./effect/service-use" export namespace AppFileSystem { export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", { diff --git a/packages/opencode/src/account/account.ts b/packages/opencode/src/account/account.ts index dcff82664b..2d855e0e95 100644 --- a/packages/opencode/src/account/account.ts +++ b/packages/opencode/src/account/account.ts @@ -1,5 +1,5 @@ import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, Context } from "effect" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { FetchHttpClient, HttpClient, diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index a5291e8283..052f1cbd65 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -1,5 +1,5 @@ import { eq } from "drizzle-orm" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Effect, Layer, Option, Schema, Context } from "effect" import { Database } from "@/storage/db" diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index f7e2c134a3..064a59f59e 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -1,5 +1,5 @@ import { Config } from "@/config/config" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "../provider/schema" import { generateObject, streamObject, type ModelMessage } from "ai" diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts index b5f4320bda..73ec18d73b 100644 --- a/packages/opencode/src/bus/index.ts +++ b/packages/opencode/src/bus/index.ts @@ -5,7 +5,7 @@ import { BusEvent } from "./bus-event" import { GlobalBus } from "./global" import { InstanceState } from "@/effect/instance-state" import { makeRuntime } from "@/effect/run-service" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Identifier } from "@/id/id" import type { InstanceContext } from "@/project/instance-context" import { InstanceRef } from "@/effect/instance-ref" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 349b7e6a07..307b02ca4d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,5 +1,5 @@ import * as Log from "@opencode-ai/core/util/log" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import path from "path" import { pathToFileURL } from "url" import os from "os" diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index d1b4b12f63..9f44d22334 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -1,5 +1,5 @@ import { Context, Effect, FiberMap, Iterable, Layer, Schema, Stream } from "effect" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { FetchHttpClient, HttpBody, HttpClient, HttpClientError, HttpClientRequest } from "effect/unstable/http" import { Database } from "@/storage/db" import { asc } from "drizzle-orm" diff --git a/packages/opencode/src/env/index.ts b/packages/opencode/src/env/index.ts index efdfd0ac01..89ccf7d0fd 100644 --- a/packages/opencode/src/env/index.ts +++ b/packages/opencode/src/env/index.ts @@ -1,5 +1,5 @@ import { Context, Effect, Layer } from "effect" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { InstanceState } from "@/effect/instance-state" type State = Record diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts index 0f17ed2792..0992289fe2 100644 --- a/packages/opencode/src/file/index.ts +++ b/packages/opencode/src/file/index.ts @@ -1,5 +1,5 @@ import { BusEvent } from "@/bus/bus-event" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { InstanceState } from "@/effect/instance-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index c1a636782b..8b3d5cf174 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -1,5 +1,5 @@ import path from "path" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Cause, Context, Effect, Fiber, Layer, Queue, Schema, Stream } from "effect" import type { PlatformError } from "effect/PlatformError" diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index eb55b0713b..6d0311f6fa 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -1,5 +1,5 @@ import { Effect, Layer, Context, Schema } from "effect" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { ChildProcess } from "effect/unstable/process" import { AppProcess } from "@opencode-ai/core/process" import { InstanceState } from "@/effect/instance-state" diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index d96924a2a0..1f8dc116b1 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -1,5 +1,5 @@ import { Effect, Layer, Schema, Context, Stream } from "effect" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { withTransientReadRetry } from "@/util/effect-http-client" import { errorMessage } from "@/util/error" diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index d7406e5192..adddea0b35 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -1,5 +1,5 @@ import path from "path" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Global } from "@opencode-ai/core/global" import { Effect, Layer, Context, Option, Schema } from "effect" import { AppFileSystem } from "@opencode-ai/core/filesystem" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 85985876e7..efd72c0c1f 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -1,5 +1,5 @@ import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Client } from "@modelcontextprotocol/sdk/client/index.js" import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" diff --git a/packages/opencode/src/project/instance-store.ts b/packages/opencode/src/project/instance-store.ts index 8c847fd993..1f513fb1b4 100644 --- a/packages/opencode/src/project/instance-store.ts +++ b/packages/opencode/src/project/instance-store.ts @@ -1,5 +1,5 @@ import { GlobalBus } from "@/bus/global" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { WorkspaceContext } from "@/control-plane/workspace-context" import { InstanceRef } from "@/effect/instance-ref" import { disposeInstance as runDisposers } from "@/effect/instance-registry" diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index a12c2cde1f..4b4f2cdf58 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -20,7 +20,7 @@ import { AppProcess } from "@opencode-ai/core/process" import { Project as ProjectV2 } from "@opencode-ai/core/project" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AbsolutePath, NonNegativeInt, optionalOmitUndefined } from "@opencode-ai/core/schema" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { RuntimeFlags } from "@/effect/runtime-flags" const log = Log.create({ service: "project" }) diff --git a/packages/opencode/src/provider/auth.ts b/packages/opencode/src/provider/auth.ts index 214ab3bd99..a304fec540 100644 --- a/packages/opencode/src/provider/auth.ts +++ b/packages/opencode/src/provider/auth.ts @@ -1,5 +1,5 @@ import type { AuthOAuthResult, Hooks } from "@opencode-ai/plugin" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Auth } from "@/auth" import { InstanceState } from "@/effect/instance-state" import { optionalOmitUndefined } from "@opencode-ai/core/schema" diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 2140bea4df..496a2f6d2d 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -7,7 +7,7 @@ import * as Log from "@opencode-ai/core/util/log" import { Npm } from "@opencode-ai/core/npm" import { Hash } from "@opencode-ai/core/util/hash" import { Plugin } from "../plugin" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { type LanguageModelV3 } from "@ai-sdk/provider" import * as ModelsDev from "@opencode-ai/core/models-dev" import { Auth } from "../auth" diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index ef007fe74d..4f87edf64a 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -16,7 +16,7 @@ import { Effect, Layer, Context, Schema } from "effect" import * as DateTime from "effect/DateTime" import { InstanceState } from "@/effect/instance-state" import { isOverflow as overflow, usable } from "./overflow" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { RuntimeFlags } from "@/effect/runtime-flags" import { EventV2Bridge } from "@/event-v2-bridge" import { SessionEvent } from "@opencode-ai/core/session-event" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index ec87bfc446..ea2efc99d0 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,5 +1,5 @@ import { Provider } from "@/provider/provider" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import * as Log from "@opencode-ai/core/util/log" import { Context, Effect, Layer } from "effect" import * as Stream from "effect/Stream" diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 0131d44389..f75ac910d4 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -1,5 +1,5 @@ import { Slug } from "@opencode-ai/core/util/slug" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import path from "path" import { BackgroundJob } from "@/background/job" import { BusEvent } from "@/bus/bus-event" diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index 98d5c82255..ab2d9d151d 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -1,5 +1,5 @@ import type * as SDK from "@opencode-ai/sdk/v2" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { Effect, Exit, Layer, Option, Schema, Scope, Context, Stream } from "effect" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { Account } from "@/account/account" diff --git a/packages/opencode/src/sync/index.ts b/packages/opencode/src/sync/index.ts index a8858255f2..8573636615 100644 --- a/packages/opencode/src/sync/index.ts +++ b/packages/opencode/src/sync/index.ts @@ -12,7 +12,7 @@ import { EventID } from "./schema" import { Context, Effect, Layer, Schema as EffectSchema } from "effect" import type { DeepMutable } from "@opencode-ai/core/schema" import { EventV2 } from "@opencode-ai/core/event" -import { serviceUse } from "@/effect/service-use" +import { serviceUse } from "@opencode-ai/core/effect/service-use" import { InstanceState } from "@/effect/instance-state" import { RuntimeFlags } from "@/effect/runtime-flags" import { EffectBridge } from "@/effect/bridge" diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f0778a6a54..49b7643d7b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -26,11 +26,6 @@ import { import { InstanceRuntime } from "@/project/instance-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" - -/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */ -const infra = CrossSpawnSpawner.defaultLayer.pipe( - Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), -) import path from "path" import fs from "fs/promises" import { pathToFileURL } from "url" @@ -42,6 +37,11 @@ import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" +/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */ +const infra = CrossSpawnSpawner.defaultLayer.pipe( + Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), +) + const testFlock = EffectFlock.defaultLayer const unexpectedHttp = HttpClient.make((request) => @@ -148,28 +148,29 @@ async function writeConfig(dir: string, config: object, name = "opencode.json") } const writeConfigEffect = (dir: string, config: object, name = "opencode.json") => - AppFileSystem.use.writeFileString(path.join(dir, name), JSON.stringify(config)) + AppFileSystem.use.writeWithDirs(path.join(dir, name), JSON.stringify(config)) -const project = { - dir: (relative: string) => - TestInstance.use((test) => AppFileSystem.use.ensureDir(path.join(test.directory, relative))), - file: (relative: string, content: string) => - TestInstance.use((test) => AppFileSystem.use.writeWithDirs(path.join(test.directory, relative), content)), -} +const withInstanceDir = (dir: string, effect: Effect.Effect) => + effect.pipe( + Effect.provideService(TestInstance, { directory: dir }), + provideInstanceEffect(dir), + Effect.provide(testInstanceStoreLayer), + Effect.provide(CrossSpawnSpawner.defaultLayer), + ) const withGlobalConfigDir = (dir: string, effect: Effect.Effect) => Effect.acquireUseRelease( Effect.gen(function* () { const previous = Global.Path.config ;(Global.Path as { config: string }).config = dir - yield* clearEffect(true) + yield* clearEffect() return previous }), () => effect, (previous) => Effect.gen(function* () { ;(Global.Path as { config: string }).config = previous - yield* clearEffect(true) + yield* clearEffect() }), ) @@ -190,17 +191,17 @@ const withConfigTree = ( Effect.gen(function* () { const root = yield* tmpdirScoped() const directory = path.join(root, "project") - const local = path.join(directory, ".opencode") - yield* AppFileSystem.use.ensureDir(local) - if (input.global) yield* writeConfigEffect(root, schemaConfig(input.global)) - if (input.project) yield* writeConfigEffect(directory, schemaConfig(input.project)) - if (input.local) yield* writeConfigEffect(local, schemaConfig(input.local)) - return yield* effect.pipe( - Effect.provideService(TestInstance, { directory }), - provideInstanceEffect(directory), - Effect.provide(testInstanceStoreLayer), - Effect.provide(CrossSpawnSpawner.defaultLayer), + yield* Effect.all( + [ + input.global ? writeConfigEffect(root, schemaConfig(input.global)) : undefined, + input.project ? writeConfigEffect(directory, schemaConfig(input.project)) : undefined, + input.local ? writeConfigEffect(path.join(directory, ".opencode"), schemaConfig(input.local)) : undefined, + ].filter( + (effect): effect is Effect.Effect => effect !== undefined, + ), + { concurrency: "unbounded" }, ) + return yield* withInstanceDir(directory, effect) }) const wellKnown = (input: { @@ -705,7 +706,6 @@ it.instance("migrates mode field to agent field", () => it.instance("loads config from .opencode directory", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "agent")) yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "agent", "test.md"), `--- @@ -728,7 +728,6 @@ Test agent prompt`, it.instance("agent markdown permission config preserves user key order", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "agent")) yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "agent", "ordered.md"), `--- @@ -748,7 +747,6 @@ Ordered permissions`, it.instance("loads agents from .opencode/agents (plural)", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "agents", "nested")) yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "agents", "helper.md"), `--- @@ -788,7 +786,6 @@ Nested agent prompt`, it.instance("loads commands from .opencode/command (singular)", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "command", "nested")) yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "command", "hello.md"), `--- @@ -822,7 +819,6 @@ Nested command template`, it.instance("loads commands from .opencode/commands (plural)", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "commands", "nested")) yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "commands", "hello.md"), `--- @@ -912,7 +908,6 @@ it.instance("resolves scoped npm plugins in config", () => Effect.gen(function* () { const test = yield* TestInstance const pluginDir = path.join(test.directory, "node_modules", "@scope", "plugin") - yield* AppFileSystem.use.ensureDir(pluginDir) yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, "package.json"), JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2), @@ -960,7 +955,6 @@ it.effect("merges plugin arrays from global and local configs", () => it.instance("does not error when only custom agent is a subagent", () => Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "agent")) yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "agent", "helper.md"), `--- @@ -1671,8 +1665,11 @@ describe("deduplicatePluginOrigins", () => { withConfigTree( { global: { plugin: ["my-plugin@1.0.0"] } }, Effect.gen(function* () { - yield* project.dir(".opencode/plugin") - yield* project.file(".opencode/plugin/my-plugin.js", "export default {}") + const test = yield* TestInstance + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "plugin", "my-plugin.js"), + "export default {}", + ) const plugins = (yield* Config.use.get()).plugin ?? [] expect(plugins.some((p) => ConfigPlugin.pluginSpecifier(p) === "my-plugin@1.0.0")).toBe(true) @@ -1704,7 +1701,6 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { "true", Effect.gen(function* () { const test = yield* TestInstance - yield* AppFileSystem.use.ensureDir(path.join(test.directory, ".opencode", "command")) yield* AppFileSystem.use.writeWithDirs( path.join(test.directory, ".opencode", "command", "test-cmd.md"), "# Test Command\nThis is a test command.", From ed95ba73f4e1b1c5b3d94db88d2e7fa3aa640be2 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 23 May 2026 19:39:03 -0400 Subject: [PATCH 05/11] test(opencode): restore config coverage parity --- bun.lock | 1 - packages/desktop/package.json | 1 - packages/opencode/test/config/config.test.ts | 96 +++++++++++++++----- 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/bun.lock b/bun.lock index 3ef06eda27..659d844823 100644 --- a/bun.lock +++ b/bun.lock @@ -274,7 +274,6 @@ "@sentry/solid": "catalog:", "@sentry/vite-plugin": "catalog:", "@solid-primitives/i18n": "2.2.1", - "@solid-primitives/scheduled": "1.5.3", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.4", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index d78cd5e306..ed9041e72b 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -42,7 +42,6 @@ "@sentry/solid": "catalog:", "@sentry/vite-plugin": "catalog:", "@solid-primitives/i18n": "2.2.1", - "@solid-primitives/scheduled": "1.5.3", "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.4", diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 49b7643d7b..6ce0acdb2a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -128,6 +128,7 @@ const clear = (wait = false) => Effect.runPromise(clearEffect(wait)) // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! const originalTestToken = process.env.TEST_TOKEN +const originalConsoleToken = process.env.OPENCODE_CONSOLE_TOKEN beforeEach(async () => { await clear(true) @@ -137,6 +138,8 @@ afterEach(async () => { await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) if (originalTestToken === undefined) delete process.env.TEST_TOKEN else process.env.TEST_TOKEN = originalTestToken + if (originalConsoleToken === undefined) delete process.env.OPENCODE_CONSOLE_TOKEN + else process.env.OPENCODE_CONSOLE_TOKEN = originalConsoleToken await clear(true) }) @@ -163,14 +166,14 @@ const withGlobalConfigDir = (dir: string, effect: Effect.Effect effect, (previous) => Effect.gen(function* () { ;(Global.Path as { config: string }).config = previous - yield* clearEffect() + yield* clearEffect(true) }), ) @@ -190,10 +193,11 @@ const withConfigTree = ( ) => Effect.gen(function* () { const root = yield* tmpdirScoped() + const global = yield* tmpdirScoped() const directory = path.join(root, "project") yield* Effect.all( [ - input.global ? writeConfigEffect(root, schemaConfig(input.global)) : undefined, + input.global ? writeConfigEffect(global, schemaConfig(input.global)) : undefined, input.project ? writeConfigEffect(directory, schemaConfig(input.project)) : undefined, input.local ? writeConfigEffect(path.join(directory, ".opencode"), schemaConfig(input.local)) : undefined, ].filter( @@ -201,7 +205,7 @@ const withConfigTree = ( ), { concurrency: "unbounded" }, ) - return yield* withInstanceDir(directory, effect) + return yield* withGlobalConfigDir(global, withInstanceDir(directory, effect)) }) const wellKnown = (input: { @@ -952,6 +956,25 @@ it.effect("merges plugin arrays from global and local configs", () => ), ) +it.effect("global config remains global when project config is disabled", () => + withConfigTree( + { + global: { model: "global/model", plugin: ["global-plugin"] }, + project: { model: "project/model" }, + local: { model: "local/model" }, + }, + withProcessEnv( + "OPENCODE_DISABLE_PROJECT_CONFIG", + "true", + Effect.gen(function* () { + const config = yield* Config.use.get() + expect(config.model).toBe("global/model") + expect(config.plugin_origins?.find((item) => item.spec === "global-plugin")?.scope).toBe("global") + }), + ), + ), +) + it.instance("does not error when only custom agent is a subagent", () => Effect.gen(function* () { const test = yield* TestInstance @@ -1130,6 +1153,16 @@ it.instance( { config: { autoupdate: true, disabled_providers: [] } }, ) +it.instance("managed jsonc settings override managed json settings", () => + Effect.gen(function* () { + yield* writeManagedSettingsEffect({ model: "managed/json" }) + yield* writeManagedSettingsEffect({ model: "managed/jsonc" }, "opencode.jsonc") + + const config = yield* Config.use.get() + expect(config.model).toBe("managed/jsonc") + }), +) + it.instance( "missing managed settings file is not an error", Effect.gen(function* () { @@ -1439,14 +1472,16 @@ test("remote well-known config can use FetchHttpClient layer", async () => { }) try { - await provideTmpdirInstance(() => - Config.Service.use((svc) => - Effect.gen(function* () { - const config = yield* svc.get() - expect(fetchedUrl).toBe(`${server.url.origin}/.well-known/opencode`) - expect(config.mcp?.jira?.enabled).toBe(true) - }), - ), + await provideTmpdirInstance( + () => + Config.Service.use((svc) => + Effect.gen(function* () { + const config = yield* svc.get() + expect(fetchedUrl).toBe(`${server.url.origin}/.well-known/opencode`) + expect(config.mcp?.jira?.enabled).toBe(true) + }), + ), + { git: true }, ).pipe( Effect.scoped, Effect.provide( @@ -1488,6 +1523,26 @@ templatedHeaderWellKnown.it.instance("wellknown remote_config supports templated }), ) +const remotePrecedenceWellKnown = wellKnown({ + config: { + mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: false } }, + }, + remoteConfig: { url: "https://config.example.com/{env:TEST_TOKEN}/opencode.json" }, + remote: { + config: { mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } } }, + }, +}) + +remotePrecedenceWellKnown.it.instance( + "wellknown remote_config url tokens and nested config override embedded config", + () => + Effect.gen(function* () { + const config = yield* Config.use.get() + expect(remotePrecedenceWellKnown.seen.remote).toBe("https://config.example.com/test-token/opencode.json") + expect(config.mcp?.confluence?.enabled).toBe(true) + }), +) + const envIsolationWellKnown = wellKnown({ remoteConfig: { url: "https://config.example.com/opencode.json", @@ -1501,16 +1556,13 @@ const envIsolationWellKnown = wellKnown({ envIsolationWellKnown.it.instance( "wellknown token env substitution does not mutate process env", () => - withProcessEnv( - "TEST_TOKEN", - "preexisting-token", - Effect.gen(function* () { - const config = yield* Config.use.get() - expect(envIsolationWellKnown.seen.authorization).toBe("Bearer test-token") - expect(config.username).toBe("test-token") - expect(process.env.TEST_TOKEN).toBe("preexisting-token") - }), - ), + Effect.gen(function* () { + process.env.TEST_TOKEN = "preexisting-token" + const config = yield* Config.use.get() + expect(envIsolationWellKnown.seen.authorization).toBe("Bearer test-token") + expect(config.username).toBe("test-token") + expect(process.env.TEST_TOKEN).toBe("preexisting-token") + }), { git: true, config: { username: "{env:TEST_TOKEN}" } }, ) From e2fd4413c86992a69d9dac566df698fd3bdecede Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 24 May 2026 11:12:32 -0400 Subject: [PATCH 06/11] test(opencode): migrate skill fixtures to app filesystem (#29040) --- packages/opencode/test/tool/skill.test.ts | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts index 6732b42bbe..08b9adebff 100644 --- a/packages/opencode/test/tool/skill.test.ts +++ b/packages/opencode/test/tool/skill.test.ts @@ -1,4 +1,5 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Cause, Effect, Exit, Layer } from "effect" import { afterEach, describe, expect } from "bun:test" import path from "path" @@ -7,6 +8,7 @@ import type { Permission } from "../../src/permission" import type { Tool } from "@/tool/tool" import { SkillTool } from "../../src/tool/skill" import { ToolRegistry } from "@/tool/registry" +import { ModelID, ProviderID } from "../../src/provider/schema" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { SessionID, MessageID } from "../../src/session/schema" import { testEffect } from "../lib/effect" @@ -27,17 +29,16 @@ afterEach(async () => { const node = CrossSpawnSpawner.defaultLayer -const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node)) +const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node, AppFileSystem.defaultLayer)) describe("tool.skill", () => { it.live("execute returns skill content block with files", () => provideTmpdirInstance((dir) => Effect.gen(function* () { const skill = path.join(dir, ".opencode", "skill", "tool-skill") - yield* Effect.promise(() => - Bun.write( - path.join(skill, "SKILL.md"), - `--- + yield* AppFileSystem.use.writeWithDirs( + path.join(skill, "SKILL.md"), + `--- name: tool-skill description: Skill for tool tests. --- @@ -46,9 +47,8 @@ description: Skill for tool tests. Use this skill. `, - ), ) - yield* Effect.promise(() => Bun.write(path.join(skill, "scripts", "demo.txt"), "demo")) + yield* AppFileSystem.use.writeWithDirs(path.join(skill, "scripts", "demo.txt"), "demo") const home = process.env.OPENCODE_TEST_HOME process.env.OPENCODE_TEST_HOME = dir @@ -61,8 +61,8 @@ Use this skill. const registry = yield* ToolRegistry.Service const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } const tool = (yield* registry.tools({ - providerID: "opencode" as any, - modelID: "gpt-5" as any, + providerID: ProviderID.opencode, + modelID: ModelID.make("gpt-5"), agent, })).find((tool) => tool.id === SkillTool.id) if (!tool) throw new Error("Skill tool not found") @@ -105,8 +105,8 @@ Use this skill. const registry = yield* ToolRegistry.Service const agent = { name: "build", mode: "primary" as const, permission: [], options: {} } const tool = (yield* registry.tools({ - providerID: "opencode" as any, - modelID: "gpt-5" as any, + providerID: ProviderID.opencode, + modelID: ModelID.make("gpt-5"), agent, })).find((tool) => tool.id === SkillTool.id) if (!tool) throw new Error("Skill tool not found") From 84eff93bfa87bbc0dcbd268c308b7a713d608e8b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 24 May 2026 11:15:39 -0400 Subject: [PATCH 07/11] test(opencode): migrate tool registry fixtures (#29042) --- packages/opencode/src/tool/registry.ts | 3 + packages/opencode/test/tool/registry.test.ts | 419 ++++++++----------- 2 files changed, 185 insertions(+), 237 deletions(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 6ef6d39a65..3ea9571d45 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -55,6 +55,7 @@ import { Reference } from "@/reference/reference" import { BackgroundJob } from "@/background/job" import { SessionStatus } from "@/session/status" import { RuntimeFlags } from "@/effect/runtime-flags" +import { serviceUse } from "@opencode-ai/core/effect/service-use" const log = Log.create({ service: "tool.registry" }) @@ -81,6 +82,8 @@ export interface Interface { export class Service extends Context.Service()("@opencode/ToolRegistry") {} +export const use = serviceUse(Service) + export const layer: Layer.Layer< Service, never, diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index d3549e66f3..eb1eb1adbb 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -95,15 +95,25 @@ const brokenPluginLayer = Layer.succeed( }), ) -const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer)) +const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer, AppFileSystem.defaultLayer)) const scout = testEffect( - Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer), + Layer.mergeAll( + registryLayer({ flags: { experimentalScout: true } }), + node, + Agent.defaultLayer, + AppFileSystem.defaultLayer, + ), ) const background = testEffect( - Layer.mergeAll(registryLayer({ flags: { experimentalBackgroundSubagents: true } }), node, Agent.defaultLayer), + Layer.mergeAll( + registryLayer({ flags: { experimentalBackgroundSubagents: true } }), + node, + Agent.defaultLayer, + AppFileSystem.defaultLayer, + ), ) const withBrokenPlugin = testEffect( - Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer), + Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer, AppFileSystem.defaultLayer), ) afterEach(async () => { @@ -113,8 +123,7 @@ afterEach(async () => { describe("tool.registry", () => { it.instance("hides repo research tools unless experimental", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).not.toContain("repo_clone") expect(ids).not.toContain("repo_overview") @@ -123,8 +132,7 @@ describe("tool.registry", () => { scout.instance("shows repo research tools when experimental scout is enabled", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("repo_clone") expect(ids).toContain("repo_overview") @@ -133,8 +141,7 @@ describe("tool.registry", () => { it.instance("hides task_status unless experimental background subagents are enabled", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).not.toContain("task_status") }), @@ -142,11 +149,10 @@ describe("tool.registry", () => { it.instance("hides task background parameter unless experimental background subagents are enabled", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service const agent = yield* Agent.Service const build = yield* agent.get("build") if (!build) throw new Error("build agent not found") - const task = (yield* registry.tools({ + const task = (yield* ToolRegistry.use.tools({ providerID: ProviderID.opencode, modelID: ModelID.make("test"), agent: build, @@ -159,8 +165,7 @@ describe("tool.registry", () => { background.instance("shows task_status when experimental background subagents are enabled", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("task_status") }), @@ -169,26 +174,20 @@ describe("tool.registry", () => { it.instance("loads tools from .opencode/tool (singular)", () => Effect.gen(function* () { const test = yield* TestInstance - const opencode = path.join(test.directory, ".opencode") - const tool = path.join(opencode, "tool") - yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tool, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tool", "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("hello") }), ) @@ -196,25 +195,20 @@ describe("tool.registry", () => { it.instance("ignores non-tool exports in .opencode/tool files", () => Effect.gen(function* () { const test = yield* TestInstance - const tool = path.join(test.directory, ".opencode", "tool") - yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tool, "mixed.ts"), - [ - "export const helper = 'not a tool'", - "export default {", - " description: 'mixed tool',", - " args: {},", - " execute: async () => 'ok',", - "}", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tool", "mixed.ts"), + [ + "export const helper = 'not a tool'", + "export default {", + " description: 'mixed tool',", + " args: {},", + " execute: async () => 'ok',", + "}", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("mixed") expect(ids).not.toContain("mixed_helper") }), @@ -229,28 +223,23 @@ describe("tool.registry", () => { it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () => Effect.gen(function* () { const test = yield* TestInstance - const tool = path.join(test.directory, ".opencode", "tool") - yield* Effect.promise(() => fs.mkdir(tool, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tool, "noargs.ts"), - [ - "export default {", - " description: 'tool with no args',", - " args: undefined,", - " execute: async () => 'ok',", - "}", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tool", "noargs.ts"), + [ + "export default {", + " description: 'tool with no args',", + " args: undefined,", + " execute: async () => 'ok',", + "}", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() // Built-in tools must still load — a single malformed custom tool must // not poison the whole registry. expect(ids).toContain("read") - const loaded = (yield* registry.all()).find((t) => t.id === "noargs") + const loaded = (yield* ToolRegistry.use.all()).find((t) => t.id === "noargs") if (!loaded) throw new Error("noargs tool was not loaded") expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} }) }), @@ -264,8 +253,7 @@ describe("tool.registry", () => { // protection. withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () => Effect.gen(function* () { - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("read") expect(ids).toContain("broken_plugin_tool") }), @@ -274,26 +262,20 @@ describe("tool.registry", () => { it.instance("loads tools from .opencode/tools (plural)", () => Effect.gen(function* () { const test = yield* TestInstance - const opencode = path.join(test.directory, ".opencode") - const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "hello.ts"), - [ - "export default {", - " description: 'hello tool',", - " args: {},", - " execute: async () => {", - " return 'hello world'", - " },", - "}", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tools", "hello.ts"), + [ + "export default {", + " description: 'hello tool',", + " args: {},", + " execute: async () => {", + " return 'hello world'", + " },", + "}", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("hello") }), ) @@ -301,26 +283,21 @@ describe("tool.registry", () => { it.instance("loads Zod-schema custom tools with JSON Schema and validation", () => Effect.gen(function* () { const test = yield* TestInstance - const customTools = path.join(test.directory, ".opencode", "tools") const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href - yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(customTools, "sql.ts"), - [ - `import { tool } from ${JSON.stringify(pluginTool)}`, - "export default tool({", - " description: 'query database',", - " args: { query: tool.schema.string().describe('SQL query to execute') },", - " execute: async ({ query }) => query,", - "})", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tools", "sql.ts"), + [ + `import { tool } from ${JSON.stringify(pluginTool)}`, + "export default tool({", + " description: 'query database',", + " args: { query: tool.schema.string().describe('SQL query to execute') },", + " execute: async ({ query }) => query,", + "})", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const loaded = (yield* registry.all()).find((tool) => tool.id === "sql") + const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "sql") if (!loaded) throw new Error("custom sql tool was not loaded") expect(loaded?.jsonSchema).toMatchObject({ type: "object", @@ -333,7 +310,7 @@ describe("tool.registry", () => { expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false) const agents = yield* Agent.Service - const promptTools = yield* registry.tools({ + const promptTools = yield* ToolRegistry.use.tools({ providerID: ProviderID.opencode, modelID: ModelID.make("test"), agent: yield* agents.defaultInfo(), @@ -357,53 +334,44 @@ describe("tool.registry", () => { const opencode = path.join(test.directory, ".opencode") const customTools = path.join(opencode, "tools") const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin") - yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true })) - yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) yield* Effect.promise(() => fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), { dereference: true, recursive: true, }), ) - yield* Effect.promise(() => - Bun.write( - path.join(plugin, "package.json"), - JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(plugin, "package.json"), + JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }), ) - yield* Effect.promise(() => - Bun.write( - path.join(plugin, "dist", "index.js"), - [ - "import { z } from 'zod'", - "export function tool(input) {", - " return input", - "}", - "tool.schema = z", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(plugin, "dist", "index.js"), + [ + "import { z } from 'zod'", + "export function tool(input) {", + " return input", + "}", + "tool.schema = z", + "", + ].join("\n"), ) - yield* Effect.promise(() => - Bun.write( - path.join(customTools, "addition.ts"), - [ - 'import { tool } from "@opencode-ai/plugin"', - "export default tool({", - " description: 'Use this tool to add two numbers and return their sum.',", - " args: {", - " left: tool.schema.number().describe('The first number to add'),", - " right: tool.schema.number().describe('The second number to add'),", - " },", - " execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,", - "})", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(customTools, "addition.ts"), + [ + 'import { tool } from "@opencode-ai/plugin"', + "export default tool({", + " description: 'Use this tool to add two numbers and return their sum.',", + " args: {", + " left: tool.schema.number().describe('The first number to add'),", + " right: tool.schema.number().describe('The second number to add'),", + " },", + " execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,", + "})", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const loaded = (yield* registry.all()).find((tool) => tool.id === "addition") + const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "addition") if (!loaded) throw new Error("custom addition tool was not loaded") expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ @@ -419,29 +387,24 @@ describe("tool.registry", () => { it.instance("preserves attachments from structured custom tool results", () => Effect.gen(function* () { const test = yield* TestInstance - const customTools = path.join(test.directory, ".opencode", "tools") const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href - yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(customTools, "image.ts"), - [ - `import { tool } from ${JSON.stringify(pluginTool)}`, - "export default tool({", - " description: 'image tool',", - " args: {},", - " execute: async () => ({", - " output: 'here is an image',", - " attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],", - " }),", - "})", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tools", "image.ts"), + [ + `import { tool } from ${JSON.stringify(pluginTool)}`, + "export default tool({", + " description: 'image tool',", + " args: {},", + " execute: async () => ({", + " output: 'here is an image',", + " attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],", + " }),", + "})", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const loaded = (yield* registry.all()).find((tool) => tool.id === "image") + const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "image") if (!loaded) throw new Error("custom image tool was not loaded") const agents = yield* Agent.Service const result = yield* loaded.execute({}, { @@ -464,24 +427,19 @@ describe("tool.registry", () => { it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () => Effect.gen(function* () { const test = yield* TestInstance - const tools = path.join(test.directory, ".opencode", "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "legacy.ts"), - [ - "export default {", - " description: 'legacy schema tool',", - " args: { text: { type: 'string', description: 'Text to render' } },", - " execute: async ({ text }) => text,", - "}", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, ".opencode", "tools", "legacy.ts"), + [ + "export default {", + " description: 'legacy schema tool',", + " args: { text: { type: 'string', description: 'Text to render' } },", + " execute: async ({ text }) => text,", + "}", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy") + const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "legacy") if (!loaded) throw new Error("legacy custom tool was not loaded") expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({ type: "object", @@ -498,73 +456,60 @@ describe("tool.registry", () => { const test = yield* TestInstance const opencode = path.join(test.directory, ".opencode") const tools = path.join(opencode, "tools") - yield* Effect.promise(() => fs.mkdir(tools, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package.json"), - JSON.stringify({ - name: "custom-tools", - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, - }), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(opencode, "package.json"), + JSON.stringify({ + name: "custom-tools", + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", + }, + }), ) - yield* Effect.promise(() => - Bun.write( - path.join(opencode, "package-lock.json"), - JSON.stringify({ - name: "custom-tools", - lockfileVersion: 3, - packages: { - "": { - dependencies: { - "@opencode-ai/plugin": "^0.0.0", - cowsay: "^1.6.0", - }, + yield* AppFileSystem.use.writeWithDirs( + path.join(opencode, "package-lock.json"), + JSON.stringify({ + name: "custom-tools", + lockfileVersion: 3, + packages: { + "": { + dependencies: { + "@opencode-ai/plugin": "^0.0.0", + cowsay: "^1.6.0", }, }, - }), - ), + }, + }), ) const cowsay = path.join(opencode, "node_modules", "cowsay") - yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "package.json"), - JSON.stringify({ - name: "cowsay", - type: "module", - exports: "./index.js", - }), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(cowsay, "package.json"), + JSON.stringify({ + name: "cowsay", + type: "module", + exports: "./index.js", + }), ) - yield* Effect.promise(() => - Bun.write( - path.join(cowsay, "index.js"), - ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(cowsay, "index.js"), + ["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"), ) - yield* Effect.promise(() => - Bun.write( - path.join(tools, "cowsay.ts"), - [ - "import { say } from 'cowsay'", - "export default {", - " description: 'tool that imports cowsay at top level',", - " args: { text: { type: 'string' } },", - " execute: async ({ text }: { text: string }) => {", - " return say({ text })", - " },", - "}", - "", - ].join("\n"), - ), + yield* AppFileSystem.use.writeWithDirs( + path.join(tools, "cowsay.ts"), + [ + "import { say } from 'cowsay'", + "export default {", + " description: 'tool that imports cowsay at top level',", + " args: { text: { type: 'string' } },", + " execute: async ({ text }: { text: string }) => {", + " return say({ text })", + " },", + "}", + "", + ].join("\n"), ) - const registry = yield* ToolRegistry.Service - const ids = yield* registry.ids() + const ids = yield* ToolRegistry.use.ids() expect(ids).toContain("cowsay") }), ) From 47560358de3935c019fc4c810adbc94322ca2de3 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 24 May 2026 11:17:56 -0400 Subject: [PATCH 08/11] test(opencode): migrate file fixtures to app filesystem (#29043) --- packages/opencode/test/file/index.test.ts | 138 ++++++++++------------ 1 file changed, 63 insertions(+), 75 deletions(-) diff --git a/packages/opencode/test/file/index.test.ts b/packages/opencode/test/file/index.test.ts index b7d531c63d..eb0c61a873 100644 --- a/packages/opencode/test/file/index.test.ts +++ b/packages/opencode/test/file/index.test.ts @@ -3,7 +3,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem" import { $ } from "bun" import { Cause, Effect, Exit, Layer } from "effect" import path from "path" -import fs from "fs/promises" import { File } from "../../src/file" import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -48,6 +47,11 @@ const gitAddAll = (directory: string) => Effect.promise(() => $`git add .`.cwd(d const gitCommit = (directory: string, message: string) => Effect.promise(() => $`git commit -m ${message}`.cwd(directory).quiet()) +const writeFixtureFile = (directory: string, file: string, content: string | Uint8Array) => + AppFileSystem.use.writeWithDirs(path.join(directory, file), content) + +const removeFixtureFile = (directory: string, file: string) => AppFileSystem.use.remove(path.join(directory, file)) + const failureMessage = (self: Effect.Effect) => Effect.gen(function* () { const exit = yield* self.pipe(Effect.exit) @@ -72,7 +76,7 @@ describe("file/index Filesystem patterns", () => { it.instance("reads text file via Filesystem.readText()", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "Hello World", "utf-8")) + yield* writeFixtureFile(test.directory, "test.txt", "Hello World") const result = yield* read("test.txt") expect(result.type).toBe("text") @@ -91,9 +95,7 @@ describe("file/index Filesystem patterns", () => { it.instance("trims whitespace from text content", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => - fs.writeFile(path.join(test.directory, "test.txt"), " content with spaces \n\n", "utf-8"), - ) + yield* writeFixtureFile(test.directory, "test.txt", " content with spaces \n\n") const result = yield* read("test.txt") expect(result.content).toBe("content with spaces") @@ -103,7 +105,7 @@ describe("file/index Filesystem patterns", () => { it.instance("handles empty text file", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "empty.txt"), "", "utf-8")) + yield* writeFixtureFile(test.directory, "empty.txt", "") const result = yield* read("empty.txt") expect(result.type).toBe("text") @@ -114,9 +116,7 @@ describe("file/index Filesystem patterns", () => { it.instance("handles multi-line text files", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => - fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8"), - ) + yield* writeFixtureFile(test.directory, "multiline.txt", "line1\nline2\nline3") const result = yield* read("multiline.txt") expect(result.content).toBe("line1\nline2\nline3") @@ -129,7 +129,7 @@ describe("file/index Filesystem patterns", () => { Effect.gen(function* () { const test = yield* TestInstance const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "image.png"), binaryContent)) + yield* writeFixtureFile(test.directory, "image.png", binaryContent) const result = yield* read("image.png") expect(result.type).toBe("text") @@ -142,9 +142,7 @@ describe("file/index Filesystem patterns", () => { it.instance("returns empty for binary non-image files", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => - fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46])), - ) + yield* writeFixtureFile(test.directory, "binary.so", Buffer.from([0x7f, 0x45, 0x4c, 0x46])) const result = yield* read("binary.so") expect(result.type).toBe("binary") @@ -158,7 +156,7 @@ describe("file/index Filesystem patterns", () => { Effect.gen(function* () { const test = yield* TestInstance const filepath = path.join(test.directory, "test.json") - yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8")) + yield* AppFileSystem.use.writeWithDirs(filepath, '{"key": "value"}') expect(AppFileSystem.mimeType(filepath)).toContain("application/json") @@ -179,7 +177,7 @@ describe("file/index Filesystem patterns", () => { for (const testCase of testCases) { const filepath = path.join(test.directory, `test.${testCase.ext}`) - yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]))) + yield* AppFileSystem.use.writeWithDirs(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00])) expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime) } }), @@ -288,9 +286,7 @@ describe("file/index Filesystem patterns", () => { it.instance("treats .ts files as text", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => - fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8"), - ) + yield* writeFixtureFile(test.directory, "test.ts", "export const value = 1") const result = yield* read("test.ts") expect(result.type).toBe("text") @@ -301,9 +297,7 @@ describe("file/index Filesystem patterns", () => { it.instance("treats .mts files as text", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => - fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8"), - ) + yield* writeFixtureFile(test.directory, "test.mts", "export const value = 1") const result = yield* read("test.mts") expect(result.type).toBe("text") @@ -314,9 +308,7 @@ describe("file/index Filesystem patterns", () => { it.instance("treats .sh files as text", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => - fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8"), - ) + yield* writeFixtureFile(test.directory, "test.sh", "#!/usr/bin/env bash\necho hello") const result = yield* read("test.sh") expect(result.type).toBe("text") @@ -327,7 +319,7 @@ describe("file/index Filesystem patterns", () => { it.instance("treats Dockerfile as text", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "Dockerfile"), "FROM alpine:3.20", "utf-8")) + yield* writeFixtureFile(test.directory, "Dockerfile", "FROM alpine:3.20") const result = yield* read("Dockerfile") expect(result.type).toBe("text") @@ -338,7 +330,7 @@ describe("file/index Filesystem patterns", () => { it.instance("returns encoding info for text files", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "simple text", "utf-8")) + yield* writeFixtureFile(test.directory, "test.txt", "simple text") const result = yield* read("test.txt") expect(result.encoding).toBeUndefined() @@ -349,9 +341,7 @@ describe("file/index Filesystem patterns", () => { it.instance("returns base64 encoding for images", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => - fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0])), - ) + yield* writeFixtureFile(test.directory, "test.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xe0])) const result = yield* read("test.jpg") expect(result.encoding).toBe("base64") @@ -381,10 +371,10 @@ describe("file/index Filesystem patterns", () => { Effect.gen(function* () { const test = yield* TestInstance const filepath = path.join(test.directory, "file.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "original\n", "utf-8")) + yield* AppFileSystem.use.writeWithDirs(filepath, "original\n") yield* gitAddAll(test.directory) yield* gitCommit(test.directory, "add file") - yield* Effect.promise(() => fs.writeFile(filepath, "modified\nextra line\n", "utf-8")) + yield* AppFileSystem.use.writeWithDirs(filepath, "modified\nextra line\n") const result = yield* status() const entry = result.find((file) => file.path === "file.txt") @@ -401,9 +391,7 @@ describe("file/index Filesystem patterns", () => { () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => - fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8"), - ) + yield* writeFixtureFile(test.directory, "new.txt", "line1\nline2\nline3\n") const result = yield* status() const entry = result.find((file) => file.path === "new.txt") @@ -421,10 +409,10 @@ describe("file/index Filesystem patterns", () => { Effect.gen(function* () { const test = yield* TestInstance const filepath = path.join(test.directory, "gone.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "content\n", "utf-8")) + yield* AppFileSystem.use.writeWithDirs(filepath, "content\n") yield* gitAddAll(test.directory) yield* gitCommit(test.directory, "add file") - yield* Effect.promise(() => fs.rm(filepath)) + yield* AppFileSystem.use.remove(filepath) const result = yield* status() const entries = result.filter((file) => file.path === "gone.txt") @@ -438,14 +426,14 @@ describe("file/index Filesystem patterns", () => { () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "keep\n", "utf-8")) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "remove.txt"), "remove\n", "utf-8")) + yield* writeFixtureFile(test.directory, "keep.txt", "keep\n") + yield* writeFixtureFile(test.directory, "remove.txt", "remove\n") yield* gitAddAll(test.directory) yield* gitCommit(test.directory, "initial") - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "changed\n", "utf-8")) - yield* Effect.promise(() => fs.rm(path.join(test.directory, "remove.txt"))) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "brand-new.txt"), "hello\n", "utf-8")) + yield* writeFixtureFile(test.directory, "keep.txt", "changed\n") + yield* removeFixtureFile(test.directory, "remove.txt") + yield* writeFixtureFile(test.directory, "brand-new.txt", "hello\n") const result = yield* status() expect(result.some((file) => file.path === "keep.txt" && file.status === "modified")).toBe(true) @@ -476,13 +464,15 @@ describe("file/index Filesystem patterns", () => { Effect.gen(function* () { const test = yield* TestInstance const filepath = path.join(test.directory, "data.bin") - yield* Effect.promise(() => - fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index))), + yield* AppFileSystem.use.writeWithDirs( + filepath, + Buffer.from(Array.from({ length: 256 }, (_, index) => index)), ) yield* gitAddAll(test.directory) yield* gitCommit(test.directory, "add binary") - yield* Effect.promise(() => - fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256))), + yield* AppFileSystem.use.writeWithDirs( + filepath, + Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256)), ) const result = yield* status() @@ -502,11 +492,9 @@ describe("file/index Filesystem patterns", () => { () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "subdir"))) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "content", "utf-8")) - yield* Effect.promise(() => - fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8"), - ) + yield* AppFileSystem.use.ensureDir(path.join(test.directory, "subdir")) + yield* writeFixtureFile(test.directory, "file.txt", "content") + yield* writeFixtureFile(test.directory, path.join("subdir", "nested.txt"), "nested") const nodes = yield* list() expect(nodes.length).toBeGreaterThanOrEqual(2) @@ -527,10 +515,10 @@ describe("file/index Filesystem patterns", () => { () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "beta"))) - yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "alpha"))) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "zz.txt"), "", "utf-8")) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "aa.txt"), "", "utf-8")) + yield* AppFileSystem.use.ensureDir(path.join(test.directory, "beta")) + yield* AppFileSystem.use.ensureDir(path.join(test.directory, "alpha")) + yield* writeFixtureFile(test.directory, "zz.txt", "") + yield* writeFixtureFile(test.directory, "aa.txt", "") const nodes = yield* list() const dirs = nodes.filter((node) => node.type === "directory") @@ -551,8 +539,8 @@ describe("file/index Filesystem patterns", () => { () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".DS_Store"), "", "utf-8")) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "visible.txt"), "", "utf-8")) + yield* writeFixtureFile(test.directory, ".DS_Store", "") + yield* writeFixtureFile(test.directory, "visible.txt", "") const names = (yield* list()).map((node) => node.name) expect(names).not.toContain(".git") @@ -567,10 +555,10 @@ describe("file/index Filesystem patterns", () => { () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".gitignore"), "*.log\nbuild/\n", "utf-8")) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "app.log"), "log data", "utf-8")) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "main.ts"), "code", "utf-8")) - yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "build"))) + yield* writeFixtureFile(test.directory, ".gitignore", "*.log\nbuild/\n") + yield* writeFixtureFile(test.directory, "app.log", "log data") + yield* writeFixtureFile(test.directory, "main.ts", "code") + yield* AppFileSystem.use.ensureDir(path.join(test.directory, "build")) const nodes = yield* list() expect(nodes.find((node) => node.name === "app.log")?.ignored).toBe(true) @@ -585,9 +573,9 @@ describe("file/index Filesystem patterns", () => { () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "sub"))) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "a.txt"), "", "utf-8")) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "b.txt"), "", "utf-8")) + yield* AppFileSystem.use.ensureDir(path.join(test.directory, "sub")) + yield* writeFixtureFile(test.directory, path.join("sub", "a.txt"), "") + yield* writeFixtureFile(test.directory, path.join("sub", "b.txt"), "") const nodes = yield* list("sub") expect(nodes.length).toBe(2) @@ -609,7 +597,7 @@ describe("file/index Filesystem patterns", () => { it.instance("works without git", () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "hi", "utf-8")) + yield* writeFixtureFile(test.directory, "file.txt", "hi") const nodes = yield* list() expect(nodes.length).toBeGreaterThanOrEqual(1) @@ -755,7 +743,7 @@ describe("file/index Filesystem patterns", () => { yield* init() expect(yield* search({ query: "fresh", type: "file" })).toEqual([]) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "fresh.ts"), "fresh", "utf-8")) + yield* writeFixtureFile(test.directory, "fresh.ts", "fresh") expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts") }), @@ -770,10 +758,10 @@ describe("file/index Filesystem patterns", () => { Effect.gen(function* () { const test = yield* TestInstance const filepath = path.join(test.directory, "file.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "original content\n", "utf-8")) + yield* AppFileSystem.use.writeWithDirs(filepath, "original content\n") yield* gitAddAll(test.directory) yield* gitCommit(test.directory, "add file") - yield* Effect.promise(() => fs.writeFile(filepath, "modified content\n", "utf-8")) + yield* AppFileSystem.use.writeWithDirs(filepath, "modified content\n") const result = yield* read("file.txt") expect(result.type).toBe("text") @@ -793,10 +781,10 @@ describe("file/index Filesystem patterns", () => { Effect.gen(function* () { const test = yield* TestInstance const filepath = path.join(test.directory, "staged.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8")) + yield* AppFileSystem.use.writeWithDirs(filepath, "before\n") yield* gitAddAll(test.directory) yield* gitCommit(test.directory, "add file") - yield* Effect.promise(() => fs.writeFile(filepath, "after\n", "utf-8")) + yield* AppFileSystem.use.writeWithDirs(filepath, "after\n") yield* gitAddAll(test.directory) const result = yield* read("staged.txt") @@ -812,7 +800,7 @@ describe("file/index Filesystem patterns", () => { Effect.gen(function* () { const test = yield* TestInstance const filepath = path.join(test.directory, "clean.txt") - yield* Effect.promise(() => fs.writeFile(filepath, "unchanged\n", "utf-8")) + yield* AppFileSystem.use.writeWithDirs(filepath, "unchanged\n") yield* gitAddAll(test.directory) yield* gitCommit(test.directory, "add file") @@ -832,14 +820,14 @@ describe("file/index Filesystem patterns", () => { () => Effect.gen(function* () { const one = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(one.directory, "a.ts"), "one", "utf-8")) + yield* writeFixtureFile(one.directory, "a.ts", "one") yield* init() expect(yield* search({ query: "a.ts", type: "file" })).toContain("a.ts") expect(yield* search({ query: "b.ts", type: "file" })).not.toContain("b.ts") yield* Effect.gen(function* () { const two = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(two.directory, "b.ts"), "two", "utf-8")) + yield* writeFixtureFile(two.directory, "b.ts", "two") yield* init() expect(yield* search({ query: "b.ts", type: "file" })).toContain("b.ts") expect(yield* search({ query: "a.ts", type: "file" })).not.toContain("a.ts") @@ -853,14 +841,14 @@ describe("file/index Filesystem patterns", () => { () => Effect.gen(function* () { const test = yield* TestInstance - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "before.ts"), "before", "utf-8")) + yield* writeFixtureFile(test.directory, "before.ts", "before") yield* init() expect(yield* search({ query: "before", type: "file" })).toContain("before.ts") yield* Effect.promise(() => disposeAllInstances()) - yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "after.ts"), "after", "utf-8")) - yield* Effect.promise(() => fs.rm(path.join(test.directory, "before.ts"))) + yield* writeFixtureFile(test.directory, "after.ts", "after") + yield* removeFixtureFile(test.directory, "before.ts") yield* init() expect(yield* search({ query: "after", type: "file" })).toContain("after.ts") From 9d2e2af865f3c08671869789e8f47fb635b24a32 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 24 May 2026 11:19:35 -0400 Subject: [PATCH 09/11] test(project): use effect service fixtures (#29044) --- .../opencode/test/project/project.test.ts | 226 ++++++++---------- 1 file changed, 106 insertions(+), 120 deletions(-) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 869326d87a..32f876bf63 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -30,16 +30,9 @@ void Log.init({ print: false }) const encoder = new TextEncoder() -const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer) +const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer) const it = testEffect(layer) -function run(fn: (svc: Project.Interface) => Effect.Effect) { - return Effect.gen(function* () { - const svc = yield* Project.Service - return yield* fn(svc) - }) -} - function remoteProjectID(remote: string) { return ProjectID.make(Hash.fast(`git-remote:${remote}`)) } @@ -103,10 +96,18 @@ function projectLayerWithRuntimeFlags(flags: Parameters - testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer)) + testEffect( + Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer), + ) const iconDiscoveryIt = testEffect( - Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer), + Layer.mergeAll( + Layer.provideMerge( + projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), + CrossSpawnSpawner.defaultLayer, + ), + AppFileSystem.defaultLayer, + ), ) function waitForProjectIcon(id: ProjectID, attempts = 50): Effect.Effect { @@ -125,7 +126,7 @@ describe("Project.fromDirectory", () => { const tmp = yield* tmpdirScoped() yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) expect(project).toBeDefined() expect(project.id).toBe(ProjectID.global) @@ -133,7 +134,7 @@ describe("Project.fromDirectory", () => { expect(project.worktree).toBe(tmp) const opencodeFile = path.join(tmp, ".git", "opencode") - expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false) + expect(yield* AppFileSystem.use.existsSafe(opencodeFile)).toBe(false) }), ) @@ -141,7 +142,7 @@ describe("Project.fromDirectory", () => { Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) expect(project).toBeDefined() expect(project.id).not.toBe(ProjectID.global) @@ -153,7 +154,7 @@ describe("Project.fromDirectory", () => { it.live("returns global for non-git directory", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped() - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) expect(project.id).toBe(ProjectID.global) }), ) @@ -161,8 +162,8 @@ describe("Project.fromDirectory", () => { it.live("derives stable project ID from root commit", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project: a } = yield* run((svc) => svc.fromDirectory(tmp)) - const { project: b } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project: a } = yield* Project.use.fromDirectory(tmp) + const { project: b } = yield* Project.use.fromDirectory(tmp) expect(b.id).toBe(a.id) }), ) @@ -172,7 +173,7 @@ describe("Project.fromDirectory", () => { const tmp = yield* tmpdirScoped({ git: true }) yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet()) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo")) }), @@ -185,8 +186,8 @@ describe("Project.fromDirectory", () => { yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet()) yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet()) - const { project: a } = yield* run((svc) => svc.fromDirectory(ssh)) - const { project: b } = yield* run((svc) => svc.fromDirectory(https)) + const { project: a } = yield* Project.use.fromDirectory(ssh) + const { project: b } = yield* Project.use.fromDirectory(https) expect(a.id).toBe(remoteProjectID("github.com/owner/repo")) expect(b.id).toBe(a.id) @@ -196,8 +197,7 @@ describe("Project.fromDirectory", () => { it.live("migrates cached root project data when origin becomes available", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const projects = yield* Project.Service - const { project: rootProject } = yield* projects.fromDirectory(tmp) + const { project: rootProject } = yield* Project.use.fromDirectory(tmp) const remoteID = remoteProjectID("github.com/acme/app") const sessionID = crypto.randomUUID() as SessionID const workspaceID = WorkspaceID.ascending() @@ -236,7 +236,7 @@ describe("Project.fromDirectory", () => { }) yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet()) - const { project } = yield* projects.fromDirectory(tmp) + const { project } = yield* Project.use.fromDirectory(tmp) expect(project.id).toBe(remoteID) expect( @@ -263,7 +263,7 @@ describe("Project.fromDirectory git failure paths", () => { yield* Effect.promise(() => $`git init`.cwd(tmp).quiet()) // rev-list fails because HEAD doesn't exist yet: this is the natural scenario. - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) expect(project.vcs).toBe("git") expect(project.id).toBe(ProjectID.global) expect(project.worktree).toBe(tmp) @@ -274,7 +274,7 @@ describe("Project.fromDirectory git failure paths", () => { Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project, sandbox } = yield* Project.use.fromDirectory(tmp) expect(project.worktree).toBe(tmp) expect(sandbox).toBe(tmp) }), @@ -284,7 +284,7 @@ describe("Project.fromDirectory git failure paths", () => { Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project, sandbox } = yield* Project.use.fromDirectory(tmp) expect(project.worktree).toBe(tmp) expect(sandbox).toBe(tmp) }), @@ -296,7 +296,7 @@ describe("Project.fromDirectory with worktrees", () => { Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project, sandbox } = yield* Project.use.fromDirectory(tmp) expect(project.worktree).toBe(tmp) expect(sandbox).toBe(tmp) @@ -319,7 +319,7 @@ describe("Project.fromDirectory with worktrees", () => { ) yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet()) - const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath)) + const { project, sandbox } = yield* Project.use.fromDirectory(worktreePath) expect(project.worktree).toBe(worktreePath) expect(sandbox).toBe(worktreePath) @@ -332,7 +332,7 @@ describe("Project.fromDirectory with worktrees", () => { Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project: main } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project: main } = yield* Project.use.fromDirectory(tmp) const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared") yield* Effect.addFinalizer(() => @@ -345,12 +345,12 @@ describe("Project.fromDirectory with worktrees", () => { ) yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet()) - const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath)) + const { project: wt } = yield* Project.use.fromDirectory(worktreePath) expect(wt.id).toBe(main.id) const cache = path.join(tmp, ".git", "opencode") - const exists = yield* Effect.promise(() => Bun.file(cache).exists()) + const exists = yield* AppFileSystem.use.existsSafe(cache) expect(exists).toBe(true) }), ) @@ -368,8 +368,8 @@ describe("Project.fromDirectory with worktrees", () => { yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet()) yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet()) - const { project: a } = yield* run((svc) => svc.fromDirectory(tmp)) - const { project: b } = yield* run((svc) => svc.fromDirectory(clone)) + const { project: a } = yield* Project.use.fromDirectory(tmp) + const { project: b } = yield* Project.use.fromDirectory(clone) expect(b.id).toBe(a.id) }), @@ -400,8 +400,8 @@ describe("Project.fromDirectory with worktrees", () => { yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet()) yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet()) - yield* run((svc) => svc.fromDirectory(worktree1)) - const { project } = yield* run((svc) => svc.fromDirectory(worktree2)) + yield* Project.use.fromDirectory(worktree1) + const { project } = yield* Project.use.fromDirectory(worktree2) expect(project.worktree).toBe(worktree1) expect(project.sandboxes).toContain(worktree2) @@ -415,9 +415,9 @@ describe("Project.discover", () => { Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) + yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) const updated = yield* waitForProjectIcon(project.id) expect(updated.icon?.url).toStartWith("data:") @@ -428,12 +428,12 @@ describe("Project.discover", () => { it.live("should discover favicon.png in root", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) + yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData) - yield* run((svc) => svc.discover(project)) + yield* Project.use.discover(project) const updated = Project.get(project.id) expect(updated).toBeDefined() @@ -447,11 +447,11 @@ describe("Project.discover", () => { it.live("should not discover non-image files", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) - yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image")) + yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.txt"), "not an image") - yield* run((svc) => svc.discover(project)) + yield* Project.use.discover(project) const updated = Project.get(project.id) expect(updated).toBeDefined() @@ -462,22 +462,20 @@ describe("Project.discover", () => { it.live("should not discover favicon when override is set", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) - yield* run((svc) => - svc.update({ - projectID: project.id, - icon: { override: "data:image/png;base64,override" }, - }), - ) + yield* Project.use.update({ + projectID: project.id, + icon: { override: "data:image/png;base64,override" }, + }) - const updatedProject = yield* run((svc) => svc.get(project.id)) + const updatedProject = yield* Project.use.get(project.id) if (!updatedProject) throw new Error("Project not found") const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData)) + yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData) - yield* run((svc) => svc.discover(updatedProject)) + yield* Project.use.discover(updatedProject) const updated = Project.get(project.id) expect(updated).toBeDefined() @@ -491,14 +489,12 @@ describe("Project.update", () => { it.live("should update name", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) - const updated = yield* run((svc) => - svc.update({ - projectID: project.id, - name: "New Project Name", - }), - ) + const updated = yield* Project.use.update({ + projectID: project.id, + name: "New Project Name", + }) expect(updated.name).toBe("New Project Name") @@ -510,14 +506,12 @@ describe("Project.update", () => { it.live("should update icon url", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) - const updated = yield* run((svc) => - svc.update({ - projectID: project.id, - icon: { url: "https://example.com/icon.png" }, - }), - ) + const updated = yield* Project.use.update({ + projectID: project.id, + icon: { url: "https://example.com/icon.png" }, + }) expect(updated.icon?.url).toBe("https://example.com/icon.png") @@ -529,14 +523,12 @@ describe("Project.update", () => { it.live("should update icon color", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) - const updated = yield* run((svc) => - svc.update({ - projectID: project.id, - icon: { color: "#ff0000" }, - }), - ) + const updated = yield* Project.use.update({ + projectID: project.id, + icon: { color: "#ff0000" }, + }) expect(updated.icon?.color).toBe("#ff0000") @@ -548,14 +540,12 @@ describe("Project.update", () => { it.live("should update icon override", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) - const updated = yield* run((svc) => - svc.update({ - projectID: project.id, - icon: { override: "data:image/png;base64,abc123" }, - }), - ) + const updated = yield* Project.use.update({ + projectID: project.id, + icon: { override: "data:image/png;base64,abc123" }, + }) expect(updated.icon?.override).toBe("data:image/png;base64,abc123") @@ -567,14 +557,12 @@ describe("Project.update", () => { it.live("should update commands", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) - const updated = yield* run((svc) => - svc.update({ - projectID: project.id, - commands: { start: "npm run dev" }, - }), - ) + const updated = yield* Project.use.update({ + projectID: project.id, + commands: { start: "npm run dev" }, + }) expect(updated.commands?.start).toBe("npm run dev") @@ -585,12 +573,12 @@ describe("Project.update", () => { it.live("should fail when project not found", () => Effect.gen(function* () { - const exit = yield* run((svc) => - svc.update({ + const exit = yield* Project.use + .update({ projectID: ProjectID.make("nonexistent-project-id"), name: "Should Fail", - }), - ).pipe(Effect.exit) + }) + .pipe(Effect.exit) expect(Exit.isFailure(exit)).toBe(true) if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause) @@ -602,7 +590,7 @@ describe("Project.update", () => { it.live("should emit GlobalBus event on update", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) let eventPayload: any = null const on = (data: any) => { @@ -611,7 +599,7 @@ describe("Project.update", () => { GlobalBus.on("event", on) yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) - yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" })) + yield* Project.use.update({ projectID: project.id, name: "Updated Name" }) expect(eventPayload).not.toBeNull() expect(eventPayload.payload.type).toBe("project.updated") @@ -622,16 +610,14 @@ describe("Project.update", () => { it.live("should update multiple fields at once", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) - const updated = yield* run((svc) => - svc.update({ - projectID: project.id, - name: "Multi Update", - icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" }, - commands: { start: "make start" }, - }), - ) + const updated = yield* Project.use.update({ + projectID: project.id, + name: "Multi Update", + icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" }, + commands: { start: "make start" }, + }) expect(updated.name).toBe("Multi Update") expect(updated.icon?.url).toBe("https://example.com/favicon.ico") @@ -646,7 +632,7 @@ describe("Project.list and Project.get", () => { it.live("list returns all projects", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) const all = Project.list() expect(all.length).toBeGreaterThan(0) @@ -657,7 +643,7 @@ describe("Project.list and Project.get", () => { it.live("get returns project by id", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) const found = Project.get(project.id) expect(found).toBeDefined() @@ -675,7 +661,7 @@ describe("Project.setInitialized", () => { it.live("sets time_initialized on project", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) expect(project.time.initialized).toBeUndefined() @@ -691,15 +677,15 @@ describe("Project.addSandbox and Project.removeSandbox", () => { it.live("addSandbox adds directory and removeSandbox removes it", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) const sandboxDir = path.join(tmp, "sandbox-test") - yield* run((svc) => svc.addSandbox(project.id, sandboxDir)) + yield* Project.use.addSandbox(project.id, sandboxDir) let found = Project.get(project.id) expect(found?.sandboxes).toContain(sandboxDir) - yield* run((svc) => svc.removeSandbox(project.id, sandboxDir)) + yield* Project.use.removeSandbox(project.id, sandboxDir) found = Project.get(project.id) expect(found?.sandboxes).not.toContain(sandboxDir) @@ -709,7 +695,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => { it.live("addSandbox emits GlobalBus event", () => Effect.gen(function* () { const tmp = yield* tmpdirScoped({ git: true }) - const { project } = yield* run((svc) => svc.fromDirectory(tmp)) + const { project } = yield* Project.use.fromDirectory(tmp) const sandboxDir = path.join(tmp, "sandbox-event") const events: any[] = [] @@ -717,7 +703,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => { GlobalBus.on("event", on) yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on))) - yield* run((svc) => svc.addSandbox(project.id, sandboxDir)) + yield* Project.use.addSandbox(project.id, sandboxDir) expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true) }), @@ -739,7 +725,7 @@ describe("Project.fromDirectory with bare repos", () => { yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) - const { project } = yield* run((svc) => svc.fromDirectory(worktreePath)) + const { project } = yield* Project.use.fromDirectory(worktreePath) expect(project.id).not.toBe(ProjectID.global) expect(project.worktree).toBe(worktreePath) @@ -747,8 +733,8 @@ describe("Project.fromDirectory with bare repos", () => { const correctCache = path.join(barePath, "opencode") const wrongCache = path.join(parentDir, ".git", "opencode") - expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true) - expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false) + expect(yield* AppFileSystem.use.existsSafe(correctCache)).toBe(true) + expect(yield* AppFileSystem.use.existsSafe(wrongCache)).toBe(false) }), ) @@ -773,8 +759,8 @@ describe("Project.fromDirectory with bare repos", () => { yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet()) yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet()) - const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA)) - const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB)) + const { project: projA } = yield* Project.use.fromDirectory(worktreeA) + const { project: projB } = yield* Project.use.fromDirectory(worktreeB) expect(projA.id).not.toBe(projB.id) @@ -782,9 +768,9 @@ describe("Project.fromDirectory with bare repos", () => { const cacheB = path.join(bareB, "opencode") const wrongCache = path.join(parentDir, ".git", "opencode") - expect(yield* Effect.promise(() => Bun.file(cacheA).exists())).toBe(true) - expect(yield* Effect.promise(() => Bun.file(cacheB).exists())).toBe(true) - expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false) + expect(yield* AppFileSystem.use.existsSafe(cacheA)).toBe(true) + expect(yield* AppFileSystem.use.existsSafe(cacheB)).toBe(true) + expect(yield* AppFileSystem.use.existsSafe(wrongCache)).toBe(false) }), ) @@ -802,13 +788,13 @@ describe("Project.fromDirectory with bare repos", () => { yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet()) yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet()) - const { project } = yield* run((svc) => svc.fromDirectory(worktreePath)) + const { project } = yield* Project.use.fromDirectory(worktreePath) expect(project.id).not.toBe(ProjectID.global) expect(project.worktree).toBe(worktreePath) const correctCache = path.join(barePath, "opencode") - expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true) + expect(yield* AppFileSystem.use.existsSafe(correctCache)).toBe(true) }), ) }) From f00a681fc506492c3b5673715ecdb839eb1fe637 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 24 May 2026 20:12:19 -0400 Subject: [PATCH 10/11] test(lsp): migrate client tests to effect fixtures (#29045) --- packages/opencode/test/lsp/client.test.ts | 556 +++++++++------------- 1 file changed, 236 insertions(+), 320 deletions(-) diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts index 93844d942f..3a4f969e45 100644 --- a/packages/opencode/test/lsp/client.test.ts +++ b/packages/opencode/test/lsp/client.test.ts @@ -1,11 +1,16 @@ -import { beforeEach, describe, expect, test } from "bun:test" +import { beforeEach, describe, expect } from "bun:test" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import path from "path" import { pathToFileURL } from "url" -import { tmpdir, withTestInstance } from "../fixture/fixture" +import { Effect } from "effect" +import { pollWithTimeout, testEffect } from "../lib/effect" +import { requireInstance, TestInstance } from "../fixture/fixture" import { LSPClient } from "@/lsp/client" import * as LSPServer from "@/lsp/server" import * as Log from "@opencode-ai/core/util/log" +const it = testEffect(AppFileSystem.defaultLayer) + function spawnFakeServer() { const { spawn } = require("child_process") const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") @@ -16,202 +21,164 @@ function spawnFakeServer() { } } +const createClient = (handle: LSPServer.Handle, initialization?: LSPServer.Handle["initialization"]) => + Effect.gen(function* () { + const test = yield* TestInstance + const instance = yield* requireInstance + return yield* Effect.promise(() => + LSPClient.create({ + serverID: "fake", + server: initialization ? { ...handle, initialization } : handle, + root: test.directory, + directory: test.directory, + instance, + }), + ) + }) + +const createScopedClient = (handle: LSPServer.Handle, initialization?: LSPServer.Handle["initialization"]) => + Effect.gen(function* () { + const client = yield* createClient(handle, initialization) + yield* Effect.addFinalizer(() => Effect.promise(() => client.shutdown()).pipe(Effect.ignore)) + return client + }) + +const writeFile = (file: string, content: string) => AppFileSystem.use.writeWithDirs(file, content) + describe("LSPClient interop", () => { beforeEach(async () => { await Log.init({ print: true }) }) - test("handles workspace/workspaceFolders request", async () => { - const handle = spawnFakeServer() as any + it.instance("handles workspace/workspaceFolders request", () => + Effect.gen(function* () { + const client = yield* createScopedClient(spawnFakeServer()) - const client = await withTestInstance({ - directory: process.cwd(), - fn: (ctx) => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - directory: process.cwd(), - instance: ctx, + yield* Effect.promise(() => + client.connection.sendNotification("test/trigger", { + method: "workspace/workspaceFolders", }), - }) + ) - await client.connection.sendNotification("test/trigger", { - method: "workspace/workspaceFolders", - }) + yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {})) + expect(client.connection).toBeDefined() + }), + ) - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(client.connection).toBeDefined() - await client.shutdown() - }) + it.instance("handles client/registerCapability request", () => + Effect.gen(function* () { + const client = yield* createScopedClient(spawnFakeServer()) - test("handles client/registerCapability request", async () => { - const handle = spawnFakeServer() as any - - const client = await withTestInstance({ - directory: process.cwd(), - fn: (ctx) => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - directory: process.cwd(), - instance: ctx, + yield* Effect.promise(() => + client.connection.sendNotification("test/trigger", { + method: "client/registerCapability", }), - }) + ) - await client.connection.sendNotification("test/trigger", { - method: "client/registerCapability", - }) + yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {})) + expect(client.connection).toBeDefined() + }), + ) - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(client.connection).toBeDefined() - await client.shutdown() - }) + it.instance("handles client/unregisterCapability request", () => + Effect.gen(function* () { + const client = yield* createScopedClient(spawnFakeServer()) - test("handles client/unregisterCapability request", async () => { - const handle = spawnFakeServer() as any - - const client = await withTestInstance({ - directory: process.cwd(), - fn: (ctx) => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - directory: process.cwd(), - instance: ctx, + yield* Effect.promise(() => + client.connection.sendNotification("test/trigger", { + method: "client/unregisterCapability", }), - }) + ) - await client.connection.sendNotification("test/trigger", { - method: "client/unregisterCapability", - }) + yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {})) + expect(client.connection).toBeDefined() + }), + ) - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(client.connection).toBeDefined() - await client.shutdown() - }) + it.instance("initialize does not overclaim unsupported diagnostics capabilities", () => + Effect.gen(function* () { + const client = yield* createScopedClient(spawnFakeServer()) - test("initialize does not overclaim unsupported diagnostics capabilities", async () => { - const handle = spawnFakeServer() as any + const params = yield* Effect.promise(() => + client.connection.sendRequest<{ + capabilities: { + workspace: { diagnostics: { refreshSupport: boolean } } + textDocument: { publishDiagnostics: { versionSupport: boolean } } + } + }>("test/get-initialize-params", {}), + ) + expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false) + expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false) + }), + ) - const client = await withTestInstance({ - directory: process.cwd(), - fn: (ctx) => - LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: process.cwd(), - directory: process.cwd(), - instance: ctx, + it.instance("workspace/configuration returns one result per requested item", () => + Effect.gen(function* () { + const initialization = { + alpha: { + beta: 1, + }, + gamma: true, + } + + const client = yield* createScopedClient(spawnFakeServer(), initialization) + + const response = yield* Effect.promise(() => + client.connection.sendRequest("test/request-configuration", { + items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}], }), - }) + ) - const params = await client.connection.sendRequest("test/get-initialize-params", {}) - expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false) - expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false) + expect(response).toEqual([{ beta: 1 }, 1, null, initialization]) + }), + ) - await client.shutdown() - }) + it.instance("sends ranged didChange for incremental sync servers", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "client.ts") + yield* writeFile(file, "first\n") - test("workspace/configuration returns one result per requested item", async () => { - const handle = spawnFakeServer() as any - const initialization = { - alpha: { - beta: 1, - }, - gamma: true, - } + const client = yield* createScopedClient(spawnFakeServer()) - const client = await withTestInstance({ - directory: process.cwd(), - fn: (ctx) => - LSPClient.create({ - serverID: "fake", - server: { - ...(handle as unknown as LSPServer.Handle), - initialization, - }, - root: process.cwd(), - directory: process.cwd(), - instance: ctx, - }), - }) + yield* Effect.promise(() => client.notify.open({ path: file })) + yield* writeFile(file, "second\nthird\n") + yield* Effect.promise(() => client.notify.open({ path: file })) - const response = await client.connection.sendRequest("test/request-configuration", { - items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}], - }) - - expect(response).toEqual([{ beta: 1 }, 1, null, initialization]) - - await client.shutdown() - }) - - test("sends ranged didChange for incremental sync servers", async () => { - const handle = spawnFakeServer() as any - await using tmp = await tmpdir() - const file = path.join(tmp.path, "client.ts") - await Bun.write(file, "first\n") - - await withTestInstance({ - directory: tmp.path, - fn: async (ctx) => { - const client = await LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: tmp.path, - directory: tmp.path, - instance: ctx, - }) - - await client.notify.open({ path: file }) - await Bun.write(file, "second\nthird\n") - await client.notify.open({ path: file }) - - const change = await client.connection.sendRequest<{ + const change = yield* Effect.promise(() => + client.connection.sendRequest<{ textDocument: { version: number } contentChanges: { range?: { start: { line: number; character: number }; end: { line: number; character: number } } text: string }[] - }>("test/get-last-change", {}) - expect(change.textDocument.version).toBe(1) - expect(change.contentChanges).toEqual([ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 1, character: 0 }, - }, - text: "second\nthird\n", + }>("test/get-last-change", {}), + ) + expect(change.textDocument.version).toBe(1) + expect(change.contentChanges).toEqual([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 1, character: 0 }, }, - ]) + text: "second\nthird\n", + }, + ]) + }), + ) - await client.shutdown() - }, - }) - }) + it.instance("document mode falls back to push diagnostics", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "client.ts") + yield* writeFile(file, "const x = 1\n") - test("document mode falls back to push diagnostics", async () => { - const handle = spawnFakeServer() as any - await using tmp = await tmpdir() - const file = path.join(tmp.path, "client.ts") - await Bun.write(file, "const x = 1\n") + const client = yield* createScopedClient(spawnFakeServer()) - await withTestInstance({ - directory: tmp.path, - fn: async (ctx) => { - const client = await LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: tmp.path, - directory: tmp.path, - instance: ctx, - }) - - const version = await client.notify.open({ path: file }) - const wait = client.waitForDiagnostics({ path: file, version, mode: "document" }) - await client.connection.sendNotification("test/publish-diagnostics", { + const version = yield* Effect.promise(() => client.notify.open({ path: file })) + const wait = client.waitForDiagnostics({ path: file, version, mode: "document" }) + yield* Effect.promise(() => + client.connection.sendNotification("test/publish-diagnostics", { uri: pathToFileURL(file).href, version, diagnostics: [ @@ -224,40 +191,30 @@ describe("LSPClient interop", () => { severity: 1, }, ], - }) - await wait + }), + ) + yield* Effect.promise(() => wait) - const diagnostics = client.diagnostics.get(file) ?? [] - expect(diagnostics).toHaveLength(1) - expect(diagnostics[0]?.message).toBe("push diagnostic") + const diagnostics = client.diagnostics.get(file) ?? [] + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.message).toBe("push diagnostic") - const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {}) - expect(count).toBe(0) + const count = yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {})) + expect(count).toBe(0) + }), + ) - await client.shutdown() - }, - }) - }) + it.instance("document mode accepts matching push diagnostics published before waiting", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "client.ts") + yield* writeFile(file, "const x = 1\n") - test("document mode accepts matching push diagnostics published before waiting", async () => { - const handle = spawnFakeServer() as any - await using tmp = await tmpdir() - const file = path.join(tmp.path, "client.ts") - await Bun.write(file, "const x = 1\n") + const client = yield* createScopedClient(spawnFakeServer()) - await withTestInstance({ - directory: tmp.path, - fn: async (ctx) => { - const client = await LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: tmp.path, - directory: tmp.path, - instance: ctx, - }) - - const version = await client.notify.open({ path: file }) - await client.connection.sendNotification("test/publish-diagnostics", { + const version = yield* Effect.promise(() => client.notify.open({ path: file })) + yield* Effect.promise(() => + client.connection.sendNotification("test/publish-diagnostics", { uri: pathToFileURL(file).href, version, diagnostics: [ @@ -270,41 +227,31 @@ describe("LSPClient interop", () => { severity: 1, }, ], - }) + }), + ) - for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) { - await new Promise((resolve) => setTimeout(resolve, 25)) - } + const diagnostic = yield* pollWithTimeout( + Effect.sync(() => client.diagnostics.get(file)?.[0]), + "push diagnostic was not published", + ) + expect(diagnostic.message).toBe("push diagnostic") - expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic") + const started = Date.now() + yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" })) + expect(Date.now() - started).toBeLessThan(1_000) + }), + ) - const started = Date.now() - await client.waitForDiagnostics({ path: file, version, mode: "document" }) - expect(Date.now() - started).toBeLessThan(1_000) + it.instance("document mode waits for pull diagnostics", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "client.cs") + yield* writeFile(file, "class C {}\n") - await client.shutdown() - }, - }) - }) + const client = yield* createScopedClient(spawnFakeServer()) - test("document mode waits for pull diagnostics", async () => { - const handle = spawnFakeServer() as any - await using tmp = await tmpdir() - const file = path.join(tmp.path, "client.cs") - await Bun.write(file, "class C {}\n") - - await withTestInstance({ - directory: tmp.path, - fn: async (ctx) => { - const client = await LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: tmp.path, - directory: tmp.path, - instance: ctx, - }) - - await client.connection.sendRequest("test/configure-pull-diagnostics", { + yield* Effect.promise(() => + client.connection.sendRequest("test/configure-pull-diagnostics", { registerOn: "didOpen", registrations: [{ identifier: "DocumentCompilerSemantic" }], documentDiagnosticsByIdentifier: { @@ -319,41 +266,31 @@ describe("LSPClient interop", () => { }, ], }, - }) + }), + ) - const version = await client.notify.open({ path: file }) - await client.waitForDiagnostics({ path: file, version, mode: "document" }) + const version = yield* Effect.promise(() => client.notify.open({ path: file })) + yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" })) - const diagnostics = client.diagnostics.get(file) ?? [] - expect(diagnostics).toHaveLength(1) - expect(diagnostics[0]?.message).toBe("pull diagnostic") + const diagnostics = client.diagnostics.get(file) ?? [] + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.message).toBe("pull diagnostic") - const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {}) - expect(count).toBeGreaterThan(0) + const count = yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {})) + expect(count).toBeGreaterThan(0) + }), + ) - await client.shutdown() - }, - }) - }) + it.instance("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "client.cs") + yield* writeFile(file, "class C {}\n") - test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => { - const handle = spawnFakeServer() as any - await using tmp = await tmpdir() - const file = path.join(tmp.path, "client.cs") - await Bun.write(file, "class C {}\n") + const client = yield* createScopedClient(spawnFakeServer()) - await withTestInstance({ - directory: tmp.path, - fn: async (ctx) => { - const client = await LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: tmp.path, - directory: tmp.path, - instance: ctx, - }) - - await client.connection.sendRequest("test/configure-pull-diagnostics", { + yield* Effect.promise(() => + client.connection.sendRequest("test/configure-pull-diagnostics", { registrations: [{ identifier: "fast" }, { identifier: "slow" }], documentDiagnosticsByIdentifier: { fast: [ @@ -371,43 +308,34 @@ describe("LSPClient interop", () => { documentDelayMsByIdentifier: { slow: 2_500, }, - }) + }), + ) - const version = await client.notify.open({ path: file }) - await client.connection.sendRequest("test/register-configured-pull-diagnostics", {}) - await new Promise((resolve) => setTimeout(resolve, 100)) - const started = Date.now() - await client.waitForDiagnostics({ path: file, version, mode: "document" }) + const version = yield* Effect.promise(() => client.notify.open({ path: file })) + yield* Effect.promise(() => client.connection.sendRequest("test/register-configured-pull-diagnostics", {})) + const started = Date.now() + yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" })) - expect(Date.now() - started).toBeLessThan(1_000) - expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic") - expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1) + expect(Date.now() - started).toBeLessThan(1_000) + expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic") + expect( + yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {})), + ).toBeGreaterThan(1) + }), + ) - await client.shutdown() - }, - }) - }) + it.instance("full mode includes workspace pull diagnostics", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "client.cs") + const related = path.join(test.directory, "other.cs") + yield* writeFile(file, "class C {}\n") + yield* writeFile(related, "class D {}\n") - test("full mode includes workspace pull diagnostics", async () => { - const handle = spawnFakeServer() as any - await using tmp = await tmpdir() - const file = path.join(tmp.path, "client.cs") - const related = path.join(tmp.path, "other.cs") - await Bun.write(file, "class C {}\n") - await Bun.write(related, "class D {}\n") + const client = yield* createScopedClient(spawnFakeServer()) - await withTestInstance({ - directory: tmp.path, - fn: async (ctx) => { - const client = await LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: tmp.path, - directory: tmp.path, - instance: ctx, - }) - - await client.connection.sendRequest("test/configure-pull-diagnostics", { + yield* Effect.promise(() => + client.connection.sendRequest("test/configure-pull-diagnostics", { registerOn: "didOpen", registrations: [ { identifier: "DocumentCompilerSemantic" }, @@ -442,52 +370,40 @@ describe("LSPClient interop", () => { }, ], }, - }) + }), + ) - const version = await client.notify.open({ path: file }) - await client.waitForDiagnostics({ path: file, version, mode: "full" }) + const version = yield* Effect.promise(() => client.notify.open({ path: file })) + yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "full" })) - expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file") - expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file") + expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file") + expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file") + }), + ) - await client.shutdown() - }, - }) - }) + it.instance("full mode treats an empty workspace pull response as handled", () => + Effect.gen(function* () { + const test = yield* TestInstance + const file = path.join(test.directory, "client.cs") + yield* writeFile(file, "class C {}\n") - test("full mode treats an empty workspace pull response as handled", async () => { - const handle = spawnFakeServer() as any - await using tmp = await tmpdir() - const file = path.join(tmp.path, "client.cs") - await Bun.write(file, "class C {}\n") + const client = yield* createScopedClient(spawnFakeServer()) - await withTestInstance({ - directory: tmp.path, - fn: async (ctx) => { - const client = await LSPClient.create({ - serverID: "fake", - server: handle as unknown as LSPServer.Handle, - root: tmp.path, - directory: tmp.path, - instance: ctx, - }) - - await client.connection.sendRequest("test/configure-pull-diagnostics", { + yield* Effect.promise(() => + client.connection.sendRequest("test/configure-pull-diagnostics", { registerOn: "didOpen", registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }], workspaceDiagnosticsByIdentifier: { WorkspaceDocumentsAndProject: [], }, - }) + }), + ) - const version = await client.notify.open({ path: file }) - const started = Date.now() - await client.waitForDiagnostics({ path: file, version, mode: "full" }) + const version = yield* Effect.promise(() => client.notify.open({ path: file })) + const started = Date.now() + yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "full" })) - expect(Date.now() - started).toBeLessThan(1_000) - - await client.shutdown() - }, - }) - }) + expect(Date.now() - started).toBeLessThan(1_000) + }), + ) }) From ea27e2457e5da640efcf679b274c44b12174a81c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 24 May 2026 20:12:48 -0400 Subject: [PATCH 11/11] test(skill): migrate skill tests to effect fixtures (#29046) --- packages/opencode/src/skill/index.ts | 3 + packages/opencode/test/skill/skill.test.ts | 594 ++++++++++----------- 2 files changed, 285 insertions(+), 312 deletions(-) diff --git a/packages/opencode/src/skill/index.ts b/packages/opencode/src/skill/index.ts index c1c6d0d6f2..6ee0b7c559 100644 --- a/packages/opencode/src/skill/index.ts +++ b/packages/opencode/src/skill/index.ts @@ -16,6 +16,7 @@ import * as Log from "@opencode-ai/core/util/log" import { Discovery } from "./discovery" import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" } import { isRecord } from "@/util/record" +import { serviceUse } from "@opencode-ai/core/effect/service-use" const log = Log.create({ service: "skill" }) const CLAUDE_EXTERNAL_DIR = ".claude" @@ -243,6 +244,8 @@ const loadSkills = Effect.fnUntraced(function* (state: State, discovered: Discov export class Service extends Context.Service()("@opencode/Skill") {} +export const use = serviceUse(Service) + export const layer = Layer.effect( Service, Effect.gen(function* () { diff --git a/packages/opencode/test/skill/skill.test.ts b/packages/opencode/test/skill/skill.test.ts index fc1f6bff6a..9b3c5e4575 100644 --- a/packages/opencode/test/skill/skill.test.ts +++ b/packages/opencode/test/skill/skill.test.ts @@ -8,14 +8,13 @@ import { Config } from "../../src/config/config" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Global } from "@opencode-ai/core/global" -import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture" +import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import path from "path" -import fs from "fs/promises" const node = CrossSpawnSpawner.defaultLayer -const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node)) +const it = testEffect(Layer.mergeAll(Skill.defaultLayer, AppFileSystem.defaultLayer, node)) const itWithoutClaudeCodeSkills = testEffect( Layer.mergeAll( Skill.layer.pipe( @@ -26,6 +25,7 @@ const itWithoutClaudeCodeSkills = testEffect( Layer.provide(Global.layer), Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })), ), + AppFileSystem.defaultLayer, node, ), ) @@ -39,15 +39,18 @@ const itWithoutExternalSkills = testEffect( Layer.provide(Global.layer), Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })), ), + AppFileSystem.defaultLayer, node, ), ) -async function createGlobalSkill(homeDir: string) { - const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill") - await fs.mkdir(skillDir, { recursive: true }) - await Bun.write( - path.join(skillDir, "SKILL.md"), +const writeSkill = (dir: string, parts: string[], content: string) => + AppFileSystem.use.writeWithDirs(path.join(dir, ...parts, "SKILL.md"), content) + +const createGlobalSkill = (homeDir: string) => + writeSkill( + homeDir, + [".claude", "skills", "global-test-skill"], `--- name: global-test-skill description: A global skill from ~/.claude/skills for testing. @@ -58,7 +61,6 @@ description: A global skill from ~/.claude/skills for testing. This skill is loaded from the global home directory. `, ) -} const withHome = (home: string, self: Effect.Effect) => Effect.acquireUseRelease( @@ -75,14 +77,14 @@ const withHome = (home: string, self: Effect.Effect) => ) describe("skill", () => { - it.live("discovers skills from .opencode/skill/ directory", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Bun.write( - path.join(dir, ".opencode", "skill", "test-skill", "SKILL.md"), - `--- + it.instance( + "discovers skills from .opencode/skill/ directory", + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeSkill( + test.directory, + [".opencode", "skill", "test-skill"], + `--- name: test-skill description: A test skill for verification. --- @@ -91,118 +93,111 @@ description: A test skill for verification. Instructions here. `, - ), - ) + ) - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(1) - const item = list.find((x) => x.name === "test-skill") - expect(item).toBeDefined() - expect(item!.description).toBe("A test skill for verification.") - expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md")) - }), - { git: true }, - ), + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "test-skill") + expect(item).toBeDefined() + expect(item!.description).toBe("A test skill for verification.") + expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md")) + }), + { git: true }, ) - it.live("returns skill directories from Skill.dirs", () => - provideTmpdirInstance( - (dir) => - withHome( - dir, - Effect.gen(function* () { - yield* Effect.promise(() => - Bun.write( - path.join(dir, ".opencode", "skill", "dir-skill", "SKILL.md"), - `--- + it.instance( + "returns skill directories from Skill.dirs", + Effect.gen(function* () { + const test = yield* TestInstance + yield* withHome( + test.directory, + Effect.gen(function* () { + yield* writeSkill( + test.directory, + [".opencode", "skill", "dir-skill"], + `--- name: dir-skill description: Skill for dirs test. --- # Dir Skill `, - ), - ) + ) - const skill = yield* Skill.Service - const dirs = yield* skill.dirs() - expect(dirs).toContain(path.join(dir, ".opencode", "skill", "dir-skill")) - expect(dirs.length).toBe(1) - }), - ), - { git: true }, - ), + const dirs = yield* Skill.use.dirs() + expect(dirs).toContain(path.join(test.directory, ".opencode", "skill", "dir-skill")) + expect(dirs.length).toBe(1) + }), + ) + }), + { git: true }, ) - it.live("discovers multiple skills from .opencode/skill/ directory", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Promise.all([ - Bun.write( - path.join(dir, ".opencode", "skill", "skill-one", "SKILL.md"), - `--- + it.instance( + "discovers multiple skills from .opencode/skill/ directory", + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.all( + [ + writeSkill( + test.directory, + [".opencode", "skill", "skill-one"], + `--- name: skill-one description: First test skill. --- # Skill One `, - ), - Bun.write( - path.join(dir, ".opencode", "skill", "skill-two", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".opencode", "skill", "skill-two"], + `--- name: skill-two description: Second test skill. --- # Skill Two `, - ), - ]), - ) + ), + ], + ) - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(2) - expect(list.find((x) => x.name === "skill-one")).toBeDefined() - expect(list.find((x) => x.name === "skill-two")).toBeDefined() - }), - { git: true }, - ), + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(2) + expect(list.find((x) => x.name === "skill-one")).toBeDefined() + expect(list.find((x) => x.name === "skill-two")).toBeDefined() + }), + { git: true }, ) - it.live("skips skills with missing frontmatter", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Bun.write( - path.join(dir, ".opencode", "skill", "no-frontmatter", "SKILL.md"), - `# No Frontmatter + it.instance( + "skips skills with missing frontmatter", + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeSkill( + test.directory, + [".opencode", "skill", "no-frontmatter"], + `# No Frontmatter Just some content without YAML frontmatter. `, - ), - ) + ) - const skill = yield* Skill.Service - expect((yield* skill.all()).filter((s) => s.location !== "")).toEqual([]) - }), - { git: true }, - ), + expect((yield* Skill.use.all()).filter((s) => s.location !== "")).toEqual([]) + }), + { git: true }, ) - it.live("discovers skills without descriptions", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Bun.write( - path.join(dir, ".opencode", "skill", "manual-skill", "SKILL.md"), - `--- + it.instance( + "discovers skills without descriptions", + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeSkill( + test.directory, + [".opencode", "skill", "manual-skill"], + `--- name: manual-skill --- @@ -210,98 +205,81 @@ name: manual-skill Instructions here. `, - ), - ) + ) - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(1) - const item = list.find((x) => x.name === "manual-skill") - expect(item).toBeDefined() - expect(item!.description).toBeUndefined() - expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.") - expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.") - }), - { git: true }, - ), + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "manual-skill") + expect(item).toBeDefined() + expect(item!.description).toBeUndefined() + expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.") + expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.") + }), + { git: true }, ) - it.live("discovers skills from .claude/skills/ directory", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Bun.write( - path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), - `--- + it.instance( + "discovers skills from .claude/skills/ directory", + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeSkill( + test.directory, + [".claude", "skills", "claude-skill"], + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, - ), - ) - - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(1) - const item = list.find((x) => x.name === "claude-skill") - expect(item).toBeDefined() - expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md")) - }), - { git: true }, - ), - ) - - it.live("discovers global skills from ~/.claude/skills/ directory", () => - Effect.gen(function* () { - const tmp = yield* Effect.acquireRelease( - Effect.promise(() => tmpdir({ git: true })), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "claude-skill") + expect(item).toBeDefined() + expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md")) + }), + { git: true }, + ) + + it.instance( + "discovers global skills from ~/.claude/skills/ directory", + Effect.gen(function* () { + const test = yield* TestInstance yield* withHome( - tmp.path, + test.directory, Effect.gen(function* () { - yield* Effect.promise(() => createGlobalSkill(tmp.path)) - yield* Effect.gen(function* () { - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(1) - expect(list[0].name).toBe("global-test-skill") - expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.") - expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md")) - }).pipe(provideInstance(tmp.path)) + yield* createGlobalSkill(test.directory) + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(1) + expect(list[0].name).toBe("global-test-skill") + expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.") + expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md")) }), ) }), + { git: true }, ) - it.live("returns empty array when no skills exist", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const skill = yield* Skill.Service - expect((yield* skill.all()).filter((s) => s.location !== "")).toEqual([]) - }), - { git: true }, - ), + it.instance( + "returns empty array when no skills exist", + Effect.gen(function* () { + expect((yield* Skill.use.all()).filter((s) => s.location !== "")).toEqual([]) + }), + { git: true }, ) - it.live("fails with typed error when requiring a missing skill", () => - provideTmpdirInstance( - () => - Effect.gen(function* () { - const skill = yield* Skill.Service - const error = yield* Effect.flip(skill.require("missing-skill")) - expect(error).toBeInstanceOf(Skill.NotFoundError) - expect(error._tag).toBe("Skill.NotFoundError") - expect(error.name).toBe("missing-skill") - expect(error.message).toContain('Skill "missing-skill" not found.') - }), - { git: true }, - ), + it.instance( + "fails with typed error when requiring a missing skill", + Effect.gen(function* () { + const error = yield* Effect.flip(Skill.use.require("missing-skill")) + expect(error).toBeInstanceOf(Skill.NotFoundError) + expect(error._tag).toBe("Skill.NotFoundError") + expect(error.name).toBe("missing-skill") + expect(error.message).toContain('Skill "missing-skill" not found.') + }), + { git: true }, ) it.effect("exposes tagged expected skill failure classes", () => @@ -320,50 +298,42 @@ description: A skill in the .claude/skills directory. }), ) - it.live("discovers skills from .agents/skills/ directory", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Bun.write( - path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), - `--- + it.instance( + "discovers skills from .agents/skills/ directory", + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeSkill( + test.directory, + [".agents", "skills", "agent-skill"], + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, - ), - ) - - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(1) - const item = list.find((x) => x.name === "agent-skill") - expect(item).toBeDefined() - expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md")) - }), - { git: true }, - ), - ) - - it.live("discovers global skills from ~/.agents/skills/ directory", () => - Effect.gen(function* () { - const tmp = yield* Effect.acquireRelease( - Effect.promise(() => tmpdir({ git: true })), - (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), ) + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(1) + const item = list.find((x) => x.name === "agent-skill") + expect(item).toBeDefined() + expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md")) + }), + { git: true }, + ) + + it.instance( + "discovers global skills from ~/.agents/skills/ directory", + Effect.gen(function* () { + const test = yield* TestInstance yield* withHome( - tmp.path, + test.directory, Effect.gen(function* () { - const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill") - yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true })) - yield* Effect.promise(() => - Bun.write( - path.join(skillDir, "SKILL.md"), - `--- + yield* writeSkill( + test.directory, + [".agents", "skills", "global-agent-skill"], + `--- name: global-agent-skill description: A global skill from ~/.agents/skills for testing. --- @@ -372,198 +342,198 @@ description: A global skill from ~/.agents/skills for testing. This skill is loaded from the global home directory. `, - ), ) - yield* Effect.gen(function* () { - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(1) - expect(list[0].name).toBe("global-agent-skill") - expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.") - expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md")) - }).pipe(provideInstance(tmp.path)) + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(1) + expect(list[0].name).toBe("global-agent-skill") + expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.") + expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md")) }), ) }), + { git: true }, ) - it.live("discovers skills from both .claude/skills/ and .agents/skills/", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Promise.all([ - Bun.write( - path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), - `--- + it.instance( + "discovers skills from both .claude/skills/ and .agents/skills/", + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.all( + [ + writeSkill( + test.directory, + [".claude", "skills", "claude-skill"], + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, - ), - Bun.write( - path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".agents", "skills", "agent-skill"], + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, - ), - ]), - ) + ), + ], + ) - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.length).toBe(2) - expect(list.find((x) => x.name === "claude-skill")).toBeDefined() - expect(list.find((x) => x.name === "agent-skill")).toBeDefined() - }), - { git: true }, - ), + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.length).toBe(2) + expect(list.find((x) => x.name === "claude-skill")).toBeDefined() + expect(list.find((x) => x.name === "agent-skill")).toBeDefined() + }), + { git: true }, ) - itWithoutClaudeCodeSkills.live("skips Claude Code skills when disabled", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Promise.all([ - Bun.write( - path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), - `--- + itWithoutClaudeCodeSkills.instance( + "skips Claude Code skills when disabled", + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.all( + [ + writeSkill( + test.directory, + [".claude", "skills", "claude-skill"], + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, - ), - Bun.write( - path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".agents", "skills", "agent-skill"], + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, - ), - ]), - ) + ), + ], + ) - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.map((s) => s.name)).toEqual(["agent-skill"]) - }), - { git: true }, - ), + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.map((s) => s.name)).toEqual(["agent-skill"]) + }), + { git: true }, ) - itWithoutExternalSkills.live("skips external skill directories when disabled", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Promise.all([ - Bun.write( - path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), - `--- + itWithoutExternalSkills.instance( + "skips external skill directories when disabled", + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.all( + [ + writeSkill( + test.directory, + [".claude", "skills", "claude-skill"], + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, - ), - Bun.write( - path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".agents", "skills", "agent-skill"], + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, - ), - Bun.write( - path.join(dir, ".opencode", "skill", "opencode-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".opencode", "skill", "opencode-skill"], + `--- name: opencode-skill description: A skill in the .opencode/skill directory. --- # OpenCode Skill `, - ), - ]), - ) + ), + ], + ) - const skill = yield* Skill.Service - const list = (yield* skill.all()).filter((s) => s.location !== "") - expect(list.map((s) => s.name)).toEqual(["opencode-skill"]) - }), - { git: true }, - ), + const list = (yield* Skill.use.all()).filter((s) => s.location !== "") + expect(list.map((s) => s.name)).toEqual(["opencode-skill"]) + }), + { git: true }, ) - it.live("properly resolves directories that skills live in", () => - provideTmpdirInstance( - (dir) => - Effect.gen(function* () { - yield* Effect.promise(() => - Promise.all([ - Bun.write( - path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"), - `--- + it.instance( + "properly resolves directories that skills live in", + Effect.gen(function* () { + const test = yield* TestInstance + yield* Effect.all( + [ + writeSkill( + test.directory, + [".claude", "skills", "claude-skill"], + `--- name: claude-skill description: A skill in the .claude/skills directory. --- # Claude Skill `, - ), - Bun.write( - path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".agents", "skills", "agent-skill"], + `--- name: agent-skill description: A skill in the .agents/skills directory. --- # Agent Skill `, - ), - Bun.write( - path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".opencode", "skill", "agent-skill"], + `--- name: opencode-skill description: A skill in the .opencode/skill directory. --- # OpenCode Skill `, - ), - Bun.write( - path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"), - `--- + ), + writeSkill( + test.directory, + [".opencode", "skills", "agent-skill"], + `--- name: opencode-skill description: A skill in the .opencode/skills directory. --- # OpenCode Skill `, - ), - ]), - ) + ), + ], + ) - const skill = yield* Skill.Service - expect((yield* skill.dirs()).length).toBe(4) - }), - { git: true }, - ), + expect((yield* Skill.use.dirs()).length).toBe(4) + }), + { git: true }, ) })