integration.test.ts 14 KB

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