Skip to content
Open
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
162 changes: 162 additions & 0 deletions app/test/e2e/helpers/composio-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// @ts-nocheck
/**
* Shared helpers for Composio connector E2E specs.
*
* All helpers are platform-agnostic (tauri-driver + Appium Mac2) and
* follow the same patterns established in composio-triggers-flow.spec.ts
* and the existing shared-flows / element-helpers modules.
*/
import { setMockBehavior } from '../mock-server';
import { textExists, waitForText } from './element-helpers';
import { navigateToHome, navigateToSkills, waitForHomePage } from './shared-flows';

const LOG = '[ComposioHelpers]';

// ---------------------------------------------------------------------------
// Seed helpers — set mock behavior knobs before navigation
// ---------------------------------------------------------------------------

/**
* Seed a single Composio connection into the mock backend.
*
* Sets the `composioConnections` behavior knob with a single entry for the
* given toolkit. Subsequent calls overwrite any previous seed — isolate
* specs by calling this in `beforeEach` or at the start of each test.
*/
export function seedComposioConnection(
toolkit: string,
status: 'ACTIVE' | 'FAILED' | 'EXPIRED' | 'CONNECTING',
connectionId: string = 'c-e2e'
): void {
setMockBehavior('composioConnections', JSON.stringify([{ id: connectionId, toolkit, status }]));
}

/**
* Seed the list of available Composio toolkits shown on the Skills page.
*
* Sets the `composioToolkits` behavior knob to the given slugs array.
*/
export function seedComposioToolkits(slugs: string[]): void {
setMockBehavior('composioToolkits', JSON.stringify(slugs));
}

// ---------------------------------------------------------------------------
// Navigation + UI assertion helpers
// ---------------------------------------------------------------------------

/**
* Navigate to /skills and wait until the connector card with the given
* display name is visible.
*
* Throws (via waitForText) if the card is not visible within the timeout.
*/
export async function assertConnectorCardVisible(name: string, timeout = 15_000): Promise<void> {
await navigateToSkills();
await waitForText(name, timeout);
console.log(`${LOG} connector card visible: "${name}"`);
}

/**
* Click a connector card by display name, then wait for the modal header
* to appear. The modal header text is either "Connect <name>", "Manage
* <name>", or "Reconnect <name>" depending on connection state.
*
* Returns the modal header text that was found, or null when none of the
* candidates appeared within the timeout (so callers that can tolerate a
* missing modal don't have to wrap in try/catch).
*/
export async function openConnectorModal(name: string, timeout = 15_000): Promise<string | null> {
console.log(`${LOG} opening connector modal for "${name}"`);
// Click the connector card by name
const cardEl = await waitForText(name, timeout);
await cardEl.click();
await browser.pause(1_500);

// Wait for any of the standard modal header patterns
const candidates = [`Connect ${name}`, `Manage ${name}`, `Reconnect ${name}`];
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
for (const candidate of candidates) {
if (await textExists(candidate)) {
console.log(`${LOG} modal opened: "${candidate}"`);
return candidate;
}
}
await browser.pause(400);
}

console.log(`${LOG} modal for "${name}" did not open within timeout`);
return null;
}

/**
* Assert the modal is in a given phase by checking UI markers.
*
* Phase markers:
* idle — Connect button present (no active connection)
* connected — "is connected" or Disconnect button visible
* expired — "authorization expired" text visible
* error — error UI present (coral-coloured error block)
*/
export async function assertModalPhase(
phase: 'idle' | 'connected' | 'expired' | 'error',
name: string,
timeout = 10_000
): Promise<void> {
const deadline = Date.now() + timeout;

const phaseMarkers: Record<string, string[]> = {
idle: [`Connect ${name}`, 'Connect'],
connected: ['Disconnect', 'is connected'],
expired: ['authorization expired', 'Reconnect'],
error: ['Something went wrong', 'Authorization failed', 'dismissAll'],
};

const markers = phaseMarkers[phase] ?? [];
while (Date.now() < deadline) {
for (const marker of markers) {
if (await textExists(marker)) {
console.log(`${LOG} modal phase "${phase}" confirmed via marker: "${marker}"`);
return;
}
}
await browser.pause(400);
}

throw new Error(
`assertModalPhase: phase "${phase}" for "${name}" not confirmed within ${timeout}ms — no marker found in [${markers.join(', ')}]`
);
}

/**
* Assert that the user session is still alive (not logged out) by navigating
* to /home and waiting for home page content.
*
* This is the key guard for the "401 on composio routes must NOT log user
* out" class of regressions (#2285, #2286).
*/
export async function assertSessionNotNuked(timeout = 20_000): Promise<void> {
console.log(`${LOG} asserting session is intact — navigating to /home`);
await navigateToHome();
const marker = await waitForHomePage(timeout);
if (!marker) {
throw new Error(`assertSessionNotNuked: Home page not reached — user may have been logged out`);
}
console.log(`${LOG} session intact, home page marker: "${marker}"`);
}

/**
* Inject a mock HTTP fault on all Composio routes by setting the
* composioExecuteFails / composioDeleteFails / composioSyncFails behavior
* knobs to trigger the given status code.
*
* Supported status codes: 400, 500.
* The mock route handlers interpret knob value '400' → HTTP 400 and '500' → HTTP 500.
*/
export function injectComposioFault(statusCode: 400 | 500): void {
const value = String(statusCode);
setMockBehavior('composioExecuteFails', value);
setMockBehavior('composioDeleteFails', value);
setMockBehavior('composioSyncFails', value);
console.log(`${LOG} injected composio fault: status=${statusCode}`);
}
166 changes: 166 additions & 0 deletions app/test/e2e/specs/connector-airtable.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// @ts-nocheck
/**
* E2E: Airtable (Composio) connector flow.
*/
import { waitForApp } from '../helpers/app-helpers';
import {
assertConnectorCardVisible,
assertModalPhase,
assertSessionNotNuked,
injectComposioFault,
openConnectorModal,
seedComposioConnection,
seedComposioToolkits,
} from '../helpers/composio-helpers';
import { callOpenhumanRpc } from '../helpers/core-rpc';
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
import {
textExists,
waitForText,
waitForWebView,
waitForWindowVisible,
} from '../helpers/element-helpers';
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
import {
clearRequestLog,
getRequestLog,
resetMockBehavior,
startMockServer,
stopMockServer,
} from '../mock-server';

const LOG = '[ConnectorAirtableE2E]';
const CONNECTOR_NAME = 'Airtable';
const TOOLKIT_SLUG = 'airtable';
const AUTH_TOKEN = 'e2e-connector-airtable-token';

describe('Airtable Composio connector flow', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-airtable-1');
await waitForApp();
clearRequestLog();
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
await waitForWindowVisible(25_000);
await waitForWebView(15_000);
await completeOnboardingIfVisible(LOG);
});

after(async () => {
await stopMockServer();
});

afterEach(async () => {
resetMockBehavior();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-airtable-1');
});

it('card is visible and selectable', async function () {
this.timeout(60_000);
await assertConnectorCardVisible(CONNECTOR_NAME);
console.log(`${LOG} PASS: card visible`);
});

it('auth/connect flow succeeds with mocked backend', async function () {
this.timeout(60_000);
clearRequestLog();
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
expect(out.ok).toBe(true);
const log = getRequestLog();
const authReq = log.find(r => r.method === 'POST' && r.url.includes('/composio/authorize'));
expect(authReq).toBeDefined();
console.log(`${LOG} PASS: auth/connect routed`);
});

it('connected state persists after reconnect/reload', async function () {
this.timeout(60_000);
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
expect(out.ok).toBe(true);
const result = (out.result as { result?: unknown })?.result ?? out.result;
const connections = (result as { connections?: unknown[] })?.connections ?? [];
const hit = (connections as { toolkit?: string; status?: string }[]).find(
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
);
expect(hit).toBeDefined();
expect(hit?.status).toBe('ACTIVE');
console.log(`${LOG} PASS: connected state persists`);
});

it('composio_sync RPC routes to mock backend', async function () {
this.timeout(30_000);
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
const syncLog = getRequestLog();
const syncReq = syncLog.find(r => r.method === 'POST' && r.url.includes('/composio/sync'));
expect(syncReq).toBeDefined();
await assertSessionNotNuked();
console.log(`${LOG} PASS: sync does not nuke session`);
});

it('composio_execute routes a basic task', async function () {
this.timeout(30_000);
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_execute', {
connection_id: 'c-airtable-1',
action: 'AIRTABLE_LIST_BASES',
params: {},
});
const log = getRequestLog();
const execReq = log.find(r => r.url.includes('/composio/execute'));
expect(execReq).toBeDefined();
expect(execReq.method).toBe('POST');
console.log(`${LOG} PASS: execute routed`);
});

it('failed connection shows error state, not blank screen', async function () {
this.timeout(60_000);
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-airtable-fail');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
expect(await textExists(CONNECTOR_NAME)).toBe(true);
await assertSessionNotNuked();
console.log(`${LOG} PASS: failed state does not blank screen`);
});

it('expired auth shows Reconnect button and does not log user out', async function () {
this.timeout(60_000);
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-airtable-expired');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
const modal = await openConnectorModal(CONNECTOR_NAME);
if (modal) await assertModalPhase('expired', CONNECTOR_NAME);
await assertSessionNotNuked();
console.log(`${LOG} PASS: expired auth does not log user out`);
});

it('unrelated 401 on composio route does not nuke session', async function () {
this.timeout(60_000);
injectComposioFault(400);
await callOpenhumanRpc('openhuman.composio_execute', {
connection_id: 'c-airtable-1',
action: 'AIRTABLE_LIST_BASES',
params: {},
});
await assertSessionNotNuked();
console.log(`${LOG} PASS: 401-class error does not nuke session`);
});

it('disconnect flow removes connection', async function () {
this.timeout(60_000);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-airtable-1');
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_delete_connection', {
connection_id: 'c-airtable-1',
});
const log = getRequestLog();
const deleteReq = log.find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
Loading
Loading