Merge remote-tracking branch 'upstream/dev' into refactor-shells
This commit is contained in:
commit
9dde86acbe
319 changed files with 2903 additions and 1061 deletions
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Auth } from "../../src/auth"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Deferred, Effect, Layer, Schema, Stream } from "effect"
|
|||
import { Bus } from "../../src/bus"
|
||||
import { BusEvent } from "../../src/bus/bus-event"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
|
|
|||
9
packages/opencode/test/cli/tui/editor-context.test.ts
Normal file
9
packages/opencode/test/cli/tui/editor-context.test.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import { offsetToPosition } from "../../../src/cli/cmd/tui/context/editor-zed"
|
||||
|
||||
test("offsetToPosition converts Zed offsets to 1-based editor positions", () => {
|
||||
expect(offsetToPosition("one\ntwo\nthree", 0)).toEqual({ line: 1, character: 1 })
|
||||
expect(offsetToPosition("one\ntwo\nthree", 4)).toEqual({ line: 2, character: 1 })
|
||||
expect(offsetToPosition("one\ntwo\nthree", 6)).toEqual({ line: 2, character: 3 })
|
||||
expect(offsetToPosition("one\ntwo\nthree", 100)).toEqual({ line: 3, character: 6 })
|
||||
})
|
||||
|
|
@ -5,7 +5,7 @@ import { pathToFileURL } from "url"
|
|||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||
import { Npm } from "../../../src/npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
||||
const { TuiPluginRuntime } = await import("../../../src/cli/cmd/tui/plugin/runtime")
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import path from "path"
|
|||
import { pathToFileURL } from "url"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { Global } from "../../../src/global"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { TuiConfig } from "../../../src/cli/cmd/tui/config/tui"
|
||||
import { Filesystem } from "../../../src/util/"
|
||||
|
||||
|
|
|
|||
|
|
@ -3,17 +3,17 @@ import { Effect, Layer, Option } from "effect"
|
|||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config, ConfigManaged } from "../../src/config"
|
||||
import { ConfigParse } from "../../src/config/parse"
|
||||
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Env } from "../../src/env"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
|
||||
|
|
@ -23,11 +23,11 @@ const infra = CrossSpawnSpawner.defaultLayer.pipe(
|
|||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Global } from "../../src/global"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { Npm } from "@/npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
|
|
@ -645,6 +645,33 @@ Test agent prompt`,
|
|||
})
|
||||
})
|
||||
|
||||
test("agent markdown permission config preserves user key order", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const agentDir = path.join(dir, ".opencode", "agent")
|
||||
await fs.mkdir(agentDir, { recursive: true })
|
||||
|
||||
await Filesystem.write(
|
||||
path.join(agentDir, "ordered.md"),
|
||||
`---
|
||||
permission:
|
||||
bash: allow
|
||||
"*": deny
|
||||
edit: ask
|
||||
---
|
||||
Ordered permissions`,
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"])
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("loads agents from .opencode/agents (plural)", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
|
@ -895,7 +922,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
|||
})
|
||||
|
||||
// Note: deduplication and serialization of npm installs is now handled by the
|
||||
// shared Npm.Service (via EffectFlock). Those behaviors are tested in the shared
|
||||
// core Npm.Service (via EffectFlock). Those behaviors are tested in the core
|
||||
// package's npm tests, not here.
|
||||
|
||||
test("resolves scoped npm plugins in config", async () => {
|
||||
|
|
@ -1495,16 +1522,9 @@ test("merges legacy tools with existing permission config", async () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("permission config canonicalises known keys first, preserves rest-key insertion order", async () => {
|
||||
// ConfigPermission.Info is a StructWithRest schema — the decoder reorders
|
||||
// keys into declaration-order for known permission names (edit, read,
|
||||
// todowrite, external_directory are declared in `config/permission.ts`),
|
||||
// followed by rest keys in the user's insertion order.
|
||||
//
|
||||
// Rule precedence is NOT affected by this reordering: `Permission.fromConfig`
|
||||
// sorts wildcards before specifics before iterating. See the
|
||||
// "fromConfig - specific key beats wildcard regardless of JSON key order"
|
||||
// test in test/permission/next.test.ts for the behavioural guarantee.
|
||||
test("permission config preserves user key order", async () => {
|
||||
// Permission precedence follows the order users write in config, so parsing
|
||||
// must not canonicalise known keys ahead of wildcard or custom keys.
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
|
|
@ -1532,15 +1552,12 @@ test("permission config canonicalises known keys first, preserves rest-key inser
|
|||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(Object.keys(config.permission!)).toEqual([
|
||||
// known fields that the user provided, in declaration order from
|
||||
// config/permission.ts (read, edit, ..., external_directory, todowrite)
|
||||
"read",
|
||||
"edit",
|
||||
"external_directory",
|
||||
"todowrite",
|
||||
// rest keys (not in the known list), in user's insertion order
|
||||
"*",
|
||||
"edit",
|
||||
"write",
|
||||
"external_directory",
|
||||
"read",
|
||||
"todowrite",
|
||||
"thoughts_*",
|
||||
"reasoning_model_*",
|
||||
"tools_*",
|
||||
|
|
@ -1578,6 +1595,29 @@ test("permission config preserves shell and legacy bash order", async () => {
|
|||
})
|
||||
})
|
||||
|
||||
test("Effect config parser preserves permission order while rejecting unknown top-level keys", () => {
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
{
|
||||
permission: {
|
||||
bash: "allow",
|
||||
"*": "deny",
|
||||
edit: "ask",
|
||||
},
|
||||
},
|
||||
"test",
|
||||
)
|
||||
|
||||
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
|
||||
try {
|
||||
ConfigParse.effectSchema(Config.Info, { invalid_field: true }, "test")
|
||||
throw new Error("expected config parse to fail")
|
||||
} catch (err) {
|
||||
const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
|
||||
expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] })
|
||||
}
|
||||
})
|
||||
|
||||
// MCP config merging tests
|
||||
|
||||
test("project config can override MCP server enabled status", async () => {
|
||||
|
|
@ -2260,8 +2300,8 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
|
|||
// parseManagedPlist unit tests — pure function, no OS interaction
|
||||
|
||||
test("parseManagedPlist strips MDM metadata keys", async () => {
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info.zod,
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
|
|
@ -2288,8 +2328,8 @@ test("parseManagedPlist strips MDM metadata keys", async () => {
|
|||
})
|
||||
|
||||
test("parseManagedPlist parses server settings", async () => {
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info.zod,
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
|
|
@ -2308,8 +2348,8 @@ test("parseManagedPlist parses server settings", async () => {
|
|||
})
|
||||
|
||||
test("parseManagedPlist parses permission rules", async () => {
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info.zod,
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
|
|
@ -2338,8 +2378,8 @@ test("parseManagedPlist parses permission rules", async () => {
|
|||
})
|
||||
|
||||
test("parseManagedPlist parses enabled_providers", async () => {
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info.zod,
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(
|
||||
JSON.stringify({
|
||||
|
|
@ -2355,8 +2395,8 @@ test("parseManagedPlist parses enabled_providers", async () => {
|
|||
})
|
||||
|
||||
test("parseManagedPlist handles empty config", async () => {
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info.zod,
|
||||
const config = ConfigParse.effectSchema(
|
||||
Config.Info,
|
||||
ConfigParse.jsonc(
|
||||
await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })),
|
||||
"test:mobileconfig",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { tmpdir } from "../fixture/fixture"
|
|||
import { Instance } from "../../src/project/instance"
|
||||
import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
|
||||
import { Config } from "../../src/config"
|
||||
import { Global } from "../../src/global"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
|
|
|||
|
|
@ -1,413 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { Effect, Exit, Stream } from "effect"
|
||||
import type * as PlatformError from "effect/PlatformError"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const live = CrossSpawnSpawner.defaultLayer
|
||||
const fx = testEffect(live)
|
||||
|
||||
function js(code: string, opts?: ChildProcess.CommandOptions) {
|
||||
return ChildProcess.make("node", ["-e", code], opts)
|
||||
}
|
||||
|
||||
function decodeByteStream(stream: Stream.Stream<Uint8Array, PlatformError.PlatformError>) {
|
||||
return Stream.runCollect(stream).pipe(
|
||||
Effect.map((chunks) => {
|
||||
const total = chunks.reduce((acc, x) => acc + x.length, 0)
|
||||
const out = new Uint8Array(total)
|
||||
let off = 0
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, off)
|
||||
off += chunk.length
|
||||
}
|
||||
return new TextDecoder("utf-8").decode(out).trim()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function alive(pid: number) {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function gone(pid: number, timeout = 5_000) {
|
||||
const end = Date.now() + timeout
|
||||
while (Date.now() < end) {
|
||||
if (!alive(pid)) return true
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
return !alive(pid)
|
||||
}
|
||||
|
||||
describe("cross-spawn spawner", () => {
|
||||
describe("basic spawning", () => {
|
||||
fx.effect(
|
||||
"captures stdout",
|
||||
Effect.gen(function* () {
|
||||
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.string(ChildProcess.make(process.execPath, ["-e", 'process.stdout.write("ok")'])),
|
||||
)
|
||||
expect(out).toBe("ok")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"captures multiple lines",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('console.log("line1"); console.log("line2"); console.log("line3")')
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
expect(out).toBe("line1\nline2\nline3")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"returns exit code",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js("process.exit(0)")
|
||||
const code = yield* handle.exitCode
|
||||
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"returns non-zero exit code",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js("process.exit(42)")
|
||||
const code = yield* handle.exitCode
|
||||
expect(code).toBe(ChildProcessSpawner.ExitCode(42))
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("cwd option", () => {
|
||||
fx.effect(
|
||||
"uses cwd when spawning commands",
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.string(
|
||||
ChildProcess.make(process.execPath, ["-e", "process.stdout.write(process.cwd())"], { cwd: tmp.path }),
|
||||
),
|
||||
)
|
||||
expect(out).toBe(tmp.path)
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"fails for invalid cwd",
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.exit(
|
||||
ChildProcess.make("echo", ["test"], { cwd: "/nonexistent/directory/path" }).asEffect(),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("env option", () => {
|
||||
fx.effect(
|
||||
"passes environment variables with extendEnv",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stdout.write(process.env.TEST_VAR ?? "")', {
|
||||
env: { TEST_VAR: "test_value" },
|
||||
extendEnv: true,
|
||||
})
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
expect(out).toBe("test_value")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"passes multiple environment variables",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js(
|
||||
"process.stdout.write(`${process.env.VAR1}-${process.env.VAR2}-${process.env.VAR3}`)",
|
||||
{
|
||||
env: { VAR1: "one", VAR2: "two", VAR3: "three" },
|
||||
extendEnv: true,
|
||||
},
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
expect(out).toBe("one-two-three")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("stderr", () => {
|
||||
fx.effect(
|
||||
"captures stderr output",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stderr.write("error message")')
|
||||
const err = yield* decodeByteStream(handle.stderr)
|
||||
expect(err).toBe("error message")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"captures both stdout and stderr",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js(
|
||||
[
|
||||
"let pending = 2",
|
||||
"const done = () => {",
|
||||
" pending -= 1",
|
||||
" if (pending === 0) setTimeout(() => process.exit(0), 0)",
|
||||
"}",
|
||||
'process.stdout.write("stdout\\n", done)',
|
||||
'process.stderr.write("stderr\\n", done)',
|
||||
].join("\n"),
|
||||
)
|
||||
const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
|
||||
concurrency: 2,
|
||||
})
|
||||
expect(stdout).toBe("stdout")
|
||||
expect(stderr).toBe("stderr")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("combined output (all)", () => {
|
||||
fx.effect(
|
||||
"captures stdout via .all when no stderr",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("echo", ["hello from stdout"])
|
||||
const all = yield* decodeByteStream(handle.all)
|
||||
expect(all).toBe("hello from stdout")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"captures stderr via .all when no stdout",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stderr.write("hello from stderr")')
|
||||
const all = yield* decodeByteStream(handle.all)
|
||||
expect(all).toBe("hello from stderr")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("stdin", () => {
|
||||
fx.effect(
|
||||
"allows providing standard input to a command",
|
||||
Effect.gen(function* () {
|
||||
const input = "a b c"
|
||||
const stdin = Stream.make(Buffer.from(input, "utf-8"))
|
||||
const handle = yield* js(
|
||||
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
|
||||
{ stdin },
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("a b c")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("process control", () => {
|
||||
fx.effect(
|
||||
"kills a running process",
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.exit(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js("setTimeout(() => {}, 10_000)")
|
||||
yield* handle.kill()
|
||||
return yield* handle.exitCode
|
||||
}),
|
||||
)
|
||||
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"kills a child when scope exits",
|
||||
Effect.gen(function* () {
|
||||
const pid = yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js("setInterval(() => {}, 10_000)")
|
||||
return Number(handle.pid)
|
||||
}),
|
||||
)
|
||||
const done = yield* Effect.promise(() => gone(pid))
|
||||
expect(done).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"forceKillAfter escalates for stubborn processes",
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
const started = Date.now()
|
||||
const exit = yield* Effect.exit(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)')
|
||||
yield* handle.kill({ forceKillAfter: 100 })
|
||||
return yield* handle.exitCode
|
||||
}),
|
||||
)
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"isRunning reflects process state",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stdout.write("done")')
|
||||
yield* handle.exitCode
|
||||
const running = yield* handle.isRunning
|
||||
expect(running).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("error handling", () => {
|
||||
fx.effect(
|
||||
"fails for invalid command",
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* Effect.exit(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* ChildProcess.make("nonexistent-command-12345")
|
||||
return yield* handle.exitCode
|
||||
}),
|
||||
)
|
||||
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("pipeline", () => {
|
||||
fx.effect(
|
||||
"pipes stdout of one command to stdin of another",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stdout.write("hello world")').pipe(
|
||||
ChildProcess.pipeTo(
|
||||
js(
|
||||
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
|
||||
),
|
||||
),
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("HELLO WORLD")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"three-stage pipeline",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stdout.write("hello world")').pipe(
|
||||
ChildProcess.pipeTo(
|
||||
js(
|
||||
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.toUpperCase()))',
|
||||
),
|
||||
),
|
||||
ChildProcess.pipeTo(
|
||||
js(
|
||||
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out.replaceAll(" ", "-")))',
|
||||
),
|
||||
),
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("HELLO-WORLD")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"pipes stderr with { from: 'stderr' }",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stderr.write("error")').pipe(
|
||||
ChildProcess.pipeTo(
|
||||
js(
|
||||
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
|
||||
),
|
||||
{ from: "stderr" },
|
||||
),
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toBe("error")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"pipes combined output with { from: 'all' }",
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* js('process.stdout.write("stdout\\n"); process.stderr.write("stderr\\n")').pipe(
|
||||
ChildProcess.pipeTo(
|
||||
js(
|
||||
'process.stdin.setEncoding("utf8"); let out = ""; process.stdin.on("data", (chunk) => out += chunk); process.stdin.on("end", () => process.stdout.write(out))',
|
||||
),
|
||||
{ from: "all" },
|
||||
),
|
||||
)
|
||||
const out = yield* decodeByteStream(handle.stdout)
|
||||
yield* handle.exitCode
|
||||
expect(out).toContain("stdout")
|
||||
expect(out).toContain("stderr")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Windows-specific", () => {
|
||||
fx.effect(
|
||||
"uses shell routing on Windows",
|
||||
Effect.gen(function* () {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
const out = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.string(
|
||||
ChildProcess.make("set", ["OPENCODE_TEST_SHELL"], {
|
||||
shell: true,
|
||||
extendEnv: true,
|
||||
env: { OPENCODE_TEST_SHELL: "ok" },
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(out).toContain("OPENCODE_TEST_SHELL=ok")
|
||||
}),
|
||||
)
|
||||
|
||||
fx.effect(
|
||||
"runs cmd scripts with spaces on Windows without shell",
|
||||
Effect.gen(function* () {
|
||||
if (process.platform !== "win32") return
|
||||
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
const dir = path.join(tmp.path, "with space")
|
||||
const file = path.join(dir, "echo cmd.cmd")
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||
yield* Effect.promise(() => Bun.write(file, "@echo off\r\nif %~1==--stdio exit /b 0\r\nexit /b 7\r\n"))
|
||||
|
||||
const code = yield* ChildProcessSpawner.ChildProcessSpawner.use((svc) =>
|
||||
svc.exitCode(
|
||||
ChildProcess.make(file, ["--stdio"], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(code).toBe(ChildProcessSpawner.ExitCode(0))
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { resource } from "../../src/effect/observability"
|
||||
|
||||
const otelResourceAttributes = process.env.OTEL_RESOURCE_ATTRIBUTES
|
||||
const opencodeClient = process.env.OPENCODE_CLIENT
|
||||
|
||||
afterEach(() => {
|
||||
if (otelResourceAttributes === undefined) delete process.env.OTEL_RESOURCE_ATTRIBUTES
|
||||
else process.env.OTEL_RESOURCE_ATTRIBUTES = otelResourceAttributes
|
||||
|
||||
if (opencodeClient === undefined) delete process.env.OPENCODE_CLIENT
|
||||
else process.env.OPENCODE_CLIENT = opencodeClient
|
||||
})
|
||||
|
||||
describe("resource", () => {
|
||||
test("parses and decodes OTEL resource attributes", () => {
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES =
|
||||
"service.namespace=anomalyco,team=platform%2Cobservability,label=hello%3Dworld,key%2Fname=value%20here"
|
||||
|
||||
expect(resource().attributes).toMatchObject({
|
||||
"service.namespace": "anomalyco",
|
||||
team: "platform,observability",
|
||||
label: "hello=world",
|
||||
"key/name": "value here",
|
||||
})
|
||||
})
|
||||
|
||||
test("drops OTEL resource attributes when any entry is invalid", () => {
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES = "service.namespace=anomalyco,broken"
|
||||
|
||||
expect(resource().attributes["service.namespace"]).toBeUndefined()
|
||||
expect(resource().attributes["opencode.client"]).toBeDefined()
|
||||
})
|
||||
|
||||
test("keeps built-in attributes when env values conflict", () => {
|
||||
process.env.OPENCODE_CLIENT = "cli"
|
||||
process.env.OTEL_RESOURCE_ATTRIBUTES =
|
||||
"opencode.client=web,service.instance.id=override,service.namespace=anomalyco"
|
||||
|
||||
expect(resource().attributes).toMatchObject({
|
||||
"opencode.client": "cli",
|
||||
"service.namespace": "anomalyco",
|
||||
})
|
||||
expect(resource().attributes["service.instance.id"]).not.toBe("override")
|
||||
})
|
||||
})
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, test, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import path from "path"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import fs from "fs/promises"
|
||||
import { Flock } from "@opencode-ai/shared/util/flock"
|
||||
import { Flock } from "@opencode-ai/core/util/flock"
|
||||
|
||||
type Msg = {
|
||||
key: string
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, expect } from "bun:test"
|
|||
import { Effect, Layer } from "effect"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Format } from "../../src/format"
|
||||
import * as Formatter from "../../src/format/formatter"
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Effect, Layer, Stream } from "effect"
|
|||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { Installation } from "../../src/installation"
|
||||
import { InstallationChannel } from "../../src/installation/version"
|
||||
import { InstallationChannel } from "@opencode-ai/core/installation/version"
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import path from "path"
|
|||
import { Effect, Layer } from "effect"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { LSPServer } from "../../src/lsp"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import path from "path"
|
|||
import { Effect, Layer } from "effect"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { LSPServer } from "../../src/lsp"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import path from "path"
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { Global } from "@opencode-ai/shared/global"
|
||||
import { EffectFlock } from "@opencode-ai/shared/util/effect-flock"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { Npm } from "../src/npm"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
const win = process.platform === "win32"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { afterEach, test, expect } from "bun:test"
|
|||
import os from "os"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { Bus } from "../../src/bus"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
|
@ -150,61 +150,45 @@ test("fromConfig - does not expand tilde in middle of path", () => {
|
|||
expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }])
|
||||
})
|
||||
|
||||
// Top-level wildcard-vs-specific precedence semantics.
|
||||
//
|
||||
// fromConfig sorts top-level keys so wildcard permissions (containing "*")
|
||||
// come before specific permissions. Combined with `findLast` in evaluate(),
|
||||
// this gives the intuitive semantic "specific tool rules override the `*`
|
||||
// fallback", regardless of the order the user wrote the keys in their JSON.
|
||||
//
|
||||
// Sub-pattern order inside a single permission key (e.g. `bash: { "*": "allow", "rm": "deny" }`)
|
||||
// still depends on insertion order — only top-level keys are sorted.
|
||||
// Permission precedence follows config insertion order. `evaluate()` uses the
|
||||
// last matching rule, so later config entries intentionally override earlier
|
||||
// entries even when a wildcard appears after a specific permission.
|
||||
|
||||
test("fromConfig - specific key beats wildcard regardless of JSON key order", () => {
|
||||
test("fromConfig - preserves top-level config key order", () => {
|
||||
const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" })
|
||||
const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" })
|
||||
|
||||
// Both orderings produce the same ruleset
|
||||
expect(wildcardFirst).toEqual(specificFirst)
|
||||
expect(wildcardFirst.map((r) => r.permission)).toEqual(["*", "bash"])
|
||||
expect(specificFirst.map((r) => r.permission)).toEqual(["bash", "*"])
|
||||
|
||||
// And both evaluate bash → allow (bash rule wins over * fallback)
|
||||
expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("deny")
|
||||
})
|
||||
|
||||
test("fromConfig - wildcard acts as fallback for permissions with no specific rule", () => {
|
||||
const ruleset = Permission.fromConfig({ bash: "allow", "*": "ask" })
|
||||
test("fromConfig - wildcard acts as fallback when it appears before specifics", () => {
|
||||
const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow" })
|
||||
expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
|
||||
})
|
||||
|
||||
test("fromConfig - top-level ordering: wildcards first, specifics after", () => {
|
||||
test("fromConfig - top-level ordering is not sorted by wildcard specificity", () => {
|
||||
const ruleset = Permission.fromConfig({
|
||||
bash: "allow",
|
||||
"*": "ask",
|
||||
edit: "deny",
|
||||
"mcp_*": "allow",
|
||||
})
|
||||
// wildcards (* and mcp_*) come before specifics (bash, edit)
|
||||
const permissions = ruleset.map((r) => r.permission)
|
||||
expect(permissions.slice(0, 2).sort()).toEqual(["*", "mcp_*"])
|
||||
expect(permissions.slice(2)).toEqual(["shell", "edit"])
|
||||
expect(ruleset.map((r) => r.permission)).toEqual(["shell", "*", "edit", "mcp_*"])
|
||||
})
|
||||
|
||||
test("fromConfig - sub-pattern insertion order inside a tool key is preserved (only top-level sorts)", () => {
|
||||
// Sub-patterns within a single tool key use the documented "`*` first,
|
||||
// specific patterns after" convention (findLast picks specifics). The
|
||||
// top-level sort must not touch sub-pattern ordering.
|
||||
test("fromConfig - sub-pattern insertion order inside a tool key is preserved", () => {
|
||||
const ruleset = Permission.fromConfig({ bash: { "*": "deny", "git *": "allow" } })
|
||||
expect(ruleset.map((r) => r.pattern)).toEqual(["*", "git *"])
|
||||
// * fallback for unknown commands
|
||||
expect(Permission.evaluate("bash", "rm foo", ruleset).action).toBe("deny")
|
||||
// specific pattern wins for git commands (it's last, findLast picks it)
|
||||
expect(Permission.evaluate("bash", "git status", ruleset).action).toBe("allow")
|
||||
})
|
||||
|
||||
test("fromConfig - canonical documented example unchanged", () => {
|
||||
// Regression guard for the example in docs/permissions.mdx
|
||||
test("fromConfig - documented fallback-first example", () => {
|
||||
const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow", edit: "deny" })
|
||||
expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("deny")
|
||||
|
|
@ -475,7 +459,7 @@ test("evaluate - wildcard permission fallback for unknown tool", () => {
|
|||
expect(result.action).toBe("ask")
|
||||
})
|
||||
|
||||
test("evaluate - permission patterns sorted by length regardless of object order", () => {
|
||||
test("evaluate - later wildcard permission can override earlier specific permission", () => {
|
||||
const result = Permission.evaluate("bash", "rm", [
|
||||
{ permission: "bash", pattern: "*", action: "allow" },
|
||||
{ permission: "*", pattern: "*", action: "deny" },
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const { Plugin } = await import("../../src/plugin/index")
|
|||
const { PluginLoader } = await import("../../src/plugin/loader")
|
||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
const { Npm } = await import("../../src/npm")
|
||||
const { Npm } = await import("@opencode-ai/core/npm")
|
||||
|
||||
afterAll(() => {
|
||||
if (disableDefault === undefined) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { tmpdir } from "../fixture/fixture"
|
|||
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||
|
||||
const { Flag } = await import("../../src/flag/flag")
|
||||
const { Flag } = await import("@opencode-ai/core/flag/flag")
|
||||
const { Plugin } = await import("../../src/plugin/index")
|
||||
const { Workspace } = await import("../../src/control-plane/workspace")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { ProjectID } from "../../src/project/schema"
|
|||
import { Effect, Layer, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { describe, expect } from "bun:test"
|
|||
import * as fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { afterEach, describe, expect } from "bun:test"
|
|||
import * as fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { tmpdir } from "../fixture/fixture"
|
|||
import { Instance } from "../../src/project/instance"
|
||||
import { Provider } from "../../src/provider"
|
||||
import { Env } from "../../src/env"
|
||||
import { Global } from "../../src/global"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export {}
|
|||
// import { Instance } from "../../src/project/instance"
|
||||
// import { Provider } from "../../src/provider"
|
||||
// import { Env } from "../../src/env"
|
||||
// import { Global } from "../../src/global"
|
||||
// import { Global } from "@opencode-ai/core/global"
|
||||
// import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||
|
||||
// test("GitLab Duo: loads provider with API key from environment", async () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { mkdir, unlink } from "fs/promises"
|
|||
import path from "path"
|
||||
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Global } from "../../src/global"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Plugin } from "../../src/plugin/index"
|
||||
import { ModelsDev } from "../../src/provider"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Flag } from "../../src/flag/flag"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||
import { FilePaths } from "../../src/server/routes/instance/httpapi/file"
|
||||
|
|
|
|||
69
packages/opencode/test/server/httpapi-config.test.ts
Normal file
69
packages/opencode/test/server/httpapi-config.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import path from "path"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||
import { Log } from "../../src/util"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return InstanceRoutes(websocket)
|
||||
}
|
||||
|
||||
async function waitDisposed(directory: string) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", onEvent)
|
||||
reject(new Error("timed out waiting for instance disposal"))
|
||||
}, 10_000)
|
||||
|
||||
function onEvent(event: { directory?: string; payload: { type?: string } }) {
|
||||
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
|
||||
clearTimeout(timer)
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve()
|
||||
}
|
||||
|
||||
GlobalBus.on("event", onEvent)
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("config HttpApi", () => {
|
||||
test("serves config update through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const disposed = waitDisposed(tmp.path)
|
||||
|
||||
const response = await app().request("/config", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-opencode-directory": tmp.path,
|
||||
},
|
||||
body: JSON.stringify({ username: "patched-user", formatter: false, lsp: false }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ username: "patched-user", formatter: false, lsp: false })
|
||||
await disposed
|
||||
expect(await Bun.file(path.join(tmp.path, "config.json")).json()).toMatchObject({
|
||||
username: "patched-user",
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
})
|
||||
})
|
||||
})
|
||||
134
packages/opencode/test/server/httpapi-experimental.test.ts
Normal file
134
packages/opencode/test/server/httpapi-experimental.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
|
||||
import { Log } from "../../src/util"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return InstanceRoutes(websocket)
|
||||
}
|
||||
|
||||
async function waitReady(directory: string) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", onEvent)
|
||||
reject(new Error("timed out waiting for worktree.ready"))
|
||||
}, 10_000)
|
||||
|
||||
function onEvent(event: { directory?: string; payload: { type?: string } }) {
|
||||
if (event.payload.type !== Worktree.Event.Ready.type || event.directory !== directory) return
|
||||
clearTimeout(timer)
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve()
|
||||
}
|
||||
|
||||
GlobalBus.on("event", onEvent)
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("experimental HttpApi", () => {
|
||||
test("serves read-only experimental endpoints through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
mcp: {
|
||||
demo: {
|
||||
type: "local",
|
||||
command: ["echo", "demo"],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const [consoleState, consoleOrgs, toolIDs, worktrees, resources] = await Promise.all([
|
||||
app().request(ExperimentalPaths.console, { headers }),
|
||||
app().request(ExperimentalPaths.consoleOrgs, { headers }),
|
||||
app().request(ExperimentalPaths.toolIDs, { headers }),
|
||||
app().request(ExperimentalPaths.worktree, { headers }),
|
||||
app().request(ExperimentalPaths.resource, { headers }),
|
||||
])
|
||||
|
||||
expect(consoleState.status).toBe(200)
|
||||
expect(await consoleState.json()).toEqual({
|
||||
consoleManagedProviders: [],
|
||||
switchableOrgCount: 0,
|
||||
})
|
||||
|
||||
expect(consoleOrgs.status).toBe(200)
|
||||
expect(await consoleOrgs.json()).toEqual({ orgs: [] })
|
||||
|
||||
expect(toolIDs.status).toBe(200)
|
||||
expect(await toolIDs.json()).toContain("bash")
|
||||
|
||||
expect(worktrees.status).toBe(200)
|
||||
expect(await worktrees.json()).toEqual([])
|
||||
|
||||
expect(resources.status).toBe(200)
|
||||
expect(await resources.json()).toEqual({})
|
||||
})
|
||||
|
||||
test("serves worktree mutations through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const created = await app().request(ExperimentalPaths.worktree, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ name: "api-test" }),
|
||||
})
|
||||
|
||||
expect(created.status).toBe(200)
|
||||
const info = (await created.json()) as Worktree.Info
|
||||
expect(info).toMatchObject({ name: "api-test", branch: "opencode/api-test" })
|
||||
await waitReady(info.directory)
|
||||
|
||||
const listed = await app().request(ExperimentalPaths.worktree, { headers })
|
||||
expect(listed.status).toBe(200)
|
||||
expect(await listed.json()).toContain(info.directory)
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const reset = await app().request(ExperimentalPaths.worktreeReset, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ directory: info.directory }),
|
||||
})
|
||||
|
||||
expect(reset.status).toBe(200)
|
||||
expect(await reset.json()).toBe(true)
|
||||
}
|
||||
|
||||
const removed = await app().request(ExperimentalPaths.worktree, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
body: JSON.stringify({ directory: info.directory }),
|
||||
})
|
||||
|
||||
expect(removed.status).toBe(200)
|
||||
expect(await removed.json()).toBe(true)
|
||||
|
||||
const afterRemove = await app().request(ExperimentalPaths.worktree, { headers })
|
||||
expect(afterRemove.status).toBe(200)
|
||||
expect(await afterRemove.json()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -54,4 +54,24 @@ describe("file HttpApi", () => {
|
|||
expect(status.status).toBe(200)
|
||||
expect(await status.json()).toContainEqual({ path: "hello.txt", added: 1, removed: 0, status: "added" })
|
||||
})
|
||||
|
||||
test("serves search endpoints", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(path.join(tmp.path, "hello.txt"), "needle")
|
||||
|
||||
const [text, files, symbols] = await Promise.all([
|
||||
request(FilePaths.findText, tmp.path, { pattern: "needle" }),
|
||||
request(FilePaths.findFile, tmp.path, { query: "hello", type: "file" }),
|
||||
request(FilePaths.findSymbol, tmp.path, { query: "hello" }),
|
||||
])
|
||||
|
||||
expect(text.status).toBe(200)
|
||||
expect(await text.json()).toContainEqual(expect.objectContaining({ line_number: 1 }))
|
||||
|
||||
expect(files.status).toBe(200)
|
||||
expect(await files.json()).toContain("hello.txt")
|
||||
|
||||
expect(symbols.status).toBe(200)
|
||||
expect(await symbols.json()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
166
packages/opencode/test/server/httpapi-instance.test.ts
Normal file
166
packages/opencode/test/server/httpapi-instance.test.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import type { UpgradeWebSocket } from "hono/ws"
|
||||
import path from "path"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceRoutes } from "../../src/server/routes/instance"
|
||||
import { InstancePaths } from "../../src/server/routes/instance/httpapi/instance"
|
||||
import { Log } from "../../src/util"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
|
||||
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
|
||||
|
||||
function app() {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
||||
return InstanceRoutes(websocket)
|
||||
}
|
||||
|
||||
async function waitDisposed(directory: string) {
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", onEvent)
|
||||
reject(new Error("timed out waiting for instance disposal"))
|
||||
}, 10_000)
|
||||
|
||||
function onEvent(event: { directory?: string; payload: { type?: string } }) {
|
||||
if (event.payload.type !== "server.instance.disposed" || event.directory !== directory) return
|
||||
clearTimeout(timer)
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve()
|
||||
}
|
||||
|
||||
GlobalBus.on("event", onEvent)
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
|
||||
await Instance.disposeAll()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("instance HttpApi", () => {
|
||||
test("serves path and VCS read endpoints through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Bun.write(path.join(tmp.path, "changed.txt"), "hello")
|
||||
|
||||
const vcsDiff = new URL(`http://localhost${InstancePaths.vcsDiff}`)
|
||||
vcsDiff.searchParams.set("mode", "git")
|
||||
|
||||
const [paths, vcs, diff] = await Promise.all([
|
||||
app().request(InstancePaths.path, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.vcs, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(vcsDiff, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
])
|
||||
|
||||
expect(paths.status).toBe(200)
|
||||
expect(await paths.json()).toMatchObject({ directory: tmp.path, worktree: tmp.path })
|
||||
|
||||
expect(vcs.status).toBe(200)
|
||||
expect(await vcs.json()).toMatchObject({ branch: expect.any(String) })
|
||||
|
||||
expect(diff.status).toBe(200)
|
||||
expect(await diff.json()).toContainEqual(
|
||||
expect.objectContaining({ file: "changed.txt", additions: 1, status: "added" }),
|
||||
)
|
||||
})
|
||||
|
||||
test("serves catalog read endpoints through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
|
||||
const [commands, agents, skills, lsp, formatter] = await Promise.all([
|
||||
app().request(InstancePaths.command, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.agent, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.skill, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.lsp, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
app().request(InstancePaths.formatter, { headers: { "x-opencode-directory": tmp.path } }),
|
||||
])
|
||||
|
||||
expect(commands.status).toBe(200)
|
||||
expect(await commands.json()).toContainEqual(expect.objectContaining({ name: "init", source: "command" }))
|
||||
|
||||
expect(agents.status).toBe(200)
|
||||
expect(await agents.json()).toContainEqual(expect.objectContaining({ name: "build", mode: "primary" }))
|
||||
|
||||
expect(skills.status).toBe(200)
|
||||
expect(await skills.json()).toBeArray()
|
||||
|
||||
expect(lsp.status).toBe(200)
|
||||
expect(await lsp.json()).toEqual([])
|
||||
|
||||
expect(formatter.status).toBe(200)
|
||||
expect(await formatter.json()).toEqual([])
|
||||
})
|
||||
|
||||
test("serves project git init through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
const disposed = waitDisposed(tmp.path)
|
||||
|
||||
const response = await app().request("/project/git/init", {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
|
||||
await disposed
|
||||
|
||||
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
|
||||
expect(current.status).toBe(200)
|
||||
expect(await current.json()).toMatchObject({ vcs: "git", worktree: tmp.path })
|
||||
})
|
||||
|
||||
test("serves project update through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
|
||||
const current = await app().request("/project/current", { headers: { "x-opencode-directory": tmp.path } })
|
||||
expect(current.status).toBe(200)
|
||||
const project = (await current.json()) as { id: string }
|
||||
|
||||
const response = await app().request(`/project/${project.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "patched-project", commands: { start: "bun dev" } }),
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
id: project.id,
|
||||
name: "patched-project",
|
||||
commands: { start: "bun dev" },
|
||||
})
|
||||
|
||||
const list = await app().request("/project", { headers: { "x-opencode-directory": tmp.path } })
|
||||
expect(list.status).toBe(200)
|
||||
expect(await list.json()).toContainEqual(
|
||||
expect.objectContaining({ id: project.id, name: "patched-project", commands: { start: "bun dev" } }),
|
||||
)
|
||||
})
|
||||
|
||||
test("serves instance dispose through Hono bridge", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
|
||||
const disposed = new Promise<string | undefined>((resolve) => {
|
||||
const onEvent = (event: { directory?: string; payload: { type?: string } }) => {
|
||||
if (event.payload.type !== "server.instance.disposed") return
|
||||
GlobalBus.off("event", onEvent)
|
||||
resolve(event.directory)
|
||||
}
|
||||
GlobalBus.on("event", onEvent)
|
||||
})
|
||||
|
||||
const response = await app().request(InstancePaths.dispose, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toBe(true)
|
||||
expect(await disposed).toBe(tmp.path)
|
||||
})
|
||||
})
|
||||
|
|
@ -11,12 +11,13 @@ void Log.init({ print: false })
|
|||
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
|
||||
function request(route: string, directory: string) {
|
||||
function request(route: string, directory: string, init?: RequestInit) {
|
||||
const headers = new Headers(init?.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return ExperimentalHttpApiServer.webHandler().handler(
|
||||
new Request(`http://localhost${route}`, {
|
||||
headers: {
|
||||
"x-opencode-directory": directory,
|
||||
},
|
||||
...init,
|
||||
headers,
|
||||
}),
|
||||
context,
|
||||
)
|
||||
|
|
@ -45,4 +46,65 @@ describe("mcp HttpApi", () => {
|
|||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({ demo: { status: "disabled" } })
|
||||
})
|
||||
|
||||
test("serves add, connect, and disconnect endpoints", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
mcp: {
|
||||
demo: {
|
||||
type: "local",
|
||||
command: ["echo", "demo"],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const added = await request(McpPaths.status, tmp.path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: "added",
|
||||
config: {
|
||||
type: "local",
|
||||
command: ["echo", "added"],
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
})
|
||||
expect(added.status).toBe(200)
|
||||
expect(await added.json()).toMatchObject({ added: { status: "disabled" } })
|
||||
|
||||
const connected = await request("/mcp/demo/connect", tmp.path, { method: "POST" })
|
||||
expect(connected.status).toBe(200)
|
||||
expect(await connected.json()).toBe(true)
|
||||
|
||||
const disconnected = await request("/mcp/demo/disconnect", tmp.path, { method: "POST" })
|
||||
expect(disconnected.status).toBe(200)
|
||||
expect(await disconnected.json()).toBe(true)
|
||||
})
|
||||
|
||||
test("serves deterministic OAuth endpoints", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
config: {
|
||||
mcp: {
|
||||
demo: {
|
||||
type: "local",
|
||||
command: ["echo", "demo"],
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const start = await request("/mcp/demo/auth", tmp.path, { method: "POST" })
|
||||
expect(start.status).toBe(400)
|
||||
|
||||
const authenticate = await request("/mcp/demo/auth/authenticate", tmp.path, { method: "POST" })
|
||||
expect(authenticate.status).toBe(400)
|
||||
|
||||
const removed = await request("/mcp/demo/auth", tmp.path, { method: "DELETE" })
|
||||
expect(removed.status).toBe(200)
|
||||
expect(await removed.json()).toEqual({ success: true })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import * as SessionProcessorModule from "../../src/session/processor"
|
|||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ProviderTest } from "../fake/provider"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Instruction } from "../../src/session/instruction"
|
|||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { Global } from "../../src/global"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
const run = <A>(effect: Effect.Effect<A, any, Instruction.Service>) =>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { SessionStatus } from "../../src/session/status"
|
|||
import { SessionSummary } from "../../src/session/summary"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Log } from "../../src/util"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { raw, reply, TestLLMServer } from "../lib/llm-server"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { expect } from "bun:test"
|
|||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { fileURLToPath } from "url"
|
||||
import { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Command } from "../../src/command"
|
||||
|
|
@ -21,7 +21,7 @@ import { Todo } from "../../src/session/todo"
|
|||
import { Session } from "../../src/session"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { SessionCompaction } from "../../src/session/compaction"
|
||||
import { SessionSummary } from "../../src/session/summary"
|
||||
import { Instruction } from "../../src/session/instruction"
|
||||
|
|
@ -38,7 +38,7 @@ import { Snapshot } from "../../src/snapshot"
|
|||
import { ToolRegistry } from "../../src/tool"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { Log } from "../../src/util"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
import { provideTmpdirInstance, provideTmpdirServer } from "../fixture/fixture"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { NamedError } from "@opencode-ai/shared/util/error"
|
||||
import type { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { APICallError } from "ai"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Effect, Schedule } from "effect"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { MessageV2 } from "../../src/session/message-v2"
|
|||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Log } from "../../src/util"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
|
|
|||
|
|
@ -52,8 +52,8 @@ import { SessionStatus } from "../../src/session/status"
|
|||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ToolRegistry } from "../../src/tool"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstab
|
|||
import { AccessToken, AccountID, OrgID, RefreshToken } from "../../src/account/schema"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { AccountRepo } from "../../src/account/repo"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config"
|
||||
import { Provider } from "../../src/provider"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, test, expect, beforeAll, afterAll } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Discovery } from "../../src/skill/discovery"
|
||||
import { Global } from "../../src/global"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import { rm } from "fs/promises"
|
||||
import path from "path"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Skill } from "../../src/skill"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import path from "path"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Global } from "../../src/global"
|
||||
import { InstallationChannel } from "../../src/installation/version"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { InstallationChannel } from "@opencode-ai/core/installation/version"
|
||||
import { Database } from "../../src/storage"
|
||||
|
||||
describe("Database.Path", () => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import path from "path"
|
|||
import fs from "fs/promises"
|
||||
import { readFileSync, readdirSync } from "fs"
|
||||
import { JsonMigration } from "../../src/storage"
|
||||
import { Global } from "../../src/global"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { ProjectTable } from "../../src/project/project.sql"
|
||||
import { ProjectID } from "../../src/project/schema"
|
||||
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Exit, Layer } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Git } from "../../src/git"
|
||||
import { Global } from "../../src/global"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Storage } from "../../src/storage"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { SyncEvent } from "../../src/sync"
|
|||
import { Database } from "../../src/storage"
|
||||
import { EventTable } from "../../src/sync/event.sql"
|
||||
import { Identifier } from "../../src/id/id"
|
||||
import { Flag } from "../../src/flag/flag"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { initProjectors } from "../../src/server/projectors"
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Effect, ManagedRuntime, Layer } from "effect"
|
|||
import { ApplyPatchTool } from "../../src/tool/apply_patch"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { EditTool } from "../../src/tool/edit"
|
|||
import { Instance } from "../../src/project/instance"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Format } from "../../src/format"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import path from "path"
|
|||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { GlobTool } from "../../src/tool/glob"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import { Effect, Layer } from "effect"
|
|||
import { GrepTool } from "../../src/tool/grep"
|
||||
import { provideInstance, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
|
|
|
|||
162
packages/opencode/test/tool/lsp.test.ts
Normal file
162
packages/opencode/test/tool/lsp.test.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { MessageID, SessionID } from "../../src/session/schema"
|
||||
import { Tool, Truncate } from "../../src/tool"
|
||||
import { LspTool } from "../../src/tool/lsp"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await Instance.disposeAll()
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make(""),
|
||||
callID: "",
|
||||
agent: "build",
|
||||
abort: AbortSignal.any([]),
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
const lsp = Layer.succeed(
|
||||
LSP.Service,
|
||||
LSP.Service.of({
|
||||
init: () => Effect.void,
|
||||
status: () => Effect.succeed([]),
|
||||
hasClients: () => Effect.succeed(true),
|
||||
touchFile: () => Effect.void,
|
||||
diagnostics: () => Effect.succeed({}),
|
||||
hover: () => Effect.succeed([]),
|
||||
definition: () => Effect.succeed([]),
|
||||
references: () => Effect.succeed([]),
|
||||
implementation: () => Effect.succeed([]),
|
||||
documentSymbol: () => Effect.succeed([]),
|
||||
workspaceSymbol: () => Effect.succeed([]),
|
||||
prepareCallHierarchy: () => Effect.succeed([]),
|
||||
incomingCalls: () => Effect.succeed([]),
|
||||
outgoingCalls: () => Effect.succeed([]),
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
Truncate.defaultLayer,
|
||||
lsp,
|
||||
),
|
||||
)
|
||||
|
||||
const init = Effect.fn("LspToolTest.init")(function* () {
|
||||
const info = yield* LspTool
|
||||
return yield* info.init()
|
||||
})
|
||||
|
||||
const run = Effect.fn("LspToolTest.run")(function* (
|
||||
args: Tool.InferParameters<typeof LspTool>,
|
||||
next: Tool.Context = ctx,
|
||||
) {
|
||||
const tool = yield* init()
|
||||
return yield* tool.execute(args, next)
|
||||
})
|
||||
|
||||
const put = Effect.fn("LspToolTest.put")(function* (file: string) {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
yield* fs.writeWithDirs(file, "export const x = 1\n")
|
||||
})
|
||||
|
||||
const asks = () => {
|
||||
const items: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||
return {
|
||||
items,
|
||||
next: {
|
||||
...ctx,
|
||||
ask: (req: Omit<Permission.Request, "id" | "sessionID" | "tool">) =>
|
||||
Effect.sync(() => {
|
||||
items.push(req)
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("tool.lsp", () => {
|
||||
describe("permission metadata", () => {
|
||||
it.live("keeps cursor details for position-based operations", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "test.ts")
|
||||
yield* put(file)
|
||||
|
||||
const { items, next } = asks()
|
||||
const result = yield* run({ operation: "goToDefinition", filePath: file, line: 3, character: 7 }, next)
|
||||
const req = items.find((item) => item.permission === "lsp")
|
||||
|
||||
expect(req).toBeDefined()
|
||||
expect(req!.metadata).toEqual({
|
||||
operation: "goToDefinition",
|
||||
filePath: file,
|
||||
line: 3,
|
||||
character: 7,
|
||||
})
|
||||
expect(result.title).toBe("goToDefinition test.ts:3:7")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("omits cursor details for documentSymbol", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "test.ts")
|
||||
yield* put(file)
|
||||
|
||||
const { items, next } = asks()
|
||||
const result = yield* run({ operation: "documentSymbol", filePath: file, line: 3, character: 7 }, next)
|
||||
const req = items.find((item) => item.permission === "lsp")
|
||||
|
||||
expect(req).toBeDefined()
|
||||
expect(req!.metadata).toEqual({
|
||||
operation: "documentSymbol",
|
||||
filePath: file,
|
||||
})
|
||||
expect(result.title).toBe("documentSymbol test.ts")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("omits file and cursor details for workspaceSymbol", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(dir, "test.ts")
|
||||
yield* put(file)
|
||||
|
||||
const { items, next } = asks()
|
||||
const result = yield* run({ operation: "workspaceSymbol", filePath: file, line: 3, character: 7 }, next)
|
||||
const req = items.find((item) => item.permission === "lsp")
|
||||
|
||||
expect(req).toBeDefined()
|
||||
expect(req!.metadata).toEqual({
|
||||
operation: "workspaceSymbol",
|
||||
})
|
||||
expect(result.title).toBe("workspaceSymbol")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -4,7 +4,7 @@ import { QuestionTool } from "../../src/tool/question"
|
|||
import { Question } from "../../src/question"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { afterEach, describe, expect } from "bun:test"
|
|||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import path from "path"
|
|||
import fs from "fs/promises"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ToolRegistry } from "../../src/tool"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
|
|
|||
|
|
@ -12,8 +12,8 @@ import type { Permission } from "../../src/permission"
|
|||
import { Agent } from "../../src/agent/agent"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
|
||||
const runtime = ManagedRuntime.make(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { afterEach, describe, expect } from "bun:test"
|
|||
import { Effect, Layer } from "effect"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Config } from "../../src/config"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ import fs from "fs/promises"
|
|||
import { WriteTool } from "../../src/tool/write"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { LSP } from "../../src/lsp"
|
||||
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Format } from "../../src/format"
|
||||
import { Truncate } from "../../src/tool"
|
||||
import { Tool } from "../../src/tool"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, test, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Glob } from "@opencode-ai/shared/util/glob"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
describe("Glob", () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { afterEach, expect, test } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Global } from "../../src/global"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Log } from "../../src/util"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Module } from "@opencode-ai/shared/util/module"
|
||||
import { Module } from "@opencode-ai/core/util/module"
|
||||
import { Filesystem } from "../../src/util"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { registerAdaptor } from "../../src/control-plane/adaptors"
|
|||
import type { WorkspaceAdaptor } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { Flag } from "../../src/flag/flag"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Session as SessionNs } from "../../src/session"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue