Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 37 additions & 22 deletions core/test/connectors.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,21 @@ import { apply } from '../index.js'
import { cleanApps, composioAppStatus, composioDeleteSession, composioSearchApps, composioSession, composioSessionAlive, composioSessionFind, signIn } from '../src/host/connectors.js'
import { fakeContext } from './fake-ctx.js'

// A test's temp home can still be written by a background save that outlives dispose(), so a
// plain rmSync sometimes raced and failed the whole run with ENOTEMPTY (a flaky verify/CI). Settle
// and retry; if it is still racing, leave the temp dir — the test's assertions already passed.
async function cleanHome(home) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
rmSync(home, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 })
return
} catch (error) {
if (error?.code !== 'ENOTEMPTY' && error?.code !== 'EBUSY' && error?.code !== 'ENOENT') throw error
await new Promise(resolve => setTimeout(resolve, 50))
}
}
}

const fresh = () => `ghp_${randomBytes(16).toString('hex')}`
const answer = (status, body) => ({ status, ok: status >= 200 && status < 300, body, json: async () => body })

Expand Down Expand Up @@ -82,7 +97,7 @@ test('connecting saves the token, kept out of every Bot shell, and mounts GitHub
t.after(() => { globalThis.fetch = realFetch })
globalThis.fetch = async () => answer(200, { login: 'octo' })
const { ctx, home, mounts } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const token = fresh()

const before = await call(ctx, 'state', {})
Expand Down Expand Up @@ -115,7 +130,7 @@ test('a token GitHub refuses is not saved', async (t) => {
t.after(() => { globalThis.fetch = realFetch })
globalThis.fetch = async () => answer(401, {})
const { ctx, home, mounts } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const result = await call(ctx, 'connector-connect', { id: 'github', token: fresh() })
assert.equal(result.ok, false)
assert.match(result.error.message, /did not accept/)
Expand All @@ -128,7 +143,7 @@ test('a failed MCP connection shows the error without the token, and Try again m
t.after(() => { globalThis.fetch = realFetch })
globalThis.fetch = async () => answer(200, { login: 'octo' })
const { ctx, home, mounts } = setup({ fail: true })
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const token = fresh()
const result = await call(ctx, 'connector-connect', { id: 'github', token })
const github = result.value.find(entry => entry.id === 'github')
Expand All @@ -144,7 +159,7 @@ test('deleting GITHUB_TOKEN on the Secrets page unmounts GitHub', async (t) => {
t.after(() => { globalThis.fetch = realFetch })
globalThis.fetch = async () => answer(200, { login: 'octo' })
const { ctx, home, mounts } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
await call(ctx, 'connector-connect', { id: 'github', token: fresh() })
await call(ctx, 'secret-delete', { name: 'GITHUB_TOKEN' })
const state = await call(ctx, 'state', {})
Expand All @@ -157,13 +172,13 @@ test('github: false and composio: false hide the connectors', async (t) => {
const ctx = fakeContext()
capture(ctx)
apply(ctx, { home, github: false })
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
assert.deepEqual((await call(ctx, 'state', {})).value.connectors.map(entry => entry.id), ['composio'])
const other = teamHome()
const second = fakeContext()
capture(second)
apply(second, { home: other, github: false, composio: false })
t.after(async () => { await second.dispose(); rmSync(other, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await second.dispose(); await cleanHome(other) })
assert.deepEqual((await call(second, 'state', {})).value.connectors, [])
})

Expand All @@ -173,7 +188,7 @@ test('a GitHub address without https refuses to send the token', async (t) => {
let asked = false
globalThis.fetch = async () => { asked = true; return answer(200, { login: 'octo' }) }
const { ctx, home, mounts } = setup({ github: { url: 'http://mcp.test/', api: 'https://gh.test' } })
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const refused = await call(ctx, 'connector-connect', { id: 'github', token: fresh() })
assert.equal(refused.ok, false)
assert.match(refused.error.message, /https/)
Expand Down Expand Up @@ -256,7 +271,7 @@ test('connecting Composio keeps the key out of every Bot shell and the profile,
const sent = fakeComposio()
globalThis.fetch = sent.fetch
const { ctx, home, mounts } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const key = composioKey()

const before = (await call(ctx, 'state', {})).value.connectors.find(entry => entry.id === 'composio')
Expand Down Expand Up @@ -307,7 +322,7 @@ test('Choose apps mounts again with the new list and needs no key; an unknown ap
const sent = fakeComposio(() => refuse ? answer(400, { error: { message: 'Unknown toolkit nope' } }) : answer(201, SESSION))
globalThis.fetch = sent.fetch
const { ctx, home, mounts } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const early = await call(ctx, 'connector-apps', { id: 'composio', apps: ['gmail'] })
assert.equal(early.ok, false)
assert.match(early.error.message, /Connect Composio first/)
Expand Down Expand Up @@ -336,7 +351,7 @@ test('a key Composio refuses is not saved', async (t) => {
t.after(() => { globalThis.fetch = realFetch })
globalThis.fetch = fakeComposio(() => answer(401, {})).fetch
const { ctx, home, mounts } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const result = await call(ctx, 'connector-connect', { id: 'composio', token: composioKey(), apps: ['gmail'] })
assert.equal(result.ok, false)
assert.match(result.error.message, /did not accept/)
Expand All @@ -349,7 +364,7 @@ test('a failed Composio MCP connection shows an error without the key, and Try a
t.after(() => { globalThis.fetch = realFetch })
globalThis.fetch = fakeComposio().fetch
const { ctx, home, mounts } = setup({ fail: true })
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const key = composioKey()
const result = await call(ctx, 'connector-connect', { id: 'composio', token: key, apps: ['gmail'] })
const composio = result.value.find(entry => entry.id === 'composio')
Expand All @@ -364,7 +379,7 @@ test('deleting COMPOSIO_API_KEY on the Secrets page unmounts Composio, and GitHu
t.after(() => { globalThis.fetch = realFetch })
globalThis.fetch = async (url, init) => url.startsWith('https://gh.test') ? answer(200, { login: 'octo' }) : fakeComposio().fetch(url, init)
const { ctx, home, mounts } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
await call(ctx, 'connector-connect', { id: 'github', token: fresh() })
await call(ctx, 'connector-connect', { id: 'composio', token: composioKey(), apps: ['gmail'] })
assert.deepEqual(mounts.map(mount => mount.config.serverName), ['github', 'composio'])
Expand All @@ -381,7 +396,7 @@ test('a Composio address without https refuses to send the key', async (t) => {
let asked = false
globalThis.fetch = async () => { asked = true; return answer(201, SESSION) }
const { ctx, home, mounts } = setup({ composio: { api: 'http://composio.test/api/v3.1' } })
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const refused = await call(ctx, 'connector-connect', { id: 'composio', token: composioKey(), apps: ['gmail'] })
assert.equal(refused.ok, false)
assert.match(refused.error.message, /https/)
Expand Down Expand Up @@ -430,7 +445,7 @@ test('Composio makes one session per connect, keeps it (never the key) in state,
const sent = fakeComposio(numbered())
globalThis.fetch = sent.fetch
const { ctx, home, mounts } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const key = composioKey()
await call(ctx, 'connector-connect', { id: 'composio', token: key, apps: ['gmail'] })
assert.equal(sent.posts.length, 1, 'the key check and the mount share one session')
Expand Down Expand Up @@ -462,7 +477,7 @@ test('a session Composio no longer has is made again on Try again, and the new o
const sent = fakeComposio(numbered())
globalThis.fetch = sent.fetch
const { ctx, home, mounts } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
await call(ctx, 'connector-connect', { id: 'composio', token: composioKey(), apps: ['gmail'] })
sent.live.clear()
const retried = await call(ctx, 'connector-retry', { id: 'composio' })
Expand All @@ -480,7 +495,7 @@ test('Choose apps deletes the superseded session after the new one is kept; a re
const sent = fakeComposio(() => refuse ? answer(400, { error: { message: 'Unknown toolkit nope' } }) : make())
globalThis.fetch = sent.fetch
const { ctx, home, mounts } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const key = composioKey()
await call(ctx, 'connector-connect', { id: 'composio', token: key, apps: ['gmail'] })
const changed = await call(ctx, 'connector-apps', { id: 'composio', apps: ['gmail', 'notion'] })
Expand All @@ -506,7 +521,7 @@ test('Disconnect deletes the session with the key it was made with, drops it fro
let breakDelete = false
globalThis.fetch = (url, init) => breakDelete && init.method === 'DELETE' ? Promise.reject(new Error('boom')) : sent.fetch(url, init)
const { ctx, home } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const key = composioKey()
await call(ctx, 'connector-connect', { id: 'composio', token: key, apps: ['gmail'] })
const userId = JSON.parse(readFileSync(join(home, 'state.json'), 'utf8')).connectors.composio.userId
Expand Down Expand Up @@ -534,7 +549,7 @@ test('a new key makes its own session and deletes the old key\'s session with th
const sent = fakeComposio(numbered())
globalThis.fetch = sent.fetch
const { ctx, home } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const first = composioKey()
const second = composioKey()
await call(ctx, 'connector-connect', { id: 'composio', token: first, apps: ['gmail'] })
Expand Down Expand Up @@ -584,7 +599,7 @@ test('an address saved by an older version is dropped from state.json at start,
globalThis.fetch = sent.fetch
const first = setup()
const { home } = first
t.after(() => rmSync(home, { recursive: true, force: true, maxRetries: 5 }))
t.after(() => cleanHome(home))
const key = composioKey()
await call(first.ctx, 'connector-connect', { id: 'composio', token: key, apps: ['gmail'] })
await first.ctx.dispose()
Expand Down Expand Up @@ -613,7 +628,7 @@ test('a session whose answer names no address on Composio\'s host is made again,
const sent = fakeComposio(numbered())
globalThis.fetch = sent.fetch
const { ctx, home, mounts } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
await call(ctx, 'connector-connect', { id: 'composio', token: composioKey(), apps: ['gmail'] })
sent.addresses.set('trs_1', 'https://evil.test/mcp')
const retried = await call(ctx, 'connector-retry', { id: 'composio' })
Expand All @@ -628,7 +643,7 @@ test('a mount error that quotes the session address shows it as [session address
t.after(() => { globalThis.fetch = realFetch })
globalThis.fetch = fakeComposio(numbered()).fetch
const { ctx, home } = setup({ fail: 'url' })
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const result = await call(ctx, 'connector-connect', { id: 'composio', token: composioKey(), apps: ['gmail'] })
const composio = result.value.find(entry => entry.id === 'composio')
assert.equal(composio.state, 'error')
Expand Down Expand Up @@ -709,7 +724,7 @@ test('the Connectors page searches apps with the pasted key before connecting (n
return sent.fetch(url, init)
}
const { ctx, home } = setup()
t.after(async () => { await ctx.dispose(); rmSync(home, { recursive: true, force: true, maxRetries: 5 }) })
t.after(async () => { await ctx.dispose(); await cleanHome(home) })
const key = composioKey()

const keyless = await call(ctx, 'connector-app-search', { id: 'composio', query: 'not' })
Expand Down
Loading
Loading