| import { describe, expect } from "bun:test" |
| import { Duration, Effect, Exit, Fiber, 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 { LayerNode } from "@opencode-ai/core/effect/layer-node" |
| import { EventV2 } from "@opencode-ai/core/event" |
| import { Integration } from "@opencode-ai/core/integration" |
| import { testEffect } from "./lib/effect" |
|
|
| const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node]))) |
|
|
| 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( |
| new Integration.Info({ 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 events = yield* EventV2.Service |
| const integrationID = Integration.ID.make("openai") |
| yield* integrations.transform((editor) => |
| editor.method.update({ |
| integrationID, |
| method: { type: "key", label: "API key" }, |
| }), |
| ) |
| const updated = yield* events |
| .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.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.connection.oauth({ |
| integrationID, |
| methodID, |
| inputs: {}, |
| label: "Personal", |
| }) |
| expect(attempt.mode).toBe("code") |
| yield* integrations.attempt.complete({ 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.connection.oauth({ integrationID, methodID, inputs: {} }) |
| expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf( |
| Integration.CodeRequiredError, |
| ) |
| expect(closed).toBe(false) |
| yield* integrations.attempt.cancel(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.connection.oauth({ integrationID, methodID, inputs: {} }) |
| yield* Effect.yieldNow |
| expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({ |
| status: "complete", |
| time: attempt.time, |
| }) |
| expect(yield* credentials.list(integrationID)).toHaveLength(1) |
| }), |
| ) |
|
|
| 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.connection.oauth({ 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.attempt.status(attempt.attemptID)).toEqual({ |
| status: "expired", |
| time: attempt.time, |
| }) |
| expect(closed).toBe(true) |
| expect(yield* credentials.list(integrationID)).toEqual([]) |
| }), |
| ) |
|
|
| 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" }), |
| }) |
|
|
| |
| 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 |
| }), |
| ) |
| }) |
| }) |
|
|