Files
openhuman/app/test/playwright/specs/connector-gmail-composio.spec.ts
T

268 lines
9.7 KiB
TypeScript

import { expect, type Page, test } from '@playwright/test';
import {
bootRuntimeReadyGuestPage,
callCoreRpc,
dismissWalkthroughIfPresent,
signInViaCallbackToken,
waitForAppReady,
} from '../helpers/core-rpc';
const CONNECTOR_NAME = 'Gmail';
const TOOLKIT_SLUG = 'gmail';
const CONNECTION_ID = 'c-gmail-1';
const ACTION = 'GMAIL_FETCH_EMAILS';
const MOCK_BASE = 'http://127.0.0.1:' + (process.env.E2E_MOCK_PORT || '18473');
type RequestLogEntry = { method?: string; url?: string; body?: string };
async function mockFetch(path: string, init?: RequestInit) {
const response = await fetch(MOCK_BASE + path, init);
if (!response.ok) throw new Error('mock request failed: ' + response.status + ' ' + path);
return response.json() as Promise<{ data?: unknown }>;
}
async function resetMock() {
await mockFetch('/__admin/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keepBehavior: false, keepRequests: false }),
});
}
async function setMockBehavior(behavior: Record<string, unknown>) {
await mockFetch('/__admin/behavior', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ behavior }),
});
}
async function getRequestLog(): Promise<RequestLogEntry[]> {
const payload = await mockFetch('/__admin/requests');
return (payload.data as RequestLogEntry[]) ?? [];
}
async function seedConnector(status: 'ACTIVE' | 'FAILED' | 'EXPIRED' = 'ACTIVE') {
await setMockBehavior({
composioToolkits: JSON.stringify([TOOLKIT_SLUG]),
composioConnections: JSON.stringify([{ id: CONNECTION_ID, toolkit: TOOLKIT_SLUG, status }]),
});
}
async function bootSkillsPage(page: Page, userId: string) {
await resetMock();
await seedConnector();
await bootRuntimeReadyGuestPage(page);
try {
await signInViaCallbackToken(page, userId);
} catch {
await bootRuntimeReadyGuestPage(page);
await signInViaCallbackToken(page, userId);
}
await page.evaluate(() => {
try {
localStorage.setItem('openhuman:walkthrough_completed', 'true');
localStorage.removeItem('openhuman:walkthrough_pending');
} catch {}
});
// Phase 2: /skills → /connections
await page.evaluate(() => {
window.location.hash = '/connections';
});
await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 })
.toContain('/connections');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
// Navigate to the Composio tab
await page.getByRole('tab', { name: 'Composio' }).click();
const heading = page.getByRole('heading', { name: 'Composio Integrations', exact: true });
if (!(await heading.isVisible().catch(() => false))) {
const connectionsButton = page.getByRole('button', { name: 'Connections' });
if (await connectionsButton.isVisible().catch(() => false)) {
await connectionsButton.click({ force: true });
await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 })
.toContain('/connections');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
}
}
await expect(
page.getByRole('heading', { name: 'Composio Integrations', exact: true })
).toBeVisible({ timeout: 20_000 });
}
async function reloadSkills(page: Page) {
await ensureComposioSurface(page);
}
async function ensureComposioSurface(page: Page) {
// Navigate to /connections and click the Composio tab
const heading = page.getByRole('heading', { name: 'Composio Integrations', exact: true });
for (let attempt = 0; attempt < 3; attempt++) {
await page.evaluate(() => {
window.location.hash = '/connections';
});
await expect
.poll(async () => page.evaluate(() => window.location.hash), { timeout: 10_000 })
.toContain('/connections');
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Composio' }).click();
if (await heading.isVisible().catch(() => false)) {
return;
}
const connectionsButton = page.getByRole('button', { name: 'Connections' });
if (await connectionsButton.isVisible().catch(() => false)) {
await connectionsButton.click({ force: true });
await waitForAppReady(page);
await dismissWalkthroughIfPresent(page);
await page.getByRole('tab', { name: 'Composio' }).click();
if (await heading.isVisible().catch(() => false)) {
return;
}
}
}
await expect(heading).toBeVisible({ timeout: 20_000 });
}
async function assertSessionNotNuked(page: Page) {
// Phase 2: /skills → /connections
await expect
.poll(async () =>
page.evaluate(() => {
const win = window as typeof window & {
__OPENHUMAN_CORE_STATE__?: () => {
snapshot?: {
sessionToken?: string | null;
currentUser?: { _id?: string | null } | null;
};
};
};
const snapshot = win.__OPENHUMAN_CORE_STATE__?.()?.snapshot;
return {
// The connections page now appends an active-tab query (e.g.
// `#/connections?tab=composio`); strip it so we assert we're still on
// the connections route (session not nuked), not the exact sub-tab.
hash: window.location.hash.replace(/\?.*$/, ''),
hasToken: Boolean(snapshot?.sessionToken),
hasUser: Boolean(snapshot?.currentUser?._id),
};
})
)
.toEqual({ hash: '#/connections', hasToken: true, hasUser: true });
}
async function openModal(page: Page) {
await page.getByTestId('skill-install-composio-gmail').click();
const dialog = page.getByRole('dialog', { name: /(Connect|Manage|Reconnect) Gmail/i });
await expect(dialog).toBeVisible();
return dialog;
}
function unwrapConnections(payload: unknown): Array<{ toolkit?: string; status?: string }> {
const root = payload as {
result?: { connections?: Array<{ toolkit?: string; status?: string }> };
connections?: Array<{ toolkit?: string; status?: string }>;
};
return root.result?.connections ?? root.connections ?? [];
}
test.describe('Gmail connector', () => {
test.beforeEach(async ({ page }, testInfo) => {
const testSlug = testInfo.title.toLowerCase().replace(/[^a-z0-9]+/g, '-');
await bootSkillsPage(page, 'pw-gmail-' + testSlug);
});
test('renders the connector card', async ({ page }) => {
await expect(page.getByTestId('skill-install-composio-gmail')).toContainText(CONNECTOR_NAME);
});
test('routes authorize through the mock backend', async () => {
await callCoreRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
const requests = await getRequestLog();
const authReq = requests.find(
request =>
request.method === 'POST' && request.url?.includes('/agent-integrations/composio/authorize')
);
expect(authReq).toBeDefined();
expect(JSON.parse(authReq?.body || '{}')).toMatchObject({ toolkit: TOOLKIT_SLUG });
});
test('persists connected state through list_connections', async () => {
const payload = await callCoreRpc<unknown>('openhuman.composio_list_connections', {});
const hit = unwrapConnections(payload).find(
connection => connection.toolkit?.toLowerCase() === TOOLKIT_SLUG
);
expect(hit?.status).toBe('ACTIVE');
});
test.skip('keeps the session alive after composio_sync', async ({ page }) => {
await callCoreRpc('openhuman.composio_sync', { connection_id: CONNECTION_ID });
await assertSessionNotNuked(page);
});
test('routes composio_execute without blanking the app', async ({ page }) => {
await callCoreRpc('openhuman.composio_execute', { tool: ACTION, arguments: {} });
await assertSessionNotNuked(page);
});
test('survives a 400 fetch emails error and keeps the skills page usable', async ({ page }) => {
await setMockBehavior({ composioExecuteFails: '1' });
await expect(
callCoreRpc('openhuman.composio_execute', {
connection_id: CONNECTION_ID,
tool: ACTION,
arguments: {},
})
).rejects.toThrow(/failed/i);
await assertSessionNotNuked(page);
await reloadSkills(page);
await expect(page.getByTestId('skill-install-composio-gmail')).toContainText(CONNECTOR_NAME);
});
test('survives failed connector state on the skills page', async ({ page }) => {
await seedConnector('FAILED');
await reloadSkills(page);
await expect(page.getByTestId('skill-install-composio-gmail')).toContainText(CONNECTOR_NAME);
await assertSessionNotNuked(page);
});
test('shows expired-auth state without logging out', async ({ page }) => {
await seedConnector('EXPIRED');
await reloadSkills(page);
await expect(page.getByTestId('skill-install-composio-' + TOOLKIT_SLUG)).toContainText(
/Auth expired|Reconnect/i
);
const dialog = await openModal(page);
await expect(dialog).toContainText(CONNECTOR_NAME);
await assertSessionNotNuked(page);
});
test('survives a 4xx composio execute error', async ({ page }) => {
await setMockBehavior({ composioExecuteFails: '400' });
await expect(
callCoreRpc('openhuman.composio_execute', {
connection_id: CONNECTION_ID,
tool: ACTION,
arguments: {},
})
).rejects.toThrow(/failed/i);
await assertSessionNotNuked(page);
});
test('routes disconnect through the mock backend', async ({ page }) => {
await callCoreRpc('openhuman.composio_delete_connection', { connection_id: CONNECTION_ID });
const requests = await getRequestLog();
const deleteReq = requests.find(
request =>
request.method === 'DELETE' &&
request.url?.includes('/agent-integrations/composio/connections/')
);
expect(deleteReq).toBeDefined();
await assertSessionNotNuked(page);
});
});