| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349 |
- 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" }),
- })
- // 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
- }),
- )
- })
- })
|