mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
268 lines
9.7 KiB
TypeScript
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);
|
|
});
|
|
});
|