integration.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import { describe, expect } from "bun:test"
  2. import { Duration, Effect, Exit, Fiber, Scope, Stream } from "effect"
  3. import * as TestClock from "effect/testing/TestClock"
  4. import { Credential } from "@opencode-ai/core/credential"
  5. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  6. import { LayerNode } from "@opencode-ai/core/effect/layer-node"
  7. import { EventV2 } from "@opencode-ai/core/event"
  8. import { Integration } from "@opencode-ai/core/integration"
  9. import { testEffect } from "./lib/effect"
  10. const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, EventV2.node])))
  11. describe("Integration", () => {
  12. it.effect("registers integrations through the editor", () =>
  13. Effect.gen(function* () {
  14. const integrations = yield* Integration.Service
  15. const scope = yield* Scope.fork(yield* Scope.Scope)
  16. const openai = Integration.ID.make("openai")
  17. yield* integrations
  18. .transform((editor) => editor.update(openai, (integration) => (integration.name = "OpenAI")))
  19. .pipe(Scope.provide(scope))
  20. expect(yield* integrations.get(openai)).toEqual(
  21. new Integration.Info({ id: openai, name: "OpenAI", methods: [], connections: [] }),
  22. )
  23. yield* Scope.close(scope, Exit.void)
  24. expect(yield* integrations.get(openai)).toBeUndefined()
  25. }),
  26. )
  27. it.effect("reveals the previous registration when an override closes", () =>
  28. Effect.gen(function* () {
  29. const integrations = yield* Integration.Service
  30. const id = Integration.ID.make("openai")
  31. const first = yield* Scope.fork(yield* Scope.Scope)
  32. const second = yield* Scope.fork(yield* Scope.Scope)
  33. yield* integrations
  34. .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI")))
  35. .pipe(Scope.provide(first))
  36. yield* integrations
  37. .transform((editor) => editor.update(id, (integration) => (integration.name = "OpenAI Override")))
  38. .pipe(Scope.provide(second))
  39. expect((yield* integrations.get(id))?.name).toBe("OpenAI Override")
  40. yield* Scope.close(second, Exit.void)
  41. expect((yield* integrations.get(id))?.name).toBe("OpenAI")
  42. expect((yield* integrations.list()).map((integration) => integration.id)).toEqual([id])
  43. }),
  44. )
  45. it.effect("registers and overrides methods independently", () =>
  46. Effect.gen(function* () {
  47. const integrations = yield* Integration.Service
  48. const integrationID = Integration.ID.make("openai")
  49. const methodID = Integration.MethodID.make("chatgpt")
  50. const first = yield* Scope.fork(yield* Scope.Scope)
  51. const second = yield* Scope.fork(yield* Scope.Scope)
  52. const authorize = () =>
  53. Effect.succeed({
  54. mode: "auto" as const,
  55. url: "https://example.com/authorize",
  56. instructions: "Sign in",
  57. callback: Effect.never,
  58. })
  59. yield* integrations
  60. .transform((editor) =>
  61. editor.method.update({
  62. integrationID,
  63. method: { id: methodID, type: "oauth", label: "ChatGPT" },
  64. authorize,
  65. }),
  66. )
  67. .pipe(Scope.provide(first))
  68. yield* integrations
  69. .transform((editor) => {
  70. expect(editor.get(integrationID)).toEqual({ id: integrationID, name: "openai" })
  71. expect(editor.list()).toEqual([{ id: integrationID, name: "openai" }])
  72. expect(editor.method.list(integrationID)).toEqual([
  73. expect.objectContaining({ id: methodID, label: "ChatGPT" }),
  74. ])
  75. editor.method.update({
  76. integrationID,
  77. method: { id: methodID, type: "oauth", label: "ChatGPT Override" },
  78. authorize,
  79. })
  80. })
  81. .pipe(Scope.provide(second))
  82. expect((yield* integrations.get(integrationID))?.name).toBe("openai")
  83. expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT Override" })
  84. yield* Scope.close(second, Exit.void)
  85. expect((yield* integrations.get(integrationID))?.methods[0]).toMatchObject({ label: "ChatGPT" })
  86. expect((yield* integrations.get(integrationID))?.methods).toEqual([expect.objectContaining({ id: methodID })])
  87. }),
  88. )
  89. it.effect("connects with a key and stores the credential", () =>
  90. Effect.gen(function* () {
  91. const integrations = yield* Integration.Service
  92. const credentials = yield* Credential.Service
  93. const events = yield* EventV2.Service
  94. const integrationID = Integration.ID.make("openai")
  95. yield* integrations.transform((editor) =>
  96. editor.method.update({
  97. integrationID,
  98. method: { type: "key", label: "API key" },
  99. }),
  100. )
  101. const updated = yield* events
  102. .subscribe(Integration.Event.Updated)
  103. .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
  104. yield* Effect.yieldNow
  105. yield* integrations.connection.key({
  106. integrationID,
  107. key: "secret",
  108. label: "Work",
  109. })
  110. expect(yield* credentials.list(integrationID)).toEqual([
  111. expect.objectContaining({
  112. integrationID,
  113. label: "Work",
  114. value: Credential.Key.make({ type: "key", key: "secret" }),
  115. }),
  116. ])
  117. expect((yield* Fiber.join(updated)).length).toBe(1)
  118. }),
  119. )
  120. it.effect("completes code OAuth once and stores the credential", () =>
  121. Effect.gen(function* () {
  122. const integrations = yield* Integration.Service
  123. const credentials = yield* Credential.Service
  124. const integrationID = Integration.ID.make("openai")
  125. const methodID = Integration.MethodID.make("chatgpt")
  126. yield* integrations.transform((editor) =>
  127. editor.method.update({
  128. integrationID,
  129. method: { id: methodID, type: "oauth", label: "ChatGPT" },
  130. authorize: () =>
  131. Effect.succeed({
  132. mode: "code" as const,
  133. url: "https://example.com/authorize",
  134. instructions: "Paste the code",
  135. callback: (code: string) =>
  136. Effect.succeed(
  137. Credential.OAuth.make({
  138. type: "oauth",
  139. methodID,
  140. access: "access",
  141. refresh: "refresh",
  142. expires: 1,
  143. metadata: { code },
  144. }),
  145. ),
  146. }),
  147. }),
  148. )
  149. const attempt = yield* integrations.connection.oauth({
  150. integrationID,
  151. methodID,
  152. inputs: {},
  153. label: "Personal",
  154. })
  155. expect(attempt.mode).toBe("code")
  156. yield* integrations.attempt.complete({ attemptID: attempt.attemptID, code: "1234" })
  157. expect((yield* credentials.list(integrationID))[0]).toEqual(
  158. expect.objectContaining({
  159. integrationID,
  160. label: "Personal",
  161. value: Credential.OAuth.make({
  162. type: "oauth",
  163. methodID,
  164. access: "access",
  165. refresh: "refresh",
  166. expires: 1,
  167. metadata: { code: "1234" },
  168. }),
  169. }),
  170. )
  171. }),
  172. )
  173. it.effect("keeps code attempts open when the code is missing and closes them on cancel", () =>
  174. Effect.gen(function* () {
  175. const integrations = yield* Integration.Service
  176. const credentials = yield* Credential.Service
  177. const integrationID = Integration.ID.make("openai")
  178. const methodID = Integration.MethodID.make("chatgpt")
  179. let closed = false
  180. yield* integrations.transform((editor) =>
  181. editor.method.update({
  182. integrationID,
  183. method: { id: methodID, type: "oauth", label: "ChatGPT" },
  184. authorize: () =>
  185. Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
  186. Effect.as({
  187. mode: "code" as const,
  188. url: "https://example.com/authorize",
  189. instructions: "Paste the code",
  190. callback: () => Effect.die("unexpected callback"),
  191. }),
  192. ),
  193. }),
  194. )
  195. const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
  196. expect(yield* integrations.attempt.complete({ attemptID: attempt.attemptID }).pipe(Effect.flip)).toBeInstanceOf(
  197. Integration.CodeRequiredError,
  198. )
  199. expect(closed).toBe(false)
  200. yield* integrations.attempt.cancel(attempt.attemptID)
  201. expect(closed).toBe(true)
  202. expect(yield* credentials.list(integrationID)).toEqual([])
  203. }),
  204. )
  205. it.effect("completes auto OAuth in the background", () =>
  206. Effect.gen(function* () {
  207. const integrations = yield* Integration.Service
  208. const credentials = yield* Credential.Service
  209. const integrationID = Integration.ID.make("openai")
  210. const methodID = Integration.MethodID.make("browser")
  211. yield* integrations.transform((editor) =>
  212. editor.method.update({
  213. integrationID,
  214. method: { id: methodID, type: "oauth", label: "Browser" },
  215. authorize: () =>
  216. Effect.succeed({
  217. mode: "auto" as const,
  218. url: "https://example.com/authorize",
  219. instructions: "Sign in",
  220. callback: Effect.succeed(
  221. Credential.OAuth.make({ type: "oauth", methodID, access: "access", refresh: "refresh", expires: 1 }),
  222. ),
  223. }),
  224. }),
  225. )
  226. const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
  227. yield* Effect.yieldNow
  228. expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
  229. status: "complete",
  230. time: attempt.time,
  231. })
  232. expect(yield* credentials.list(integrationID)).toHaveLength(1)
  233. }),
  234. )
  235. it.effect("expires abandoned OAuth attempts", () =>
  236. Effect.gen(function* () {
  237. const integrations = yield* Integration.Service
  238. const credentials = yield* Credential.Service
  239. const integrationID = Integration.ID.make("openai")
  240. const methodID = Integration.MethodID.make("browser")
  241. let closed = false
  242. yield* integrations.transform((editor) =>
  243. editor.method.update({
  244. integrationID,
  245. method: { id: methodID, type: "oauth", label: "Browser" },
  246. authorize: () =>
  247. Effect.addFinalizer(() => Effect.sync(() => (closed = true))).pipe(
  248. Effect.as({
  249. mode: "auto" as const,
  250. url: "https://example.com/authorize",
  251. instructions: "Sign in",
  252. callback: Effect.never,
  253. }),
  254. ),
  255. }),
  256. )
  257. const attempt = yield* integrations.connection.oauth({ integrationID, methodID, inputs: {} })
  258. expect(attempt.time.expires - attempt.time.created).toBe(Duration.toMillis(Duration.minutes(10)))
  259. yield* TestClock.adjust(Duration.minutes(10))
  260. yield* Effect.yieldNow
  261. expect(yield* integrations.attempt.status(attempt.attemptID)).toEqual({
  262. status: "expired",
  263. time: attempt.time,
  264. })
  265. expect(closed).toBe(true)
  266. expect(yield* credentials.list(integrationID)).toEqual([])
  267. }),
  268. )
  269. it.effect("projects credential and env connections", () => {
  270. const integrationID = Integration.ID.make("acme")
  271. return Effect.acquireUseRelease(
  272. Effect.sync(() => {
  273. const previous = process.env.INTEGRATION_TEST_ACME_KEY
  274. process.env.INTEGRATION_TEST_ACME_KEY = "secret"
  275. delete process.env.INTEGRATION_TEST_ACME_MISSING
  276. return previous
  277. }),
  278. () =>
  279. Effect.gen(function* () {
  280. const integrations = yield* Integration.Service
  281. const credentials = yield* Credential.Service
  282. yield* integrations.transform((editor) =>
  283. editor.method.update({
  284. integrationID,
  285. method: {
  286. type: "env",
  287. names: ["INTEGRATION_TEST_ACME_KEY", "INTEGRATION_TEST_ACME_MISSING"],
  288. },
  289. }),
  290. )
  291. const work = yield* credentials.create({
  292. integrationID,
  293. label: "Work",
  294. value: Credential.Key.make({ type: "key", key: "a" }),
  295. })
  296. const personal = yield* credentials.create({
  297. integrationID,
  298. label: "Personal",
  299. value: Credential.Key.make({ type: "key", key: "b" }),
  300. })
  301. // Stored credentials and detected env vars appear as connections.
  302. expect((yield* integrations.get(integrationID))?.connections).toEqual([
  303. {
  304. type: "credential",
  305. id: personal.id,
  306. label: "Personal",
  307. },
  308. { type: "env", name: "INTEGRATION_TEST_ACME_KEY" },
  309. ])
  310. expect(yield* integrations.connection.active(integrationID)).toEqual({
  311. type: "credential",
  312. id: personal.id,
  313. label: "Personal",
  314. })
  315. expect(work.id).not.toBe(personal.id)
  316. }),
  317. (previous) =>
  318. Effect.sync(() => {
  319. if (previous === undefined) delete process.env.INTEGRATION_TEST_ACME_KEY
  320. else process.env.INTEGRATION_TEST_ACME_KEY = previous
  321. }),
  322. )
  323. })
  324. })