| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508 |
- import { describe, expect } from "bun:test"
- import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
- import * as TestClock from "effect/testing/TestClock"
- import { Credential } from "@opencode-ai/core/credential"
- import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
- import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
- import { LayerNode } from "@opencode-ai/util/effect/layer-node"
- import { Bus } from "@opencode-ai/core/bus"
- import { Integration } from "@opencode-ai/core/integration"
- import { testEffect } from "./lib/effect"
- const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
- const failingCredentialNode = makeGlobalNode({
- service: Credential.Service,
- layer: Layer.succeed(
- Credential.Service,
- Credential.Service.of({
- all: () => Effect.succeed([]),
- list: () => Effect.succeed([]),
- get: () => Effect.succeed(undefined),
- create: () => Effect.die(new Error("credential persistence failed")),
- update: () => Effect.void,
- remove: () => Effect.void,
- }),
- ),
- deps: [],
- })
- const failingIt = testEffect(
- AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [[Credential.node, failingCredentialNode]]),
- )
- function eventually<A, E, R>(
- effect: Effect.Effect<A, E, R>,
- predicate: (value: A) => boolean,
- remaining = 1000,
- ): Effect.Effect<A, E | Error, R> {
- return Effect.gen(function* () {
- const value = yield* effect
- if (predicate(value)) return value
- if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
- yield* Effect.promise(() => Bun.sleep(1))
- return yield* eventually(effect, predicate, remaining - 1)
- })
- }
- describe("Integration", () => {
- it.effect("registers integrations through the editor", () =>
- Effect.gen(function* () {
- const integrations = yield* Integration.Service
- const scope = yield* Scope.fork(yield* Scope.Scope)
- const openai = Integration.ID.make("openai")
- yield* integrations
- .transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
- .pipe(Scope.provide(scope))
- expect(yield* integrations.get(openai)).toEqual(
- Integration.Info.make({ id: openai, name: "OpenAI", methods: [], connections: [] }),
- )
- yield* Scope.close(scope, Exit.void)
- expect(yield* integrations.get(openai)).toBeUndefined()
- }),
- )
- it.effect("reveals the previous registration when an override closes", () =>
- Effect.gen(function* () {
- const integrations = yield* Integration.Service
- const id = Integration.ID.make("openai")
- const first = yield* Scope.fork(yield* Scope.Scope)
- const second = yield* Scope.fork(yield* Scope.Scope)
- yield* integrations
- .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI")))
- .pipe(Scope.provide(first))
- yield* integrations
- .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override")))
- .pipe(Scope.provide(second))
- expect((yield* integrations.get(id))?.name).toBe("OpenAI Override")
- yield* Scope.close(second, Exit.void)
- expect((yield* integrations.get(id))?.name).toBe("OpenAI")
- expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id])
- }),
- )
- it.effect("registers and overrides methods independently", () =>
- Effect.gen(function* () {
- const integrations = yield* Integration.Service
- const integrationID = Integration.ID.make("openai")
- const methodID = Integration.MethodID.make("chatgpt")
- const first = yield* Scope.fork(yield* Scope.Scope)
- const second = yield* Scope.fork(yield* Scope.Scope)
- const authorize = () =>
- Effect.succeed({
- mode: "auto" as const,
- url: "https://example.com/authorize",
- instructions: "Sign in",
- callback: Effect.never,
- })
- yield* integrations
- .transform((editor) =>
- editor.method.update({
- integrationID,
- method: { id: methodID, type: "oauth", label: "ChatGPT" },
- authorize,
- }),
- )
- .pipe(Scope.provide(first))
- yield* integrations
- .transform((editor) => {
- expect(editor.get(integrationID)).toEqual({ id: integrationID, name: "openai" })
- expect(editor.list()).toEqual([{ id: integrationID, name: "openai" }])
- expect(editor.method.list(integrationID)).toEqual([
- expect.objectContaining({ id: methodID, label: "ChatGPT" }),
- ])
- editor.method.update({
- integrationID,
- method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
- authorize,
- })
- })
- .pipe(Scope.provide(second))
- expect((yield* integrations.get(integrationID))?.name).toBe("openai")
- expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT Override" })
- yield* Scope.close(second, Exit.void)
- expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" })
- expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })])
- }),
- )
- it.effect("connects with a key and stores the credential", () =>
- Effect.gen(function* () {
- const integrations = yield* Integration.Service
- const credentials = yield* Credential.Service
- const bus = yield* Bus.Service
- const integrationID = Integration.ID.make("openai")
- yield* integrations.transform((editor) =>
- editor.method.update({
- integrationID,
- method: { type: "key", label: "API key" },
- }),
- )
- const updated = yield* bus
- .subscribe(Integration.Event.Updated)
- .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
- yield* Effect.yieldNow
- yield* integrations.connection.key({
- integrationID,
- key: "secret",
- label: "Work",
- })
- expect(yield* credentials.list(integrationID)).toEqual([
- expect.objectContaining({
- integrationID,
- label: "Work",
- value: Credential.Key.make({ type: "key", key: "secret" }),
- }),
- ])
- expect((yield* Fiber.join(updated)).length).toBe(1)
- }),
- )
- it.live("runs command authentication and stores the final output line", () =>
- Effect.gen(function* () {
- const integrations = yield* Integration.Service
- const credentials = yield* Credential.Service
- const integrationID = Integration.ID.make("company")
- const methodID = Integration.MethodID.make("login")
- yield* integrations.transform((editor) =>
- editor.method.update({
- integrationID,
- method: {
- id: methodID,
- type: "command",
- label: "Log in",
- command: [
- process.execPath,
- "-e",
- 'console.error("https://example.com/login"); await Bun.sleep(50); console.log("secret")',
- ],
- },
- }),
- )
- const attempt = yield* integrations.command.connect({ integrationID, methodID, label: "Work" })
- const pending = yield* eventually(
- integrations.command.status({ integrationID, attemptID: attempt.attemptID }),
- (status) => status.status === "pending" && status.message?.includes("https://example.com/login") === true,
- )
- expect(pending).toMatchObject({ status: "pending", message: "https://example.com/login\n" })
- expect(
- yield* eventually(
- integrations.command.status({ integrationID, attemptID: attempt.attemptID }),
- (status) => status.status === "complete",
- ),
- ).toEqual({ status: "complete", time: attempt.time })
- expect(yield* credentials.list(integrationID)).toEqual([
- expect.objectContaining({
- integrationID,
- label: "Work",
- value: Credential.Key.make({ type: "key", key: "secret" }),
- }),
- ])
- }),
- )
- it.effect("completes code OAuth once and stores the credential", () =>
- Effect.gen(function* () {
- const integrations = yield* Integration.Service
- const credentials = yield* Credential.Service
- const integrationID = Integration.ID.make("openai")
- const methodID = Integration.MethodID.make("chatgpt")
- yield* integrations.transform((editor) =>
- editor.method.update({
- integrationID,
- method: { id: methodID, type: "oauth", label: "ChatGPT" },
- authorize: () =>
- Effect.succeed({
- mode: "code" as const,
- url: "https://example.com/authorize",
- instructions: "Paste the code",
- callback: (code: string) =>
- Effect.succeed(
- Credential.OAuth.make({
- type: "oauth",
- methodID,
- access: "access",
- refresh: "refresh",
- expires: 1,
- metadata: { code },
- }),
- ),
- }),
- }),
- )
- const attempt = yield* integrations.oauth.connect({
- integrationID,
- methodID,
- inputs: {},
- label: "Personal",
- })
- expect(attempt.mode).toBe("code")
- yield* integrations.oauth.complete({ integrationID, attemptID: attempt.attemptID, code: "1234" })
- expect((yield* credentials.list(integrationID))[0]).toEqual(
- expect.objectContaining({
- integrationID,
- label: "Personal",
- value: Credential.OAuth.make({
- type: "oauth",
- methodID,
- access: "access",
- refresh: "refresh",
- expires: 1,
- metadata: { code: "1234" },
- }),
- }),
- )
- }),
- )
- it.effect("keeps code attempts open when the code is missing and closes them on cancel", () =>
- Effect.gen(function* () {
- const integrations = yield* Integration.Service
- const credentials = yield* Credential.Service
- const integrationID = Integration.ID.make("openai")
- const methodID = Integration.MethodID.make("chatgpt")
- let closed = false
- yield* integrations.transform((editor) =>
- editor.method.update({
- integrationID,
- method: { id: methodID, type: "oauth", label: "ChatGPT" },
- authorize: () =>
- Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
- Effect.as({
- mode: "code" as const,
- url: "https://example.com/authorize",
- instructions: "Paste the code",
- callback: () => Effect.die("unexpected callback"),
- }),
- ),
- }),
- )
- const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
- expect(
- yield* integrations.oauth.complete({ integrationID, attemptID: attempt.attemptID }).pipe(Effect.flip),
- ).toBeInstanceOf(Integration.CodeRequiredError)
- expect(closed).toBe(false)
- yield* integrations.oauth.cancel({
- integrationID: Integration.ID.make("other"),
- attemptID: attempt.attemptID,
- })
- expect(closed).toBe(false)
- yield* integrations.oauth.cancel({ integrationID, attemptID: attempt.attemptID })
- expect(closed).toBe(true)
- expect(yield* credentials.list(integrationID)).toEqual([])
- }),
- )
- it.effect("completes auto OAuth in the background", () =>
- Effect.gen(function* () {
- const integrations = yield* Integration.Service
- const credentials = yield* Credential.Service
- const integrationID = Integration.ID.make("openai")
- const methodID = Integration.MethodID.make("browser")
- yield* integrations.transform((editor) =>
- editor.method.update({
- integrationID,
- method: { id: methodID, type: "oauth", label: "Browser" },
- authorize: () =>
- Effect.succeed({
- mode: "auto" as const,
- url: "https://example.com/authorize",
- instructions: "Sign in",
- callback: Effect.succeed(
- Credential.OAuth.make({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }),
- ),
- }),
- }),
- )
- const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
- yield* Effect.yieldNow
- expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
- status: "complete",
- time: attempt.time,
- })
- expect(yield* credentials.list(integrationID)).toHaveLength(1)
- }),
- )
- failingIt.effect("fails the attempt when credential persistence fails", () =>
- Effect.gen(function* () {
- const integrations = yield* Integration.Service
- const integrationID = Integration.ID.make("openai")
- const methodID = Integration.MethodID.make("chatgpt")
- yield* integrations.transform((editor) =>
- editor.method.update({
- integrationID,
- method: { id: methodID, type: "oauth", label: "ChatGPT" },
- authorize: () =>
- Effect.succeed({
- mode: "code" as const,
- url: "https://example.com/authorize",
- instructions: "Paste the code",
- callback: () =>
- Effect.succeed(
- Credential.OAuth.make({
- type: "oauth",
- methodID,
- access: "access",
- refresh: "refresh",
- expires: 1,
- }),
- ),
- }),
- }),
- )
- const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
- const exit = yield* integrations.oauth
- .complete({ integrationID, attemptID: attempt.attemptID, code: "1234" })
- .pipe(Effect.exit)
- expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true)
- expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
- status: "failed",
- message: "credential persistence failed",
- time: attempt.time,
- })
- }),
- )
- it.effect("expires abandoned OAuth attempts", () =>
- Effect.gen(function* () {
- const integrations = yield* Integration.Service
- const credentials = yield* Credential.Service
- const integrationID = Integration.ID.make("openai")
- const methodID = Integration.MethodID.make("browser")
- let closed = false
- yield* integrations.transform((editor) =>
- editor.method.update({
- integrationID,
- method: { id: methodID, type: "oauth", label: "Browser" },
- authorize: () =>
- Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
- Effect.as({
- mode: "auto" as const,
- url: "https://example.com/authorize",
- instructions: "Sign in",
- callback: Effect.never,
- }),
- ),
- }),
- )
- const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
- expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
- yield* TestClock.adjust(Duration.minutes(10))
- yield* Effect.yieldNow
- expect(yield* integrations.oauth.status({ integrationID, attemptID: attempt.attemptID })).toEqual({
- status: "expired",
- time: attempt.time,
- })
- expect(closed).toBe(true)
- expect(yield* credentials.list(integrationID)).toEqual([])
- }),
- )
- it.effect("uses provider-defined OAuth attempt expirations", () =>
- Effect.gen(function* () {
- const integrations = yield* Integration.Service
- const integrationID = Integration.ID.make("openai")
- const created = yield* Clock.currentTimeMillis
- const expirations = [
- created + Duration.toMillis(Duration.minutes(5)),
- created + Duration.toMillis(Duration.minutes(20)),
- ]
- yield* Effect.forEach(expirations, (expiresAt, index) => {
- const methodID = Integration.MethodID.make(`browser-${index}`)
- return Effect.gen(function* () {
- yield* integrations.transform((editor) =>
- editor.method.update({
- integrationID,
- method: { id: methodID, type: "oauth", label: "Browser" },
- authorize: () =>
- Effect.succeed({
- mode: "auto" as const,
- url: "https://example.com/authorize",
- instructions: "Sign in",
- expiresAt,
- callback: Effect.never,
- }),
- }),
- )
- const attempt = yield* integrations.oauth.connect({ integrationID, methodID, inputs: {} })
- expect(attempt.time).toEqual({ created, expires: expiresAt })
- })
- })
- }),
- )
- it.effect("projects credential and env connections", () => {
- const integrationID = Integration.ID.make("acme")
- return Effect.acquireUseRelease(
- Effect.sync(() => {
- const previous = process.env.INTEGRATION_TEST_ACME_KEY
- process.env.INTEGRATION_TEST_ACME_KEY = "secret"
- delete process.env.INTEGRATION_TEST_ACME_MISSING
- return previous
- }),
- () =>
- Effect.gen(function* () {
- const integrations = yield* Integration.Service
- const credentials = yield* Credential.Service
- yield* integrations.transform((editor) =>
- editor.method.update({
- integrationID,
- method: {
- type: "env",
- names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
- },
- }),
- )
- const work = yield* credentials.create({
- integrationID,
- label: "Work",
- value: Credential.Key.make({ type: "key", key: "a" }),
- })
- const personal = yield* credentials.create({
- integrationID,
- label: "Personal",
- value: Credential.Key.make({ type: "key", key: "b" }),
- })
- // Stored credentials and detected env vars appear as connections.
- expect((yield* integrations.get(integrationID))?.connections).toEqual([
- {
- type: "credential",
- id: personal.id,
- label: "Personal",
- },
- { type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
- ])
- expect(yield* integrations.connection.active(integrationID)).toEqual({
- type: "credential",
- id: personal.id,
- label: "Personal",
- })
- expect(work.id).not.toBe(personal.id)
- }),
- (previous) =>
- Effect.sync(() => {
- if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY
- else process.env.INTEGRATION_TEST_ACME_KEY = previous
- }),
- )
- })
- })
|