Skip to content
Merged
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
85 changes: 45 additions & 40 deletions tests/e2e/features/mcp-registry/mcp-registry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,42 @@ import {

const hasSSEHeaders = Boolean(process.env.MCP_SSE_HEADERS)

async function completeOAuthFlow(page: { context: () => any; request: any }, flow: {
authorize_url: string
oauth_config_id: string
complete_url?: string
status_url?: string
}) {
const popup = await page.context().newPage()
await popup.goto(flow.authorize_url)
await popup.locator('#user').fill('demo-user')
await popup.getByRole('button', { name: /Sign in/i }).click()
await popup.waitForLoadState('networkidle').catch(() => {})
await popup.close().catch(() => {})

const statusUrl = flow.status_url ?? `/api/oauth/config/${flow.oauth_config_id}/status`
let authorized = false
for (let i = 0; i < 30; i++) {
const statusResponse = await page.request.get(statusUrl)
if (!statusResponse.ok()) {
await new Promise((resolve) => setTimeout(resolve, 500))
continue
}
const statusBody = await statusResponse.json().catch(() => null)
if (statusBody?.status === 'authorized') {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
authorized = true
break
}
await new Promise((resolve) => setTimeout(resolve, 500))
}

expect(authorized).toBe(true)

const completeUrl = flow.complete_url ?? `/api/mcp/client/${flow.oauth_config_id}/complete-oauth`
const completeResponse = await page.request.post(completeUrl)
expect(completeResponse.ok()).toBe(true)
}

// Track created clients for cleanup
const createdClients: string[] = []

Expand Down Expand Up @@ -384,6 +420,7 @@ test.describe('MCP Registry', () => {
await expect(mcpRegistryPage.sheet).toBeVisible()

await mcpRegistryPage.selectAuthType('oauth')
await mcpRegistryPage.expandOAuthAdvancedIfCollapsed()

// All fields are optional — auto-discovered from server metadata
await expect(mcpRegistryPage.oauthClientIdInput).toBeVisible()
Expand All @@ -398,25 +435,9 @@ test.describe('MCP Registry', () => {
const clientData = createOAuthClientData()
createdClients.push(clientData.name)

const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true)

// OAuth Authorization dialog appears — click "Open Authorization Window"
const openWindowBtn = mcpRegistryPage.page.locator('[data-testid="oauth-open-window-btn"]')
await expect(openWindowBtn).toBeVisible({ timeout: 10000 })

const [popup] = await Promise.all([
mcpRegistryPage.page.waitForEvent('popup'),
openWindowBtn.click(),
])

// Complete login in popup (oauth-demo-server login form)
await popup.waitForLoadState()
await popup.locator('#user').fill('demo-user')
await popup.getByRole('button', { name: /Sign in/i }).click()

// Popup redirects to Bifrost callback then closes
await popup.waitForEvent('close', { timeout: 15000 }).catch(() => {})
const flow = await mcpRegistryPage.createOAuthClient(clientData)
await completeOAuthFlow(mcpRegistryPage.page, flow)
await mcpRegistryPage.goto()

// Client should now be connected and visible in table
const exists = await mcpRegistryPage.clientExists(clientData.name)
Expand All @@ -430,6 +451,7 @@ test.describe('MCP Registry', () => {
await expect(mcpRegistryPage.sheet).toBeVisible()

await mcpRegistryPage.selectAuthType('per_user_oauth')
await mcpRegistryPage.expandOAuthAdvancedIfCollapsed()

await expect(mcpRegistryPage.oauthClientIdInput).toBeVisible()
await expect(mcpRegistryPage.oauthClientSecretInput).toBeVisible()
Expand All @@ -443,30 +465,13 @@ test.describe('MCP Registry', () => {
const clientData = createPerUserOAuthClientData()
createdClients.push(clientData.name)

const created = await mcpRegistryPage.createClient(clientData)
expect(created).toBe(true)

// "Test OAuth Configuration" dialog appears. Per-user OAuth opens the
// authorization popup directly from this confirmation click.
const confirmBtn = mcpRegistryPage.page.locator('[data-testid="per-user-oauth-confirm"]')
await expect(confirmBtn).toBeVisible({ timeout: 10000 })

const [popup] = await Promise.all([
mcpRegistryPage.page.waitForEvent('popup'),
confirmBtn.click(),
])

// Complete login in popup (oauth-demo-server login form)
await popup.waitForLoadState()
await popup.locator('#user').fill('demo-user')
await popup.getByRole('button', { name: /Sign in/i }).click()

// Popup redirects to Bifrost callback then closes
await popup.waitForEvent('close', { timeout: 15000 }).catch(() => {})
const flow = await mcpRegistryPage.createOAuthClient(clientData)
await completeOAuthFlow(mcpRegistryPage.page, flow)
await mcpRegistryPage.goto()

// Client should be visible in table
const exists = await mcpRegistryPage.clientExists(clientData.name)
expect(exists).toBe(true)
})
})
})
})
118 changes: 107 additions & 11 deletions tests/e2e/features/mcp-registry/pages/mcp-registry.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export class MCPRegistryPage extends BasePage {
readonly cancelBtn: Locator
readonly connectionTypeSelect: Locator
readonly authTypeSelect: Locator
readonly authScopeSelect: Locator
readonly connectionUrlInput: Locator
readonly codeModeSwitch: Locator
readonly pingAvailableSwitch: Locator
Expand Down Expand Up @@ -89,6 +90,7 @@ export class MCPRegistryPage extends BasePage {
// Connection type and auth
this.connectionTypeSelect = page.locator('[data-testid="connection-type-select"]')
this.authTypeSelect = page.locator('[data-testid="auth-type-select"]')
this.authScopeSelect = page.locator('[data-testid="auth-scope-select"]')
// Use placeholder as primary selector for EnvVarInput (more reliable)
this.connectionUrlInput = this.sheet.getByPlaceholder(/http:\/\/your-mcp-server/i).or(
page.locator('[data-testid="connection-url-input"]')
Expand All @@ -104,11 +106,11 @@ export class MCPRegistryPage extends BasePage {
this.envsInput = page.locator('[data-testid="stdio-envs-input"]')

// OAuth inputs
this.oauthClientIdInput = this.sheet.getByPlaceholder(/your-client-id/i)
this.oauthClientSecretInput = this.sheet.getByPlaceholder(/your-client-secret/i)
this.oauthAuthorizeUrlInput = this.sheet.getByPlaceholder(/oauth\/authorize/i)
this.oauthTokenUrlInput = this.sheet.getByPlaceholder(/oauth\/token/i)
this.oauthScopesInput = this.sheet.getByPlaceholder(/read, write, admin/i)
this.oauthClientIdInput = this.sheet.locator('[data-testid="mcp-oauth-client-id"]').or(this.sheet.getByPlaceholder(/your-client-id/i))
this.oauthClientSecretInput = this.sheet.locator('[data-testid="mcp-oauth-client-secret"]').or(this.sheet.getByPlaceholder(/your-client-secret/i))
this.oauthAuthorizeUrlInput = this.sheet.locator('[data-testid="mcp-oauth-authorize-url"]').or(this.sheet.getByPlaceholder(/oauth\/authorize/i))
this.oauthTokenUrlInput = this.sheet.locator('[data-testid="mcp-oauth-token-url"]').or(this.sheet.getByPlaceholder(/oauth\/token/i))
this.oauthScopesInput = this.sheet.locator('[data-testid="mcp-oauth-scopes-input"]').or(this.sheet.getByPlaceholder(/read, write, admin/i))
}

async goto(): Promise<void> {
Expand Down Expand Up @@ -188,14 +190,58 @@ export class MCPRegistryPage extends BasePage {
await expect(selectTrigger).toBeVisible({ timeout: 5000 })
await selectTrigger.click()

// Select the option by data-testid (per_user_oauth uses kebab-case in testid)
const optionTestId = `auth-type-${type.replace(/_/g, '-')}`
// The UI now splits auth configuration into auth type + auth scope.
const authKind = type === 'per_user_oauth' ? 'oauth' : type
const optionTestId = `auth-type-${authKind.replace(/_/g, '-')}`
const option = this.page.locator(`[data-testid="${optionTestId}"]`)
await expect(option).toBeVisible({ timeout: 5000 })
await option.click()

// Wait for dropdown to close
await expect(option).not.toBeVisible({ timeout: 3000 }).catch(() => {})

if (type === 'per_user_oauth') {
await this.selectAuthScope('per_user')
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

async selectAuthScope(scope: 'shared' | 'per_user'): Promise<void> {
const selectTrigger = this.page.locator('[data-testid="auth-scope-select"]')
await expect(selectTrigger).toBeVisible({ timeout: 5000 })
await selectTrigger.click()

const optionTestId = `auth-scope-${scope.replace(/_/g, '-')}`
const option = this.page.locator(`[data-testid="${optionTestId}"]`)
await expect(option).toBeVisible({ timeout: 5000 })
await option.click()

await expect(option).not.toBeVisible({ timeout: 3000 }).catch(() => {})
}

async expandOAuthAdvancedIfCollapsed(): Promise<void> {
const trigger = this.sheet.locator('[data-testid="oauth-advanced-trigger"]')
const visible = await trigger.isVisible().catch(() => false)
if (!visible) return

const clientIdVisible = await this.oauthClientIdInput.isVisible().catch(() => false)
if (clientIdVisible) return

await trigger.click()
await expect(this.oauthClientIdInput).toBeVisible({ timeout: 5000 })
}

async waitForOAuthAuthorizerAction(timeout = 10000): Promise<Locator> {
const continueBtn = this.page.locator('[data-testid="per-user-oauth-confirm"]')
const openWindowBtn = this.page.locator('[data-testid="oauth-open-window-btn"]')

const deadline = Date.now() + timeout
while (Date.now() < deadline) {
if (await continueBtn.isVisible().catch(() => false)) return continueBtn
if (await openWindowBtn.isVisible().catch(() => false)) return openWindowBtn
await this.page.waitForTimeout(200)
}

throw new Error('OAuth authorizer action was not shown')
}

/**
Expand Down Expand Up @@ -281,6 +327,7 @@ export class MCPRegistryPage extends BasePage {

// Handle OAuth config (oauth and per_user_oauth share the same fields)
if (config.authType === 'oauth' || config.authType === 'per_user_oauth') {
await this.expandOAuthAdvancedIfCollapsed()
if (config.oauthClientId) {
await this.oauthClientIdInput.fill(config.oauthClientId)
}
Expand Down Expand Up @@ -360,6 +407,13 @@ export class MCPRegistryPage extends BasePage {
throw new Error(`Create MCP client failed: ${response.status()} ${body}`)
}

const responseBody = response ? await response.text().catch(() => '') : ''
const pendingOAuth = responseBody.includes('"status":"pending_oauth"')
if (pendingOAuth) {
await this.waitForOAuthAuthorizerAction()
return true
}

// Success: backend returned 2xx. Wait for create form to close (short timeout; UI usually updates quickly).
const createFormHeading = this.page.getByRole('heading', { name: 'New MCP Server' })
await createFormHeading.waitFor({ state: 'hidden', timeout: 15000 }).catch(() => null)
Expand Down Expand Up @@ -423,12 +477,54 @@ export class MCPRegistryPage extends BasePage {
throw new Error('No toast appeared and sheet did not close - form submission may have failed')
}

async createOAuthClient(config: MCPClientConfig): Promise<{
status: string
authorize_url: string
oauth_config_id: string
complete_url?: string
status_url?: string
mcp_client_id: string
}> {
await this.dismissToasts()
await this.createBtn.click()
await expect(this.sheet).toBeVisible({ timeout: 5000 })

await this.fillClientForm(config)
await this.page.waitForTimeout(1500)
await expect(this.saveBtn).toBeEnabled({ timeout: 10000 })

const responsePromise = this.page.waitForResponse(
(response) => {
const url = response.url()
const method = response.request().method()
return url.includes('/mcp/client') && !url.endsWith('/mcp/clients') && method === 'POST'
},
{ timeout: 60000 }
)

await this.saveBtn.click({ force: true })
const response = await responsePromise
if (!response.ok()) {
const body = await response.text().catch(() => '')
throw new Error(`Create OAuth MCP client failed: ${response.status()} ${body}`)
}

const body = await response.json()
if (body?.status !== 'pending_oauth' || !body?.authorize_url || !body?.oauth_config_id) {
throw new Error(`Expected pending_oauth response, got: ${JSON.stringify(body)}`)
}

return body
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/**
* View client details by clicking on the row
* View client details from the actions menu
*/
async viewClientDetails(name: string): Promise<void> {
const row = this.getClientRow(name)
await row.click()
await this.openClientActions(name)
const editItem = this.page.getByRole('menuitem', { name: /Edit/i })
await expect(editItem).toBeVisible({ timeout: 5000 })
await editItem.click()
await expect(this.detailSheet).toBeVisible({ timeout: 5000 })
}

Expand Down Expand Up @@ -723,4 +819,4 @@ export class MCPRegistryPage extends BasePage {
const emptyMessage = this.page.getByText(/No clients found/i)
return await emptyMessage.isVisible().catch(() => false)
}
}
}
Loading