test: Phase 2 — drop duplicate/vacuous tests, consolidate connectors, de-overfit (#4494)

This commit is contained in:
Steven Enamakel
2026-07-04 04:01:57 -07:00
committed by GitHub
parent dee0b5a074
commit 760e5cbbda
48 changed files with 536 additions and 2913 deletions
+4 -11
View File
@@ -350,22 +350,15 @@ fi
if should_run_suite "connectors"; then
echo ""
echo "## Running suite: connectors"
run "test/e2e/specs/connector-airtable.spec.ts" "connector-airtable" "connectors"
run "test/e2e/specs/connector-asana.spec.ts" "connector-asana" "connectors"
run "test/e2e/specs/connector-clickup.spec.ts" "connector-clickup" "connectors"
run "test/e2e/specs/connector-confluence.spec.ts" "connector-confluence" "connectors"
# Table-driven contract spec covering the 11 formerly byte-identical
# Composio connector flows (airtable/asana/clickup/confluence/gcal/gdrive/
# gsheets/notion/slack/todoist/youtube) — see connector-contract.ts.
run "test/e2e/specs/connector-composio-contract.spec.ts" "connector-composio-contract" "connectors"
run "test/e2e/specs/connector-discord-composio.spec.ts" "connector-discord" "connectors"
run "test/e2e/specs/connector-github.spec.ts" "connector-github" "connectors"
run "test/e2e/specs/connector-gmail-composio.spec.ts" "connector-gmail-composio" "connectors"
run "test/e2e/specs/connector-google-calendar.spec.ts" "connector-gcal" "connectors"
run "test/e2e/specs/connector-google-drive.spec.ts" "connector-gdrive" "connectors"
run "test/e2e/specs/connector-google-sheets.spec.ts" "connector-gsheets" "connectors"
run "test/e2e/specs/connector-jira.spec.ts" "connector-jira" "connectors"
run "test/e2e/specs/connector-notion.spec.ts" "connector-notion" "connectors"
run "test/e2e/specs/connector-session-guard.spec.ts" "connector-session-guard" "connectors"
run "test/e2e/specs/connector-slack-composio.spec.ts" "connector-slack-composio" "connectors"
run "test/e2e/specs/connector-todoist.spec.ts" "connector-todoist" "connectors"
run "test/e2e/specs/connector-youtube.spec.ts" "connector-youtube" "connectors"
_mini_summary "connectors"
fi
+7 -21
View File
@@ -600,30 +600,16 @@ fn startup_timeout_cleanup_aborts_task_and_clears_slot() {
let message = handle.cleanup_startup_timeout(false, false, 2).await;
// One loose check that the human-readable diagnostic names the failure,
// instead of pinning six exact substrings of its formatting (plan.md
// §3) — the wording of the diagnostic is not a contract.
assert!(
message.contains("core process did not become ready within"),
"timeout message should include the readiness budget: {message}"
);
assert!(
message.contains("ready_signal=false"),
"timeout message should include ready signal state: {message}"
);
assert!(
message.contains("port=19006"),
"timeout message should include RPC port: {message}"
);
assert!(
message.contains("port_open=false"),
"timeout message should include final port state: {message}"
);
assert!(
message.contains("task_state=running"),
"timeout message should include task state: {message}"
);
assert!(
message.contains("attempt=2"),
"timeout message should include startup attempt: {message}"
"timeout message should name the readiness failure: {message}"
);
// Load-bearing behaviour (skeptic-flagged, must stay): cleanup clears
// the managed task slot so a retry can spawn fresh, and cancels the
// startup shutdown token before aborting.
assert!(
handle.task.lock().await.is_none(),
"cleanup must clear the managed task slot so retry can spawn fresh"
+37 -38
View File
@@ -32,12 +32,27 @@ pub(crate) fn socket_path() -> PathBuf {
std::env::temp_dir().join(format!("com_openhuman_app_deeplink_{uid}.sock"))
}
/// Filter `openhuman://` URLs out of an argv-style iterator. Split out from
/// `extract_deep_link_urls` so the real filtering logic is unit-testable
/// without mutating the process-global `std::env::args()` — mirrors the
/// Windows sibling `collect_deep_link_urls_from_args`.
pub(crate) fn collect_deep_link_urls_from_args<I, S>(args: I) -> Vec<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
args.into_iter()
.skip(1)
.filter_map(|arg| {
let arg = arg.as_ref();
arg.starts_with("openhuman://").then(|| arg.to_string())
})
.collect()
}
/// Collect any `openhuman://` URLs from the process argv.
pub(crate) fn extract_deep_link_urls() -> Vec<String> {
std::env::args()
.skip(1)
.filter(|a| a.starts_with("openhuman://"))
.collect()
collect_deep_link_urls_from_args(std::env::args())
}
/// Result of `try_forward_deep_links`.
@@ -328,22 +343,20 @@ mod tests {
#[test]
fn extract_deep_link_urls_filters_correctly() {
// We can't mutate std::env::args(), so test the filtering logic directly.
let args = vec![
"OpenHuman".to_string(),
"openhuman://auth?token=abc".to_string(),
"--some-flag".to_string(),
"openhuman://other".to_string(),
"https://example.com".to_string(),
];
let urls: Vec<String> = args
.into_iter()
.skip(1)
.filter(|a| a.starts_with("openhuman://"))
.collect();
assert_eq!(urls.len(), 2);
assert_eq!(urls[0], "openhuman://auth?token=abc");
assert_eq!(urls[1], "openhuman://other");
// Exercise the REAL production filter through the args-slice seam
// (mirrors the Windows sibling test) instead of re-implementing the
// predicate inline — so a regression in the filter actually fails here.
let urls = collect_deep_link_urls_from_args([
"OpenHuman",
"openhuman://auth?token=abc",
"--some-flag",
"openhuman://other",
"https://example.com",
]);
assert_eq!(
urls,
vec!["openhuman://auth?token=abc", "openhuman://other"]
);
}
#[test]
@@ -384,22 +397,8 @@ mod tests {
assert_eq!(got[0], "openhuman://auth?token=testtoken123");
}
#[test]
fn no_primary_returns_appropriate_result() {
// Remove socket file to guarantee no primary.
std::env::remove_var("XDG_RUNTIME_DIR");
let _ = std::fs::remove_file(socket_path());
// The "extract_deep_link_urls" function reads actual argv which has
// no openhuman:// URLs during tests, so try_forward_deep_links()
// returns NoUrls. We test the NoPrimary branch directly by
// testing that connect to a missing socket fails.
let non_existent = PathBuf::from("/tmp/openhuman_test_nonexistent_socket.sock");
let _ = std::fs::remove_file(&non_existent);
let result = UnixStream::connect(&non_existent);
assert!(
result.is_err(),
"Expected connection failure for missing socket"
);
}
// NOTE: `no_primary_returns_appropriate_result` removed (plan.md §2.1) —
// its own comment admitted it couldn't reach the production NoPrimary
// branch and instead asserted that stdlib `UnixStream::connect` errors on
// a bogus path, which verifies nothing about our code.
}
+7 -49
View File
@@ -6,13 +6,8 @@ use super::*;
// spurious failures.
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Test that is_daemon_mode correctly detects daemon flag variations
#[test]
fn is_daemon_mode_detects_daemon_flag() {
// Note: This test relies on the current process args, so in test mode
// it will typically return false. We verify the function is callable.
let _result = is_daemon_mode();
}
// NOTE: `is_daemon_mode_detects_daemon_flag` removed (plan.md §2.1) — it
// discarded the result with `let _` and asserted nothing.
/// Test core_rpc_url returns expected format
#[test]
@@ -58,34 +53,9 @@ fn overlay_parent_rpc_url_handles_empty() {
}
}
/// Tests for setup_tray conditional compilation
/// The PR adds two versions of setup_tray():
/// 1. No-op for linux + cef: logs warning and returns Ok(())
/// 2. Full implementation for other platforms
///
/// These tests verify the function signatures are correct and
/// the compile-time cfg blocks are properly set up.
/// Verify setup_tray function exists and has correct signature
/// This test passes if the code compiles, as the function signature
/// is validated by the compiler.
#[test]
fn setup_tray_function_signature_compiles() {
// This test exists to ensure the conditional compilation
// of setup_tray is valid. The function is not actually called
// here because it requires a full Tauri AppHandle.
// The cfg attributes ensure only one version exists at compile time.
}
/// Test that AppRuntime is defined for the current feature set
#[test]
fn app_runtime_type_exists() {
// This test verifies AppRuntime is properly defined
// based on the cef feature flag.
// The type alias exists at module scope and is used throughout.
fn _check_runtime<R: tauri::Runtime>() {}
// _check_runtime::<AppRuntime>(); // Would require importing
}
// NOTE: `setup_tray_function_signature_compiles` and `app_runtime_type_exists`
// removed (plan.md §2.1) — one had an empty body, the other's only real
// assertion was commented out; both were compile-only no-ops.
#[test]
fn no_app_update_available_result_is_quiet_unavailable() {
@@ -97,20 +67,8 @@ fn no_app_update_available_result_is_quiet_unavailable() {
assert!(info.body.is_none());
}
/// Verify tray logging patterns exist (grep-friendly)
#[test]
fn tray_setup_logging_patterns_exist() {
// These log patterns from the PR are grep-friendly:
// "[tray] skipping tray setup on linux+cef: ..."
// "[tray] setting up tray icon"
// "[tray] tray icon ready"
// "[tray] action=show_window ..."
// "[tray] action=quit ..."
// "[tray] failed to setup tray icon ..."
// "[app] RunEvent::Ready — GTK initialized, setting up tray"
//
// This test passes if the code compiles with these log messages.
}
// NOTE: `tray_setup_logging_patterns_exist` removed (plan.md §2.1) — a
// comments-only body that asserted nothing.
// -------------------------------------------------------------------------
// macos_os_version (issue #1012)
@@ -1,281 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ArtifactSnapshot } from '../../store/chatRuntimeSlice';
import ArtifactCard from './ArtifactCard';
// Mock the artifact download service — the card only consumes the
// two public functions, so a per-test override is enough.
const saveArtifactViaDialogMock = vi.fn();
const revealArtifactInFileManagerMock = vi.fn();
vi.mock('../../services/artifactDownloadService', () => ({
saveArtifactViaDialog: (...args: unknown[]) => saveArtifactViaDialogMock(...args),
revealArtifactInFileManager: (...args: unknown[]) => revealArtifactInFileManagerMock(...args),
}));
function snapshot(overrides: Partial<ArtifactSnapshot> = {}): ArtifactSnapshot {
return {
artifactId: 'a-1',
kind: 'presentation',
title: 'Quarterly Deck',
status: 'ready',
sizeBytes: 4096,
path: 'a-1/deck.pptx',
updatedAt: 0,
...overrides,
};
}
beforeEach(() => {
saveArtifactViaDialogMock.mockReset();
revealArtifactInFileManagerMock.mockReset();
});
describe('<ArtifactCard /> — in_progress state', () => {
it('renders the title with the generating sub-label and no buttons', () => {
render(
<ArtifactCard
artifact={snapshot({ status: 'in_progress', sizeBytes: undefined, path: undefined })}
/>
);
expect(screen.getByText('Quarterly Deck')).toBeInTheDocument();
expect(screen.getByText(/Generating presentation/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /download/i })).not.toBeInTheDocument();
});
it('reflects the artifact kind in the generating label', () => {
render(
<ArtifactCard
artifact={snapshot({
status: 'in_progress',
kind: 'image',
sizeBytes: undefined,
path: undefined,
})}
/>
);
expect(screen.getByText(/Generating image/i)).toBeInTheDocument();
});
});
describe('<ArtifactCard /> — ready state', () => {
it('renders the Download button with the formatted size', () => {
render(<ArtifactCard artifact={snapshot()} />);
expect(screen.getByRole('button', { name: 'Download' })).toBeEnabled();
// formatFileSize on 4096 should land in the "4 KB"-ish range; we
// just assert the size cell exists rather than asserting an exact
// formatter output to avoid coupling to its implementation detail.
const subLabel = screen.getByText(/Ready ·/);
expect(subLabel.textContent ?? '').toMatch(/[KMG]?B/);
});
it('omits the size suffix when sizeBytes is null', () => {
render(<ArtifactCard artifact={snapshot({ sizeBytes: undefined })} />);
// Status text should be empty when sizeBytes is missing on a ready
// artifact — the conditional collapses to ''.
const dl = screen.getByRole('button', { name: 'Download' });
expect(dl).toBeInTheDocument();
});
it('drives the download → done flow and reveals on click', async () => {
saveArtifactViaDialogMock.mockResolvedValueOnce({
ok: true,
path: '/Users/me/Downloads/Quarterly Deck.pptx',
});
revealArtifactInFileManagerMock.mockResolvedValueOnce(true);
render(<ArtifactCard artifact={snapshot()} />);
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
// Service called with (id, title, ext) where ext comes from the
// title's extension when present, or kind fallback otherwise.
await waitFor(() => expect(saveArtifactViaDialogMock).toHaveBeenCalledTimes(1));
expect(saveArtifactViaDialogMock).toHaveBeenCalledWith('a-1', 'Quarterly Deck', 'pptx');
// Saved-to row appears with the reveal button.
await screen.findByText(
(_, el) => el?.textContent === 'Saved to /Users/me/Downloads/Quarterly Deck.pptx'
);
fireEvent.click(screen.getByRole('button', { name: 'Show in folder' }));
await waitFor(() => expect(revealArtifactInFileManagerMock).toHaveBeenCalledTimes(1));
expect(revealArtifactInFileManagerMock).toHaveBeenCalledWith(
'/Users/me/Downloads/Quarterly Deck.pptx'
);
// Download button is gone now that state === 'done'.
expect(screen.queryByRole('button', { name: 'Download' })).not.toBeInTheDocument();
});
it('falls back to the kind-based extension when title has no dot', async () => {
saveArtifactViaDialogMock.mockResolvedValueOnce({ ok: true, path: '/tmp/Doc.pdf' });
render(
<ArtifactCard artifact={snapshot({ artifactId: 'a-2', kind: 'document', title: 'Doc' })} />
);
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
await waitFor(() => expect(saveArtifactViaDialogMock).toHaveBeenCalledTimes(1));
expect(saveArtifactViaDialogMock).toHaveBeenCalledWith('a-2', 'Doc', 'pdf');
});
it('falls back to png for image kind without an extension', async () => {
saveArtifactViaDialogMock.mockResolvedValueOnce({ ok: true, path: '/tmp/Pic.png' });
render(
<ArtifactCard artifact={snapshot({ artifactId: 'a-3', kind: 'image', title: 'Pic' })} />
);
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
await waitFor(() => expect(saveArtifactViaDialogMock).toHaveBeenCalledTimes(1));
expect(saveArtifactViaDialogMock).toHaveBeenCalledWith('a-3', 'Pic', 'png');
});
it('falls back to bin for the "other" kind without an extension', async () => {
saveArtifactViaDialogMock.mockResolvedValueOnce({ ok: true, path: '/tmp/Blob.bin' });
render(
<ArtifactCard artifact={snapshot({ artifactId: 'a-4', kind: 'other', title: 'Blob' })} />
);
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
await waitFor(() => expect(saveArtifactViaDialogMock).toHaveBeenCalledTimes(1));
expect(saveArtifactViaDialogMock).toHaveBeenCalledWith('a-4', 'Blob', 'bin');
});
it('uses the existing extension on the title when present', async () => {
saveArtifactViaDialogMock.mockResolvedValueOnce({ ok: true, path: '/tmp/notes.txt' });
render(
<ArtifactCard
artifact={snapshot({ artifactId: 'a-5', kind: 'document', title: 'notes.txt' })}
/>
);
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
await waitFor(() => expect(saveArtifactViaDialogMock).toHaveBeenCalledTimes(1));
expect(saveArtifactViaDialogMock).toHaveBeenCalledWith('a-5', 'notes.txt', 'txt');
});
it('surfaces the service error message when download fails', async () => {
saveArtifactViaDialogMock.mockResolvedValueOnce({ ok: false, error: 'disk full' });
render(<ArtifactCard artifact={snapshot()} />);
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
await screen.findByText((_, el) => el?.textContent === 'Download failed: disk full');
// Download button stays so the user can retry.
expect(screen.getByRole('button', { name: 'Download' })).toBeEnabled();
});
it('does not call reveal when download.path is missing', async () => {
// Synthetic edge: the success path always carries a path, but if the
// user clicks reveal before a download ever happened the handler is
// guarded.
render(<ArtifactCard artifact={snapshot()} />);
// No reveal button is rendered until state === 'done'.
expect(screen.queryByRole('button', { name: 'Show in folder' })).not.toBeInTheDocument();
expect(revealArtifactInFileManagerMock).not.toHaveBeenCalled();
});
});
describe('<ArtifactCard /> — failed state', () => {
it('shows the producer error verbatim under the preview cap', () => {
render(
<ArtifactCard
artifact={snapshot({
status: 'failed',
path: undefined,
sizeBytes: undefined,
error: 'producer crashed',
})}
/>
);
expect(screen.getByText('producer crashed')).toBeInTheDocument();
expect(screen.getByText(/Generation failed/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument();
});
it('truncates long errors with a Show more toggle', () => {
const huge = 'x'.repeat(400);
render(
<ArtifactCard
artifact={snapshot({
status: 'failed',
path: undefined,
sizeBytes: undefined,
error: huge,
})}
/>
);
// Truncated body ends with the ellipsis suffix.
const truncated = screen.getByText(/x…$/);
expect(truncated).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show more' }));
// After expand, the full string is rendered without the ellipsis.
expect(screen.getByText(huge)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Show less' }));
// Re-collapses back to truncated form.
expect(screen.getByText(/x…$/)).toBeInTheDocument();
});
it('renders the Retry button when onRetry is provided', () => {
const onRetry = vi.fn();
render(
<ArtifactCard
artifact={snapshot({
status: 'failed',
path: undefined,
sizeBytes: undefined,
error: 'nope',
})}
onRetry={onRetry}
/>
);
const retry = screen.getByRole('button', { name: 'Retry' });
fireEvent.click(retry);
expect(onRetry).toHaveBeenCalledWith('a-1');
});
it('omits the Retry button when onRetry is not provided', () => {
render(
<ArtifactCard
artifact={snapshot({
status: 'failed',
path: undefined,
sizeBytes: undefined,
error: 'nope',
})}
/>
);
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
});
it('omits the error block entirely when no error string is set', () => {
render(
<ArtifactCard
artifact={snapshot({
status: 'failed',
path: undefined,
sizeBytes: undefined,
error: undefined,
})}
/>
);
expect(screen.getByText(/Generation failed/i)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument();
});
});
describe('<ArtifactCard /> — aria label', () => {
it('exposes an aria-label with the artifact title', () => {
render(<ArtifactCard artifact={snapshot({ title: 'Q3 Plan' })} />);
expect(screen.getByRole('group', { name: /Q3 Plan/ })).toBeInTheDocument();
});
});
@@ -50,12 +50,21 @@ describe('ApprovalRequestCard', () => {
const card = screen.getByRole('alertdialog', { name: 'Approval needed' });
const command = screen.getByText('pip show yfinance');
// The real, behavioural invariant: neither the card nor the command chip
// may use a fractional-opacity background utility (e.g.
// `dark:bg-amber-950/40`), which would let the underlying thread text bleed
// through the approval surface. Assert the *absence of any opacity suffix*
// rather than a computed colour — jsdom does not apply Tailwind, so
// getComputedStyle can't observe the background here (plan.md §3).
const OPACITY_SUFFIX = /\bdark:bg-[^\s/]+\/\d+/;
expect(card.className).not.toMatch(OPACITY_SUFFIX);
expect(command.className).not.toMatch(OPACITY_SUFFIX);
// Deliberate, labeled visual-regression lock on the opaque surface tokens —
// update these only on an intentional restyle of the approval card.
expect(card).toHaveClass('bg-amber-50');
expect(card).toHaveClass('dark:bg-amber-950');
expect(card).not.toHaveClass('bg-amber/5');
expect(card.className).not.toMatch(/\bdark:bg-[^\s/]+\/\d+/);
expect(command).toHaveClass('dark:bg-surface-canvas');
expect(command.className).not.toMatch(/\bdark:bg-[^\s/]+\/\d+/);
});
it('does not nudge the user to reply yes/no (buttons are the input path)', () => {
@@ -232,10 +232,12 @@ describe('ArtifactCard', () => {
// ─── kind variants (icon paths) ─────────────────────────────────────────
it.each(['presentation', 'document', 'image', 'other'] as const)(
'renders the in-progress spinner for kind=%s without crashing',
'renders the in-progress spinner with the kind-specific label for kind=%s',
kind => {
render(<ArtifactCard artifact={inProgress({ kind })} />);
expect(screen.getByText(/Generating /)).toBeInTheDocument();
// The generating sub-label reflects the artifact kind (e.g. "Generating
// image") — folded in from the former sibling ArtifactCard.test.tsx.
expect(screen.getByText(new RegExp(`Generating ${kind}`, 'i'))).toBeInTheDocument();
}
);
@@ -1,35 +0,0 @@
import { screen } from '@testing-library/react';
import { describe, expect, test } from 'vitest';
import { renderWithProviders } from '../../../../test/test-utils';
import { useSettingsNavigation } from '../useSettingsNavigation';
/** Renders breadcrumb labels so we can assert on the hook output. */
const BreadcrumbProbe = () => {
const { breadcrumbs } = useSettingsNavigation();
return <div data-testid="breadcrumbs">{breadcrumbs.map(b => b.label).join(' > ')}</div>;
};
/**
* The two-pane settings restructure retired breadcrumb navigation — the sidebar
* replaced the trail, so `breadcrumbs` is now always empty regardless of route.
* The field is retained (always []) so the many panel call sites keep compiling.
* Route resolution itself is covered in useSettingsNavigation.coverage.test.tsx.
*/
describe('useSettingsNavigation breadcrumbs (retired — always empty)', () => {
const routes = [
'/settings',
'/settings/notifications',
'/settings/tasks',
'/settings/developer-options',
'/settings/personality',
'/settings/recovery-phrase',
'/settings/wallet-balances',
'/settings/notification-routing',
];
test.each(routes)('breadcrumbs are empty for %s', route => {
renderWithProviders(<BreadcrumbProbe />, { initialEntries: [route] });
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('');
});
});
@@ -1,81 +0,0 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import RecoveryPhrasePanel from './RecoveryPhrasePanel';
const navigateBackMock = vi.fn();
vi.mock('../hooks/useSettingsNavigation', () => ({
useSettingsNavigation: () => ({ navigateBack: navigateBackMock, breadcrumbs: [] }),
}));
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
vi.mock('../../../providers/CoreStateProvider', () => ({
useCoreState: () => ({
snapshot: { currentUser: { id: 'test-user', publicKey: 'test-pubkey' } },
setEncryptionKey: vi.fn(),
}),
}));
vi.mock('../components/SettingsHeader', () => ({
default: ({ title, description }: { title: string; description?: string }) => (
<div data-testid="settings-header">
{title} - {description}
</div>
),
}));
vi.mock('../../../features/wallet/setupLocalWalletFromMnemonic', () => ({
persistLocalWalletFromMnemonic: vi.fn(),
}));
// Mock fetchWalletStatus to return unconfigured by default (no existing wallet).
vi.mock('../../../services/walletApi', () => ({
fetchWalletStatus: vi.fn(async () => ({
configured: false,
onboardingCompleted: false,
consentGranted: false,
secretStored: false,
source: null,
mnemonicWordCount: null,
accounts: [],
updatedAtMs: null,
})),
setupLocalWallet: vi.fn(async () => ({
configured: true,
onboardingCompleted: true,
consentGranted: true,
secretStored: true,
source: 'generated',
mnemonicWordCount: 12,
accounts: [],
updatedAtMs: Date.now(),
})),
}));
describe('<RecoveryPhrasePanel />', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('initially hides the recovery phrase and reveals it when clicking the reveal button', async () => {
render(<RecoveryPhrasePanel />);
// Wait for wallet status check to complete and enter generate mode
await waitFor(() =>
expect(screen.queryByLabelText('mnemonic.revealPhrase')).toBeInTheDocument()
);
const copyButton = screen.getByText('mnemonic.copyToClipboard').closest('button')!;
expect(copyButton).toBeDisabled();
const revealButton = screen.getByLabelText('mnemonic.revealPhrase');
expect(revealButton).toBeInTheDocument();
fireEvent.click(revealButton);
expect(screen.queryByLabelText('mnemonic.revealPhrase')).not.toBeInTheDocument();
expect(copyButton).not.toBeDisabled();
});
});
@@ -37,6 +37,7 @@ const WalkthroughTooltip = ({
{/* Progress bar — thin, smooth fill */}
<div className="h-1 bg-surface-subtle">
<div
data-testid="walkthrough-progress-bar"
className="h-full bg-gradient-to-r from-[#2F6EF4] to-[#5B9BF3] transition-all duration-500 ease-out rounded-r-full"
style={{ width: `${progress}%` }}
/>
@@ -504,26 +504,23 @@ describe('WalkthroughTooltip', () => {
});
it('renders progress bar', () => {
const { container } = render(
<WalkthroughTooltip {...makeTooltipProps({ index: 2, size: 9 })} />
);
render(<WalkthroughTooltip {...makeTooltipProps({ index: 2, size: 9 })} />);
// Gradient progress bar fills based on step progress (3/9 ≈ 33.33%)
const bar = container.querySelector('div.bg-gradient-to-r');
expect(bar).not.toBeNull();
// width rounds to ~33.33% for step 3 of 9
expect(bar?.getAttribute('style')).toMatch(/width:\s*33\.3/);
// Query the progress bar by its stable test id rather than a presentational
// Tailwind class (plan.md §3). The real signal is the computed fill width:
// step 3 of 9 ≈ 33.33%.
const bar = screen.getByTestId('walkthrough-progress-bar');
expect(bar.getAttribute('style')).toMatch(/width:\s*33\.3/);
});
});
// ── createWalkthroughSteps tests ──────────────────────────────────────────
describe('createWalkthroughSteps', () => {
it('returns 13 steps', () => {
const navigate = vi.fn();
const steps = createWalkthroughSteps(navigate);
expect(steps).toHaveLength(13);
});
// NOTE: the brittle `returns 13 steps` magic-count test was removed
// (plan.md §3) — it broke on any intentional add/remove of a walkthrough
// step. The first-/last-step target tests below carry the real ordering
// signal, and `all steps have a title and content` guards each entry.
it('first step targets home-card', () => {
const navigate = vi.fn();
@@ -163,25 +163,26 @@ describe('useDaemonLifecycle', () => {
const uid = freshUser('stable-effect');
resetUser(uid);
setAutoStartEnabled(uid, true);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
// Observe the visibilitychange listener registration directly — it is
// the real proxy for "the lifecycle effect ran exactly once and cleaned
// up exactly once". (Previously this also pinned exact console.log
// strings as an effect-rerun proxy; those are brittle to copy edits and
// the listener counts already carry the signal — plan.md §3.)
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
try {
const { unmount } = renderHook(() => useDaemonLifecycle(uid));
const lifecycleLogCount = (message: string) =>
logSpy.mock.calls.filter(([logged]) => logged === message).length;
const visibilityAddCount = () =>
addEventListenerSpy.mock.calls.filter(([event]) => event === 'visibilitychange').length;
const visibilityRemoveCount = () =>
removeEventListenerSpy.mock.calls.filter(([event]) => event === 'visibilitychange')
.length;
expect(lifecycleLogCount('[DaemonLifecycle] Setting up daemon lifecycle management')).toBe(
1
);
// Effect ran once: one listener added, none removed yet.
expect(visibilityAddCount()).toBe(1);
expect(visibilityRemoveCount()).toBe(0);
act(() => {
setDaemonStatus(uid, 'starting');
@@ -191,23 +192,16 @@ describe('useDaemonLifecycle', () => {
setIsRecovering(uid, false);
});
expect(lifecycleLogCount('[DaemonLifecycle] Setting up daemon lifecycle management')).toBe(
1
);
expect(lifecycleLogCount('[DaemonLifecycle] Cleaning up daemon lifecycle management')).toBe(
0
);
// Daemon-state churn must NOT re-run the effect: still one add, no
// teardown.
expect(visibilityAddCount()).toBe(1);
expect(visibilityRemoveCount()).toBe(0);
unmount();
expect(lifecycleLogCount('[DaemonLifecycle] Cleaning up daemon lifecycle management')).toBe(
1
);
// Unmount tears the single listener down exactly once.
expect(visibilityRemoveCount()).toBe(1);
} finally {
logSpy.mockRestore();
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
}
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import { connectionPathApi, loadGraph, loadNamespaces } from './connectionPathApi';
import { loadGraph, loadNamespaces } from './connectionPathApi';
const mockGraphQuery = vi.fn();
const mockListNamespaces = vi.fn();
@@ -63,10 +63,3 @@ describe('connectionPathApi.loadNamespaces', () => {
expect(await loadNamespaces()).toEqual(['work', 'personal']);
});
});
describe('connectionPathApi object', () => {
it('exposes the public surface', () => {
expect(typeof connectionPathApi.loadGraph).toBe('function');
expect(typeof connectionPathApi.loadNamespaces).toBe('function');
});
});
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { computeEntityAssociations } from '../../lib/memory/entityAssociations';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import { entityAssociationsApi, loadAssociations, loadNamespaces } from './entityAssociationsApi';
import { loadAssociations, loadNamespaces } from './entityAssociationsApi';
const mockGraphQuery = vi.fn();
const mockListNamespaces = vi.fn();
@@ -63,10 +63,3 @@ describe('entityAssociationsApi.loadNamespaces', () => {
expect(await loadNamespaces()).toEqual(['work', 'personal']);
});
});
describe('entityAssociationsApi object', () => {
it('exposes the public surface', () => {
expect(typeof entityAssociationsApi.loadAssociations).toBe('function');
expect(typeof entityAssociationsApi.loadNamespaces).toBe('function');
});
});
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { computeGraphCentrality } from '../../lib/memory/graphCentrality';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import { graphCentralityApi, loadCentrality, loadNamespaces } from './graphCentralityApi';
import { loadCentrality, loadNamespaces } from './graphCentralityApi';
const mockGraphQuery = vi.fn();
const mockListNamespaces = vi.fn();
@@ -64,10 +64,3 @@ describe('graphCentralityApi.loadNamespaces', () => {
expect(await loadNamespaces()).toEqual(['work', 'personal']);
});
});
describe('graphCentralityApi object', () => {
it('exposes the public surface', () => {
expect(typeof graphCentralityApi.loadCentrality).toBe('function');
expect(typeof graphCentralityApi.loadNamespaces).toBe('function');
});
});
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { computeGraphCohesion } from '../../lib/memory/graphCohesion';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import { graphCohesionApi, loadCohesion, loadNamespaces } from './graphCohesionApi';
import { loadCohesion, loadNamespaces } from './graphCohesionApi';
const mockGraphQuery = vi.fn();
const mockListNamespaces = vi.fn();
@@ -65,10 +65,3 @@ describe('graphCohesionApi.loadNamespaces', () => {
expect(await loadNamespaces()).toEqual(['work', 'personal']);
});
});
describe('graphCohesionApi object', () => {
it('exposes the public surface', () => {
expect(typeof graphCohesionApi.loadCohesion).toBe('function');
expect(typeof graphCohesionApi.loadNamespaces).toBe('function');
});
});
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { computeFreshness } from '../../lib/memory/memoryFreshness';
import { NOW, rel } from '../../test/memoryRelationFactory';
import { loadFreshness, loadNamespaces, memoryFreshnessApi } from './memoryFreshnessApi';
import { loadFreshness, loadNamespaces } from './memoryFreshnessApi';
const mockGraphQuery = vi.fn();
const mockListNamespaces = vi.fn();
@@ -48,10 +48,3 @@ describe('memoryFreshnessApi.loadNamespaces', () => {
expect(await loadNamespaces()).toEqual(['work', 'personal']);
});
});
describe('memoryFreshnessApi object', () => {
it('exposes the public surface', () => {
expect(typeof memoryFreshnessApi.loadFreshness).toBe('function');
expect(typeof memoryFreshnessApi.loadNamespaces).toBe('function');
});
});
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { computeTimeline } from '../../lib/memory/memoryTimeline';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import { loadNamespaces, loadTimeline, memoryTimelineApi } from './memoryTimelineApi';
import { loadNamespaces, loadTimeline } from './memoryTimelineApi';
const mockGraphQuery = vi.fn();
const mockListNamespaces = vi.fn();
@@ -65,10 +65,3 @@ describe('memoryTimelineApi.loadNamespaces', () => {
expect(await loadNamespaces()).toEqual(['work', 'personal']);
});
});
describe('memoryTimelineApi object', () => {
it('exposes the public surface', () => {
expect(typeof memoryTimelineApi.loadTimeline).toBe('function');
expect(typeof memoryTimelineApi.loadNamespaces).toBe('function');
});
});
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { computeNamespaceOverview } from '../../lib/memory/namespaceOverview';
import type { GraphRelation } from '../../utils/tauriCommands/memory';
import { loadNamespaceOverview, namespaceOverviewApi } from './namespaceOverviewApi';
import { loadNamespaceOverview } from './namespaceOverviewApi';
const mockGraphQuery = vi.fn();
@@ -49,9 +49,3 @@ describe('namespaceOverviewApi.loadNamespaceOverview', () => {
await expect(loadNamespaceOverview()).rejects.toThrow('graph unavailable');
});
});
describe('namespaceOverviewApi object', () => {
it('exposes the public surface', () => {
expect(typeof namespaceOverviewApi.loadNamespaceOverview).toBe('function');
});
});
+185
View File
@@ -0,0 +1,185 @@
/**
* Table-driven Composio connector contract (plan.md §2.2).
*
* The per-toolkit connector specs (Airtable, Asana, ClickUp, …) were 11
* byte-identical WDIO files differing only in a handful of toolkit strings.
* `runConnectorContract` factors the whole `describe` block out so each toolkit
* is one `ConnectorContractConfig` row in the single contract spec. Toolkits
* with bespoke UI/behaviour (Jira's subdomain field, Gmail's 400-on-fetch)
* keep their own dedicated specs.
*/
import {
clearRequestLog,
getRequestLog,
resetMockBehavior,
startMockServer,
stopMockServer,
} from '../mock-server';
import { waitForApp } from './app-helpers';
import {
assertConnectorCardVisible,
assertModalPhase,
assertSessionNotNuked,
injectComposioFault,
openConnectorModal,
seedComposioConnection,
seedComposioToolkits,
} from './composio-helpers';
import { callOpenhumanRpc } from './core-rpc';
import { triggerAuthDeepLinkBypass } from './deep-link-helpers';
import { textExists, waitForText, waitForWebView, waitForWindowVisible } from './element-helpers';
import { completeOnboardingIfVisible, navigateToSkills } from './shared-flows';
export interface ConnectorContractConfig {
/** Human-facing connector name as rendered in the UI (e.g. "Google Calendar"). */
name: string;
/** Composio toolkit slug (e.g. "googlecalendar"). */
slug: string;
/** Connection-id stem; the contract derives `${idBase}-1|-fail|-expired`. */
idBase: string;
/** A representative Composio action for the execute-routing case. */
executeAction: string;
/** Optional auth-deep-link token override (defaults to `e2e-connector-<slug>-token`). */
authToken?: string;
}
/**
* Register the shared Composio connector contract for one toolkit. Call once
* per toolkit from the contract spec; each invocation is a self-contained
* `describe` with its own mock lifecycle (mirrors the former per-file specs).
*/
export function runConnectorContract(config: ConnectorContractConfig): void {
const { name, slug, idBase, executeAction } = config;
const authToken = config.authToken ?? `e2e-connector-${slug}-token`;
const activeId = `${idBase}-1`;
const failId = `${idBase}-fail`;
const expiredId = `${idBase}-expired`;
const LOG = `[Connector:${name}]`;
describe(`${name} Composio connector flow`, () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([slug]);
seedComposioConnection(slug, 'ACTIVE', activeId);
await waitForApp();
clearRequestLog();
await triggerAuthDeepLinkBypass(authToken);
await waitForWindowVisible(25_000);
await waitForWebView(15_000);
await completeOnboardingIfVisible(LOG);
});
after(async () => {
await stopMockServer();
});
afterEach(async () => {
resetMockBehavior();
seedComposioToolkits([slug]);
seedComposioConnection(slug, 'ACTIVE', activeId);
});
it('card is visible and selectable', async function () {
this.timeout(60_000);
await assertConnectorCardVisible(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: slug });
expect(out.ok).toBe(true);
const authReq = getRequestLog().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() === slug
);
expect(hit).toBeDefined();
expect(hit?.status).toBe('ACTIVE');
console.log(`${LOG} PASS: connected state persists`);
});
// Renamed from the misleading "composio_sync RPC routes to mock backend"
// (plan.md §3): composio_sync short-circuits with no HTTP for connectors
// without a native provider, so the real, verifiable contract is that the
// call does not tear down the WebDriver session.
it('composio_sync does not tear down the session', async function () {
this.timeout(30_000);
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: slug });
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: activeId,
action: executeAction,
params: {},
});
console.log(`${LOG} PASS: execute routed`);
});
it('failed connection shows error state, not blank screen', async function () {
this.timeout(60_000);
seedComposioConnection(slug, 'FAILED', failId);
await navigateToSkills();
await waitForText(name, 10_000);
expect(await textExists(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(slug, 'EXPIRED', expiredId);
await navigateToSkills();
await waitForText(name, 10_000);
const modal = await openConnectorModal(name, 15_000, 'Auth expired');
expect(modal).toBeTruthy();
await assertModalPhase('expired', name);
await assertSessionNotNuked();
console.log(`${LOG} PASS: expired auth does not log user out`);
});
it('unrelated 400 on composio route does not nuke session', async function () {
this.timeout(60_000);
injectComposioFault(400);
await callOpenhumanRpc('openhuman.composio_execute', {
connection_id: activeId,
action: executeAction,
params: {},
});
await assertSessionNotNuked();
console.log(`${LOG} PASS: unrelated 400 error does not nuke session`);
});
it('disconnect flow removes connection', async function () {
this.timeout(60_000);
seedComposioConnection(slug, 'ACTIVE', activeId);
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_delete_connection', { connection_id: activeId });
const deleteReq = getRequestLog().find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
}
@@ -1,164 +0,0 @@
/**
* 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 authReq = getRequestLog().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 });
// syncReq URL check removed — composio_sync does no HTTP for
// connectors without a native provider (the RPC short-circuits). The
// assertSessionNotNuked() below covers the real intent: the call
// does not tear down the WebDriver session.
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: {},
});
// execReq URL check removed (see composio_sync comment above).
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, 15_000, 'Auth expired');
expect(modal).toBeTruthy();
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 deleteReq = getRequestLog().find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
-162
View File
@@ -1,162 +0,0 @@
/**
* E2E: Asana (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 = '[ConnectorAsanaE2E]';
const CONNECTOR_NAME = 'Asana';
const TOOLKIT_SLUG = 'asana';
const AUTH_TOKEN = 'e2e-connector-asana-token';
describe('Asana Composio connector flow', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-asana-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-asana-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 authReq = getRequestLog().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 });
// syncReq URL check removed — composio_sync does no HTTP for
// connectors without a native provider (the RPC short-circuits). The
// assertSessionNotNuked() below covers the real intent: the call
// does not tear down the WebDriver session.
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-asana-1',
action: 'ASANA_LIST_TASKS',
params: {},
});
// execReq URL check removed (see composio_sync comment above).
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-asana-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-asana-expired');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
expect(modal).toBeTruthy();
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-asana-1',
action: 'ASANA_LIST_TASKS',
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-asana-1');
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_delete_connection', { connection_id: 'c-asana-1' });
const deleteReq = getRequestLog().find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
@@ -1,164 +0,0 @@
/**
* E2E: ClickUp (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 = '[ConnectorClickUpE2E]';
const CONNECTOR_NAME = 'ClickUp';
const TOOLKIT_SLUG = 'clickup';
const AUTH_TOKEN = 'e2e-connector-clickup-token';
describe('ClickUp Composio connector flow', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-clickup-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-clickup-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 authReq = getRequestLog().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 });
// syncReq URL check removed — composio_sync does no HTTP for
// connectors without a native provider (the RPC short-circuits). The
// assertSessionNotNuked() below covers the real intent: the call
// does not tear down the WebDriver session.
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-clickup-1',
action: 'CLICKUP_LIST_TASKS',
params: {},
});
// execReq URL check removed (see composio_sync comment above).
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-clickup-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-clickup-expired');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
expect(modal).toBeTruthy();
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-clickup-1',
action: 'CLICKUP_LIST_TASKS',
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-clickup-1');
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_delete_connection', {
connection_id: 'c-clickup-1',
});
const deleteReq = getRequestLog().find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
@@ -0,0 +1,58 @@
/**
* E2E: Composio connector contract — one table-driven spec covering the
* toolkits that share the identical connect/sync/execute/disconnect flow
* (plan.md §2.2). Collapses 11 byte-identical connector-*.spec.ts files.
*
* Bespoke connectors keep their own specs:
* - connector-jira.spec.ts (subdomain-field UI)
* - connector-gmail-composio.spec.ts (400-on-fetch-emails handling)
*/
import { type ConnectorContractConfig, runConnectorContract } from '../helpers/connector-contract';
const TOOLKITS: ConnectorContractConfig[] = [
{
name: 'Airtable',
slug: 'airtable',
idBase: 'c-airtable',
executeAction: 'AIRTABLE_LIST_BASES',
},
{ name: 'Asana', slug: 'asana', idBase: 'c-asana', executeAction: 'ASANA_LIST_TASKS' },
{ name: 'ClickUp', slug: 'clickup', idBase: 'c-clickup', executeAction: 'CLICKUP_LIST_TASKS' },
{
name: 'Confluence',
slug: 'confluence',
idBase: 'c-confluence',
executeAction: 'CONFLUENCE_LIST_PAGES',
},
{
name: 'Google Calendar',
slug: 'googlecalendar',
idBase: 'c-gcal',
executeAction: 'GOOGLECALENDAR_LIST_EVENTS',
},
{
name: 'Google Drive',
slug: 'googledrive',
idBase: 'c-gdrive',
executeAction: 'GOOGLEDRIVE_LIST_FILES',
},
{
name: 'Google Sheets',
slug: 'googlesheets',
idBase: 'c-gsheets',
executeAction: 'GOOGLESHEETS_LIST_SPREADSHEETS',
},
{ name: 'Notion', slug: 'notion', idBase: 'c-notion', executeAction: 'NOTION_LIST_PAGES' },
{ name: 'Slack', slug: 'slack', idBase: 'c-slack', executeAction: 'SLACK_LIST_CHANNELS' },
{ name: 'Todoist', slug: 'todoist', idBase: 'c-todoist', executeAction: 'TODOIST_LIST_PROJECTS' },
{
name: 'YouTube',
slug: 'youtube',
idBase: 'c-youtube',
executeAction: 'YOUTUBE_LIST_PLAYLISTS',
},
];
for (const toolkit of TOOLKITS) {
runConnectorContract(toolkit);
}
@@ -1,164 +0,0 @@
/**
* E2E: Confluence (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 = '[ConnectorConfluenceE2E]';
const CONNECTOR_NAME = 'Confluence';
const TOOLKIT_SLUG = 'confluence';
const AUTH_TOKEN = 'e2e-connector-confluence-token';
describe('Confluence Composio connector flow', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-confluence-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-confluence-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 authReq = getRequestLog().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 });
// syncReq URL check removed — composio_sync does no HTTP for
// connectors without a native provider (the RPC short-circuits). The
// assertSessionNotNuked() below covers the real intent: the call
// does not tear down the WebDriver session.
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-confluence-1',
action: 'CONFLUENCE_LIST_PAGES',
params: {},
});
// execReq URL check removed (see composio_sync comment above).
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-confluence-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-confluence-expired');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
expect(modal).toBeTruthy();
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-confluence-1',
action: 'CONFLUENCE_LIST_PAGES',
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-confluence-1');
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_delete_connection', {
connection_id: 'c-confluence-1',
});
const deleteReq = getRequestLog().find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
@@ -116,7 +116,7 @@ describe('Discord (Composio) connector flow', () => {
console.log(`${LOG} PASS: connected state persists`);
});
it('composio_sync RPC routes to mock backend', async function () {
it('composio_sync does not tear down the session', async function () {
this.timeout(30_000);
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
+1 -1
View File
@@ -113,7 +113,7 @@ describe('GitHub Composio connector flow', () => {
console.log(`${LOG} PASS: connected state persists`);
});
it('composio_sync RPC routes to mock backend', async function () {
it('composio_sync does not tear down the session', async function () {
this.timeout(30_000);
clearRequestLog();
@@ -97,7 +97,7 @@ describe('Gmail (Composio) connector flow', () => {
console.log(`${LOG} PASS: connected state persists`);
});
it('composio_sync RPC routes to mock backend', async function () {
it('composio_sync does not tear down the session', async function () {
this.timeout(30_000);
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
@@ -1,163 +0,0 @@
/**
* E2E: Google Calendar (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 = '[ConnectorGoogleCalendarE2E]';
const CONNECTOR_NAME = 'Google Calendar';
const TOOLKIT_SLUG = 'googlecalendar';
const AUTH_TOKEN = 'e2e-connector-googlecalendar-token';
describe('Google Calendar Composio connector flow', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gcal-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-gcal-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 authReq = getRequestLog().find(
r => r.method === 'POST' && r.url.includes('/agent-integrations/composio/authorize')
);
expect(authReq).toBeDefined();
console.log(`${LOG} PASS: auth/connect routed correctly`);
});
it('connected state persists after reconnect/reload', async function () {
this.timeout(60_000);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gcal-1');
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 });
// syncReq URL check removed — composio_sync does no HTTP for
// connectors without a native provider (the RPC short-circuits). The
// assertSessionNotNuked() below covers the real intent: the call
// does not tear down the WebDriver session.
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-gcal-1',
action: 'GOOGLECALENDAR_LIST_EVENTS',
params: {},
});
// execReq URL check removed (see composio_sync comment above).
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-gcal-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-gcal-expired');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
expect(modal).toBeTruthy();
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-gcal-1',
action: 'GOOGLECALENDAR_LIST_EVENTS',
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-gcal-1');
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_delete_connection', { connection_id: 'c-gcal-1' });
const deleteReq = getRequestLog().find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
@@ -1,162 +0,0 @@
/**
* E2E: Google Drive (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 = '[ConnectorGoogleDriveE2E]';
const CONNECTOR_NAME = 'Google Drive';
const TOOLKIT_SLUG = 'googledrive';
const AUTH_TOKEN = 'e2e-connector-googledrive-token';
describe('Google Drive Composio connector flow', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gdrive-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-gdrive-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 authReq = getRequestLog().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 });
// syncReq URL check removed — composio_sync does no HTTP for
// connectors without a native provider (the RPC short-circuits). The
// assertSessionNotNuked() below covers the real intent: the call
// does not tear down the WebDriver session.
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-gdrive-1',
action: 'GOOGLEDRIVE_LIST_FILES',
params: {},
});
// execReq URL check removed (see composio_sync comment above).
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-gdrive-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-gdrive-expired');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
expect(modal).toBeTruthy();
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-gdrive-1',
action: 'GOOGLEDRIVE_LIST_FILES',
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-gdrive-1');
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_delete_connection', { connection_id: 'c-gdrive-1' });
const deleteReq = getRequestLog().find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
@@ -1,164 +0,0 @@
/**
* E2E: Google Sheets (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 = '[ConnectorGoogleSheetsE2E]';
const CONNECTOR_NAME = 'Google Sheets';
const TOOLKIT_SLUG = 'googlesheets';
const AUTH_TOKEN = 'e2e-connector-googlesheets-token';
describe('Google Sheets Composio connector flow', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gsheets-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-gsheets-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 authReq = getRequestLog().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 });
// syncReq URL check removed — composio_sync does no HTTP for
// connectors without a native provider (the RPC short-circuits). The
// assertSessionNotNuked() below covers the real intent: the call
// does not tear down the WebDriver session.
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-gsheets-1',
action: 'GOOGLESHEETS_LIST_SPREADSHEETS',
params: {},
});
// execReq URL check removed (see composio_sync comment above).
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-gsheets-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-gsheets-expired');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
expect(modal).toBeTruthy();
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-gsheets-1',
action: 'GOOGLESHEETS_LIST_SPREADSHEETS',
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-gsheets-1');
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_delete_connection', {
connection_id: 'c-gsheets-1',
});
const deleteReq = getRequestLog().find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
+1 -1
View File
@@ -132,7 +132,7 @@ describe('Jira Composio connector flow', () => {
console.log(`${LOG} PASS: connected state persists`);
});
it('composio_sync RPC routes to mock backend', async function () {
it('composio_sync does not tear down the session', async function () {
this.timeout(30_000);
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
-162
View File
@@ -1,162 +0,0 @@
/**
* E2E: Notion (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 = '[ConnectorNotionE2E]';
const CONNECTOR_NAME = 'Notion';
const TOOLKIT_SLUG = 'notion';
const AUTH_TOKEN = 'e2e-connector-notion-token';
describe('Notion Composio connector flow', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-notion-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-notion-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 authReq = getRequestLog().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 });
// syncReq URL check removed — composio_sync does no HTTP for
// connectors without a native provider (the RPC short-circuits). The
// assertSessionNotNuked() below covers the real intent: the call
// does not tear down the WebDriver session.
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-notion-1',
action: 'NOTION_LIST_PAGES',
params: {},
});
// execReq URL check removed (see composio_sync comment above).
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-notion-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-notion-expired');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
expect(modal).toBeTruthy();
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-notion-1',
action: 'NOTION_LIST_PAGES',
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-notion-1');
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_delete_connection', { connection_id: 'c-notion-1' });
const deleteReq = getRequestLog().find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
@@ -1,162 +0,0 @@
/**
* E2E: Slack (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 = '[ConnectorSlackComposioE2E]';
const CONNECTOR_NAME = 'Slack';
const TOOLKIT_SLUG = 'slack';
const AUTH_TOKEN = 'e2e-connector-slack-composio-token';
describe('Slack (Composio) connector flow', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-slack-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-slack-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 authReq = getRequestLog().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 });
// syncReq URL check removed — composio_sync does no HTTP for
// connectors without a native provider (the RPC short-circuits). The
// assertSessionNotNuked() below covers the real intent: the call
// does not tear down the WebDriver session.
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-slack-1',
action: 'SLACK_LIST_CHANNELS',
params: {},
});
// execReq URL check removed (see composio_sync comment above).
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-slack-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-slack-expired');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
expect(modal).toBeTruthy();
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-slack-1',
action: 'SLACK_LIST_CHANNELS',
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-slack-1');
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_delete_connection', { connection_id: 'c-slack-1' });
const deleteReq = getRequestLog().find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
@@ -1,164 +0,0 @@
/**
* E2E: Todoist (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 = '[ConnectorTodoistE2E]';
const CONNECTOR_NAME = 'Todoist';
const TOOLKIT_SLUG = 'todoist';
const AUTH_TOKEN = 'e2e-connector-todoist-token';
describe('Todoist Composio connector flow', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-todoist-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-todoist-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 authReq = getRequestLog().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 });
// syncReq URL check removed — composio_sync does no HTTP for
// connectors without a native provider (the RPC short-circuits). The
// assertSessionNotNuked() below covers the real intent: the call
// does not tear down the WebDriver session.
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-todoist-1',
action: 'TODOIST_LIST_PROJECTS',
params: {},
});
// execReq URL check removed (see composio_sync comment above).
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-todoist-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-todoist-expired');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
expect(modal).toBeTruthy();
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-todoist-1',
action: 'TODOIST_LIST_PROJECTS',
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-todoist-1');
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_delete_connection', {
connection_id: 'c-todoist-1',
});
const deleteReq = getRequestLog().find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
@@ -1,164 +0,0 @@
/**
* E2E: YouTube (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 = '[ConnectorYouTubeE2E]';
const CONNECTOR_NAME = 'YouTube';
const TOOLKIT_SLUG = 'youtube';
const AUTH_TOKEN = 'e2e-connector-youtube-token';
describe('YouTube Composio connector flow', () => {
before(async function () {
this.timeout(90_000);
await startMockServer();
seedComposioToolkits([TOOLKIT_SLUG]);
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-youtube-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-youtube-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 authReq = getRequestLog().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 });
// syncReq URL check removed — composio_sync does no HTTP for
// connectors without a native provider (the RPC short-circuits). The
// assertSessionNotNuked() below covers the real intent: the call
// does not tear down the WebDriver session.
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-youtube-1',
action: 'YOUTUBE_LIST_PLAYLISTS',
params: {},
});
// execReq URL check removed (see composio_sync comment above).
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-youtube-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-youtube-expired');
await navigateToSkills();
await waitForText(CONNECTOR_NAME, 10_000);
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
expect(modal).toBeTruthy();
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-youtube-1',
action: 'YOUTUBE_LIST_PLAYLISTS',
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-youtube-1');
clearRequestLog();
await callOpenhumanRpc('openhuman.composio_delete_connection', {
connection_id: 'c-youtube-1',
});
const deleteReq = getRequestLog().find(
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
);
expect(deleteReq).toBeDefined();
console.log(`${LOG} PASS: disconnect routed DELETE`);
await assertSessionNotNuked();
});
});
+7 -28
View File
@@ -10,10 +10,9 @@
*
* Every other spec assumes this works — so when CI is red, look here first.
*/
import { waitForApp, waitForAppReady } from '../helpers/app-helpers';
import { waitForApp } from '../helpers/app-helpers';
import { hasAppChrome } from '../helpers/element-helpers';
import { resetApp } from '../helpers/reset-app';
import { waitForHomePage } from '../helpers/shared-flows';
import { startMockServer, stopMockServer } from '../mock-server';
const USER_ID = 'e2e-smoke';
@@ -47,30 +46,10 @@ describe('Smoke', function () {
expect(elements.length).toBeGreaterThan(0);
});
// SKIPPED: pre-existing flake on the auth-deep-link → router
// hand-off in the Linux CI image. Same failure pattern visible on
// main run 25952893380 (four hours before this branch ran). After
// `triggerAuthDeepLinkBypass` returns, the renderer's hash stays
// on `#/` for the full 15 s poll window — the bypass JWT lands in
// sidecar config but the renderer's router doesn't react. Needs a
// dedicated investigation into the auth-state-change subscriber;
// the chat-harness PR didn't touch that path and shouldn't gate
// on it. The first three `it`s above already cover "harness
// attaches + window is mapped + DOM rendered" which is what smoke
// is for.
it.skip('(SKIPPED — see above) reaches a logged-in route after auth + onboarding', async () => {
await waitForAppReady(10_000);
let hash = '';
await browser.waitUntil(
async () => {
hash = (await browser.execute(() => window.location.hash)) as string;
return /^#\/(home|onboarding)/.test(hash);
},
{ timeout: 15_000, timeoutMsg: 'hash never settled to #/home or #/onboarding' }
);
if (hash.startsWith('#/home')) {
const homeText = await waitForHomePage(15_000);
expect(homeText).toBeTruthy();
}
});
// NOTE: a permanently-`it.skip`ped "reaches a logged-in route after auth +
// onboarding" test was removed here (plan.md §2.1) — it was skipped for a
// documented but untracked/ownerless auth-deep-link→router flake and thus
// read as coverage while never running. The three `it`s above (harness
// attaches + window mapped + DOM rendered) are what smoke is for; the
// logged-in-route journey is covered by the fuller flow specs.
});
+23 -5
View File
@@ -370,11 +370,29 @@ Dimensions the suite (and this audit) currently have **zero** coverage of:
- Event-bus panic isolation; webhook flood behavior (or file the product gap).
- `stop_core_process` debug command + crash-recovery E2E; RPC auth-failure E2E.
**Phase 2 — deletions & rewrites (parallel with Phase 1, low risk)**
- Land §2.1 deletions and §2.2 connector consolidation.
- Rewrite §3 overfits (preserve the load-bearing assertions flagged by the skeptic pass).
- Shared helpers: `assert_schema_controller_parity()`, `allowlist_contract_tests!`, envelope-unwrap
test helper, connector contract runner.
**Phase 2 — deletions & rewrites (parallel with Phase 1, low risk)** — ✅ landed (PR: `test/phase2-drops-rewrites`)
- [x] §2.1 deletions applied (⚠️ items re-verified against source before deleting):
useSettingsNavigation.test.tsx, RecoveryPhrasePanel.test.tsx, the 7 graph-api
"exposes the public surface" tests, the duplicate root ArtifactCard.test.tsx (unique
kind-label assertion ported into `__tests__/`), the it.skip smoke test; Rust:
harness_gap datetime test, 4 lib_tests no-ops, 4 factory construct-only tests,
3 telemetry emit-no-assert tests, 15 threads/ops_tests title dups (lowercase-hex
assertion folded into title.rs), whatsapp 7→1 parameterized skip test; deep_link_ipc
refactored to `collect_deep_link_urls_from_args` + real-fn test, vacuous no_primary test dropped.
- [x] §2.2 connector consolidation: 11 specs → `connector-composio-contract.spec.ts` +
`runConnectorContract()`; jira/gmail-composio kept bespoke; misleading `composio_sync`
test renamed across the contract + remaining specs.
- [x] §3 overfit rewrites (load-bearing assertions preserved): grounding wording-lock,
identity split (template-compare + labeled brand-voice lock), provider_surfaces parity via
new `assert_schema_controller_parity()`, composio catalog input-names, core_process
loose-contains (keeps task-slot/shutdown-token asserts), useDaemonLifecycle (keeps
listener asserts), ApprovalRequestCard (labeled visual lock), AppWalkthrough (data-testid).
- [x] Shared helper `assert_schema_controller_parity()` added (`src/core/all.rs`) and the
connector contract runner. (`allowlist_contract_tests!` / envelope-unwrap helper: follow-up.)
- Deferred: §3 gmail-flow fixture fix (needs deterministic mock Gmail-skill seeding validated
against a running desktop E2E app — not runnable in the authoring env; §2.3 flagged those
tests as load-bearing boot guards, so they were left intact rather than risk an unvalidated
E2E change).
**Phase 3 — P1 backlog (24 weeks, interleave with feature work)**
- Approval×turn integration, TransportManager race, socket backoff, hostile webhook payloads,
+23
View File
@@ -1019,6 +1019,29 @@ pub fn all_http_method_schemas() -> Vec<HttpMethodSchemaDefinition> {
methods
}
/// Shared test helper: assert a domain's controller-schema list and
/// registered-controller list stay in lockstep, and that a known function is
/// present. Replaces the brittle `assert_eq!(schemas().len(), N)` magic-number
/// pattern repeated across ~15 domains (plan.md §3/§6) — a legitimate new
/// controller no longer breaks the count, but a schema/handler desync or a
/// dropped op still fails.
#[cfg(test)]
pub(crate) fn assert_schema_controller_parity(
schemas: &[ControllerSchema],
controllers: &[RegisteredController],
known_function: &str,
) {
assert_eq!(
schemas.len(),
controllers.len(),
"schema/controller registration lists must stay in lockstep",
);
assert!(
schemas.iter().any(|s| s.function == known_function),
"expected a `{known_function}` controller schema to be registered",
);
}
#[cfg(test)]
#[path = "all_tests.rs"]
mod tests;
@@ -31,7 +31,6 @@ use crate::openhuman::tool_timeout::parse_tool_timeout_secs;
use crate::openhuman::tools::{Tool, ToolResult};
use async_trait::async_trait;
use parking_lot::Mutex;
use std::collections::HashSet;
// ─────────────────────────────────────────────────────────────────────────────
// Shared test doubles
@@ -327,59 +326,3 @@ fn current_datetime_line_matches_iso8601_date_and_utc_offset_pattern() {
"stamp must contain an IANA zone (slashed) or UTC fallback: {payload}"
);
}
#[test]
fn datetime_section_is_static_grounding_rule_not_a_volatile_timestamp() {
use crate::openhuman::agent::prompts::{DateTimeSection, PromptContext, PromptSection};
use std::collections::HashSet;
use std::path::Path;
use std::sync::LazyLock;
static EMPTY_FILTER: LazyLock<HashSet<String>> = LazyLock::new(HashSet::new);
static EMPTY_TOOLS: &[crate::openhuman::agent::prompts::PromptTool<'static>] = &[];
static EMPTY_INTEGRATIONS: &[crate::openhuman::context::prompt::ConnectedIntegration] = &[];
let ctx = PromptContext {
workspace_dir: Path::new("/tmp"),
model_name: "test-model",
agent_id: "",
tools: EMPTY_TOOLS,
workflows: &[],
dispatcher_instructions: "",
learned: crate::openhuman::agent::prompts::LearnedContextData::default(),
visible_tool_names: &EMPTY_FILTER,
tool_call_format: crate::openhuman::context::prompt::ToolCallFormat::PFormat,
connected_integrations: EMPTY_INTEGRATIONS,
connected_identities_md: String::new(),
include_profile: false,
include_memory_md: false,
curated_snapshot: None,
user_identity: None,
personality_soul_md: None,
personality_memory_md: None,
personality_roster: vec![],
};
let rendered = DateTimeSection.build(&ctx).unwrap();
let payload = rendered
.strip_prefix("## Current Date & Time\n\n")
.expect("DateTimeSection must start with the heading");
// The section is a static rule: it must carry the greeting-grounding
// guidance and point at the per-turn line, but NOT bake in a date — a
// concrete YYYY-MM-DD here would re-freeze the volatile clock into the
// cached prefix (the #3602 regression this guards against).
assert!(
payload.contains("match the actual local hour") && payload.contains("Current Date & Time:"),
"section must carry the grounding rule pointing at the per-turn stamp: {payload}"
);
// Byte-stability is the real no-volatile-timestamp invariant (a static
// literal like "11 PM" in the rule is fine; a baked `Local::now()` is
// not): two renders a moment apart must be identical, or the cached
// prefix would churn every second.
let again = DateTimeSection.build(&ctx).unwrap();
assert_eq!(
rendered, again,
"datetime section must be byte-stable (no volatile timestamp baked in)"
);
}
+31 -18
View File
@@ -153,17 +153,18 @@ fn grounding_contract_requires_exact_numeric_evidence() {
.build(&ctx)
.unwrap();
assert!(rendered.contains("Preserve numeric evidence exactly"));
assert!(rendered.contains(
"numbers, counts, sizes, dates, timestamps, durations, currencies, percentages, quotas, and ids"
));
assert!(rendered.contains(
"copy the exact value from the observed tool result, user message, or cited memory"
));
assert!(rendered.contains("Do not round, convert units, rewrite relative times"));
assert!(rendered.contains(
"If sources disagree, name the discrepancy instead of choosing a plausible value"
));
// WORDING LOCK (deliberate, plan.md §3): pin ONE representative clause of
// the numeric-evidence grounding rule so a copy edit that silently drops
// the "preserve numbers exactly" guidance trips review — rather than five
// verbatim prose substrings that break on any harmless rewording. The
// *structural* guarantee (the grounding contract is appended on every build
// path) is covered behaviourally by
// grounding_contract_appended_to_every_build_path. Update this string only
// on a deliberate rewrite of GROUNDING_BODY.
assert!(
rendered.contains("Preserve numeric evidence exactly"),
"numeric-evidence grounding clause missing from the built prompt"
);
}
#[test]
@@ -204,13 +205,27 @@ fn identity_section_creates_missing_workspace_files() {
"expected workspace file to be created: {file}"
);
}
// Seeded SOUL.md must equal the checked-in template verbatim (plan.md §3):
// compare against the embedded template rather than pinning brand-voice
// prose here — a missing file is seeded straight from
// default_workspace_file_content, which is this same `include_str!`.
let soul = std::fs::read_to_string(workspace.join("SOUL.md")).unwrap();
assert!(
soul.starts_with("# OpenHuman"),
"SOUL.md should be seeded from src/openhuman/agent/prompts/SOUL.md"
assert_eq!(
soul,
include_str!("SOUL.md"),
"seeded SOUL.md must be the checked-in template verbatim"
);
// #3604: the brand-voice guardrail must ship in the seeded soul so the
// agent defends the product constructively instead of validating FUD.
let _ = std::fs::remove_dir_all(workspace);
}
#[test]
fn soul_template_carries_brand_voice_guardrail() {
// BRAND-VOICE LOCK (#3604, plan.md §3): a narrow, deliberately-labeled
// wording pin on the *source* SOUL.md template — the constructive-defense
// guardrail must survive edits so the agent defends the product instead of
// validating FUD. Update only on an intentional brand-voice change.
let soul = include_str!("SOUL.md");
assert!(
soul.contains("## When OpenHuman is criticized"),
"SOUL.md must carry the brand-voice section (#3604)"
@@ -219,8 +234,6 @@ fn identity_section_creates_missing_workspace_files() {
soul.contains("Don't validate FUD"),
"SOUL.md brand-voice section must keep the do-not-validate-FUD directive (#3604)"
);
let _ = std::fs::remove_dir_all(workspace);
}
#[test]
@@ -497,150 +497,48 @@ fn whatsapp_parse_status_update_ignored() {
}
#[test]
fn whatsapp_parse_audio_message_skipped() {
fn whatsapp_parse_non_text_media_message_types_are_skipped() {
// Every non-text message type hits the same `type != "text" -> continue`
// branch in parse_webhook_payload. Table-driven, one representative case
// per media type (collapsed from 7 byte-identical tests, plan.md §2.1).
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
"value": {
"messages": [{
let cases = [
(
"audio",
serde_json::json!({ "id": "audio123", "mime_type": "audio/ogg" }),
),
("video", serde_json::json!({ "id": "video123" })),
(
"document",
serde_json::json!({ "id": "doc123", "filename": "file.pdf" }),
),
("sticker", serde_json::json!({ "id": "sticker123" })),
(
"location",
serde_json::json!({ "latitude": 40.7128, "longitude": -74.0060 }),
),
(
"contacts",
serde_json::json!([{ "name": { "formatted_name": "John" } }]),
),
(
"reaction",
serde_json::json!({ "message_id": "wamid.xxx", "emoji": "\u{1F44D}" }),
),
];
for (kind, sub) in cases {
let mut message = serde_json::json!({
"from": "111",
"timestamp": "1",
"type": "audio",
"audio": { "id": "audio123", "mime_type": "audio/ogg" }
}]
}
}]
}]
"type": kind,
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty());
}
#[test]
fn whatsapp_parse_video_message_skipped() {
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
message[kind] = sub;
let payload = serde_json::json!({
"entry": [{
"changes": [{
"value": {
"messages": [{
"from": "111",
"timestamp": "1",
"type": "video",
"video": { "id": "video123" }
}]
}
}]
}]
"entry": [{ "changes": [{ "value": { "messages": [message] } }] }]
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty());
assert!(msgs.is_empty(), "{kind} message must be skipped");
}
#[test]
fn whatsapp_parse_document_message_skipped() {
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
"value": {
"messages": [{
"from": "111",
"timestamp": "1",
"type": "document",
"document": { "id": "doc123", "filename": "file.pdf" }
}]
}
}]
}]
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty());
}
#[test]
fn whatsapp_parse_sticker_message_skipped() {
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
"value": {
"messages": [{
"from": "111",
"timestamp": "1",
"type": "sticker",
"sticker": { "id": "sticker123" }
}]
}
}]
}]
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty());
}
#[test]
fn whatsapp_parse_location_message_skipped() {
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
"value": {
"messages": [{
"from": "111",
"timestamp": "1",
"type": "location",
"location": { "latitude": 40.7128, "longitude": -74.0060 }
}]
}
}]
}]
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty());
}
#[test]
fn whatsapp_parse_contacts_message_skipped() {
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
"value": {
"messages": [{
"from": "111",
"timestamp": "1",
"type": "contacts",
"contacts": [{ "name": { "formatted_name": "John" } }]
}]
}
}]
}]
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty());
}
#[test]
fn whatsapp_parse_reaction_message_skipped() {
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
let payload = serde_json::json!({
"entry": [{
"changes": [{
"value": {
"messages": [{
"from": "111",
"timestamp": "1",
"type": "reaction",
"reaction": { "message_id": "wamid.xxx", "emoji": "👍" }
}]
}
}]
}]
});
let msgs = ch.parse_webhook_payload(&payload);
assert!(msgs.is_empty());
}
#[test]
+8 -7
View File
@@ -133,13 +133,14 @@ mod tests {
use super::*;
#[test]
fn all_schemas_returns_two() {
assert_eq!(all_provider_surfaces_controller_schemas().len(), 2);
}
#[test]
fn all_controllers_returns_two() {
assert_eq!(all_provider_surfaces_registered_controllers().len(), 2);
fn schemas_and_controllers_stay_in_lockstep_with_list_queue_present() {
// Parity + known-op presence instead of a magic `== 2` count, which
// would break on any legitimate third controller (plan.md §3).
crate::core::all::assert_schema_controller_parity(
&all_provider_surfaces_controller_schemas(),
&all_provider_surfaces_registered_controllers(),
"list_queue",
);
}
#[test]
+6 -39
View File
@@ -186,45 +186,12 @@ mod tests {
);
}
#[test]
fn factory_constructs_without_panic_when_runtime_enabled() {
let mut cfg = LocalAiConfig::default();
cfg.runtime_enabled = true;
cfg.chat_model_id = "gemma3:4b-it-qat".to_string();
let _p = make_provider(&cfg);
}
#[test]
fn factory_llamacpp_provider_constructs_without_panic() {
// When provider is "llamacpp" the health probe URL must be
// `{base}/models` (OpenAI-compat), not `{base}/api/tags` (Ollama).
// We verify construction does not panic and the routing provider
// is usable.
let mut cfg = LocalAiConfig::default();
cfg.runtime_enabled = true;
cfg.provider = "llamacpp".to_string();
cfg.base_url = Some("http://127.0.0.1:8080/v1".to_string());
let _p = make_provider(&cfg);
}
#[test]
fn factory_custom_openai_provider_constructs_without_panic() {
let mut cfg = LocalAiConfig::default();
cfg.runtime_enabled = true;
cfg.provider = "custom_openai".to_string();
cfg.base_url = Some("http://127.0.0.1:1234/v1".to_string());
let _p = make_provider(&cfg);
}
#[test]
fn factory_lm_studio_provider_constructs_without_panic() {
let mut cfg = LocalAiConfig::default();
cfg.runtime_enabled = true;
cfg.provider = "lm-studio".to_string();
cfg.base_url = Some("http://127.0.0.1:1234/v1".to_string());
cfg.chat_model_id = "local-model".to_string();
let _p = make_provider(&cfg);
}
// NOTE: four `factory_*_constructs_without_panic` smoke tests were removed
// here (plan.md §2.1) — construction is pure struct init that cannot fail,
// and private fields blocked any real probe-URL/capability assertion, so
// they verified nothing. The behavioural branches that DO assert
// (local-disabled streaming, llama-server alias, env-override precedence)
// are retained below.
#[test]
fn factory_llama_server_alias_is_recognised() {
-50
View File
@@ -59,53 +59,3 @@ pub fn emit(record: &RoutingRecord) {
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_record() -> RoutingRecord {
RoutingRecord {
model_hint: "hint:reaction".into(),
task_category: "lightweight",
routed_to: "local",
resolved_model: "gemma3:4b-it-qat".into(),
local_healthy: true,
fallback_to_remote: false,
latency_ms: 42,
input_tokens: 100,
output_tokens: 20,
cost_usd: 0.0,
}
}
#[test]
fn emit_does_not_panic() {
emit(&make_record());
}
#[test]
fn emit_fallback_does_not_panic() {
let mut r = make_record();
r.fallback_to_remote = true;
r.routed_to = "remote";
emit(&r);
}
#[test]
fn emit_remote_record_does_not_panic() {
let r = RoutingRecord {
model_hint: "hint:reasoning".into(),
task_category: "heavy",
routed_to: "remote",
resolved_model: "hint:reasoning".into(),
local_healthy: false,
fallback_to_remote: false,
latency_ms: 1200,
input_tokens: 2000,
output_tokens: 500,
cost_usd: 0.0012,
};
emit(&r);
}
}
+9 -135
View File
@@ -3,7 +3,6 @@
//! provider calls — they pin the behaviour of the branches that all of
//! the async `ops::*` entry points rely on.
use super::*;
use crate::openhuman::threads::title::collapse_whitespace;
use crate::openhuman::threads::turn_state::{
self, ClearTurnStateRequest, GetTurnStateRequest, TurnState,
};
@@ -64,48 +63,10 @@ fn counts_empty_iter_yields_empty_map() {
assert!(map.is_empty());
}
// ── title_log_fingerprint ─────────────────────────────────────
#[test]
fn title_log_fingerprint_is_16_lowercase_hex_chars() {
let fp = title_log_fingerprint("Chat Jan 1 1:00 AM");
assert_eq!(fp.len(), 16);
assert!(
fp.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"fingerprint must be lowercase hex, got: {fp}"
);
}
#[test]
fn title_log_fingerprint_is_deterministic_for_same_title() {
// The fingerprint is only used for debug logging — the only real
// contract is stability across calls inside a single process so
// grep-friendly logs remain correlatable.
let a = title_log_fingerprint("My cool thread");
let b = title_log_fingerprint("My cool thread");
assert_eq!(a, b);
}
#[test]
fn title_log_fingerprint_differs_for_different_titles() {
let a = title_log_fingerprint("thread one");
let b = title_log_fingerprint("thread two");
assert_ne!(a, b, "distinct titles must produce distinct fingerprints");
}
// ── collapse_whitespace ───────────────────────────────────────
#[test]
fn collapse_whitespace_collapses_runs_and_trims_edges() {
assert_eq!(collapse_whitespace(" a b\tc\nd "), "a b c d");
}
#[test]
fn collapse_whitespace_on_empty_or_whitespace_only_is_empty() {
assert_eq!(collapse_whitespace(""), "");
assert_eq!(collapse_whitespace(" \t\n "), "");
}
// NOTE: the title_log_fingerprint / collapse_whitespace copies were removed
// here (plan.md §2.1) — threads/title.rs (the owning module) already covers
// these functions with equivalent cases; the lowercase-hex assertion was
// folded into title.rs so no coverage was lost.
// ── build_title_prompt ────────────────────────────────────────
@@ -118,98 +79,11 @@ fn build_title_prompt_renders_user_and_assistant_sections_in_order() {
);
}
// ── sanitize_generated_title ──────────────────────────────────
#[test]
fn sanitize_generated_title_trims_surrounding_quotes_and_trailing_punct() {
assert_eq!(
sanitize_generated_title("\"Hello, world!\""),
Some("Hello, world".to_string())
);
assert_eq!(
sanitize_generated_title("`Hello`"),
Some("Hello".to_string())
);
assert_eq!(
sanitize_generated_title("'Plan trip'"),
Some("Plan trip".to_string())
);
}
#[test]
fn sanitize_generated_title_strips_repeated_trailing_punct() {
assert_eq!(
sanitize_generated_title("Check this out.!?"),
Some("Check this out".to_string())
);
}
#[test]
fn sanitize_generated_title_picks_first_non_empty_line() {
assert_eq!(
sanitize_generated_title("\n \nLine one\nLine two"),
Some("Line one".to_string())
);
}
#[test]
fn sanitize_generated_title_returns_none_for_empty_or_whitespace() {
assert!(sanitize_generated_title("").is_none());
assert!(sanitize_generated_title(" \n\t").is_none());
assert!(sanitize_generated_title("\"\"").is_none());
}
#[test]
fn sanitize_generated_title_collapses_internal_whitespace() {
assert_eq!(
sanitize_generated_title("Very spaced\tout"),
Some("Very spaced out".to_string())
);
}
#[test]
fn sanitize_generated_title_truncates_to_80_chars_by_char_count() {
// 100 `a` chars → must truncate to exactly 80. Char-based truncation
// is load-bearing so multibyte titles never get sliced mid-codepoint.
let raw = "a".repeat(100);
let out = sanitize_generated_title(&raw).expect("non-empty");
assert_eq!(out.chars().count(), 80);
}
#[test]
fn sanitize_generated_title_truncation_is_char_safe_for_multibyte() {
// 100 emoji (4-byte UTF-8 each) must still truncate on char
// boundaries, proving the `.chars().take(80)` vs byte slicing
// guarantee.
let raw = "🌍".repeat(100);
let out = sanitize_generated_title(&raw).expect("non-empty");
assert_eq!(out.chars().count(), 80);
}
// ── title_from_user_message ───────────────────────────────────
#[test]
fn title_from_user_message_builds_meaningful_fallback_title() {
assert_eq!(
title_from_user_message("Please summarize the latest five email threads for me.")
.as_deref(),
Some("Please summarize the latest five email threads for")
);
}
#[test]
fn title_from_user_message_uses_first_sentence_and_drops_trailing_punct() {
assert_eq!(
title_from_user_message("Telegram connection help? Then inspect logs.").as_deref(),
Some("Telegram connection help")
);
}
#[test]
fn title_from_user_message_returns_none_without_context() {
assert!(title_from_user_message(" ").is_none());
assert!(title_from_user_message("///").is_none());
}
// NOTE: the sanitize_generated_title / title_from_user_message copies were
// removed here (plan.md §2.1) — threads/title.rs (the owning module) already
// covers these functions with equivalent cases (quotes/punct trimming, first
// non-empty line, empty→None, internal-whitespace collapse, char-safe 80-char
// truncation incl. multibyte, and the fallback-title cases).
// ── is_auto_generated_thread_title ────────────────────────────
+7 -1
View File
@@ -170,7 +170,13 @@ mod tests {
fn fingerprint_is_sixteen_hex_chars() {
let fp = title_log_fingerprint("anything");
assert_eq!(fp.len(), 16);
assert!(fp.chars().all(|c| c.is_ascii_hexdigit()));
// Lowercase hex specifically, so grep-friendly debug logs stay
// consistent (folded in from the former threads/ops_tests copy).
assert!(
fp.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"fingerprint must be lowercase hex, got: {fp}"
);
}
// ── is_auto_generated_thread_title ────────────────────────────
+35 -26
View File
@@ -879,37 +879,46 @@ async fn composio_controller_registry_and_scope_handlers_cover_validation_edges(
#[test]
fn composio_controller_schema_catalog_covers_all_declared_functions() {
let expected = [
("list_toolkits", 0, "toolkits"),
("list_capabilities", 0, "capabilities"),
("list_agent_ready_toolkits", 0, "toolkits"),
("list_connections", 0, "connections"),
("authorize", 2, "connectUrl"),
("delete_connection", 2, "deleted"),
("list_tools", 2, "tools"),
("execute", 3, "result"),
("list_github_repos", 1, "result"),
("create_trigger", 3, "result"),
("get_user_profile", 1, "profile"),
("refresh_all_identities", 0, "report"),
("sync", 2, "outcome"),
("list_trigger_history", 1, "result"),
("get_user_scopes", 1, "pref"),
("set_user_scopes", 4, "pref"),
("list_available_triggers", 2, "triggers"),
("list_triggers", 1, "triggers"),
("enable_trigger", 3, "result"),
("disable_trigger", 1, "deleted"),
("get_mode", 0, "mode"),
("set_api_key", 2, "result"),
("clear_api_key", 0, "result"),
// (function, required input names, first output name). Assert the required
// inputs are *present* rather than pinning `inputs.len() == N` — the exact
// count broke whenever an additive optional param was declared (plan.md §3).
let expected: [(&str, &[&str], &str); 23] = [
("list_toolkits", &[], "toolkits"),
("list_capabilities", &[], "capabilities"),
("list_agent_ready_toolkits", &[], "toolkits"),
("list_connections", &[], "connections"),
("authorize", &["toolkit"], "connectUrl"),
("delete_connection", &["connection_id"], "deleted"),
("list_tools", &["toolkits"], "tools"),
("execute", &["tool", "connection_id"], "result"),
("list_github_repos", &["connection_id"], "result"),
("create_trigger", &["slug", "connection_id"], "result"),
("get_user_profile", &["connection_id"], "profile"),
("refresh_all_identities", &[], "report"),
("sync", &["connection_id"], "outcome"),
("list_trigger_history", &["limit"], "result"),
("get_user_scopes", &["toolkit"], "pref"),
("set_user_scopes", &["toolkit", "read", "write"], "pref"),
("list_available_triggers", &["toolkit"], "triggers"),
("list_triggers", &["toolkit"], "triggers"),
("enable_trigger", &["connection_id", "slug"], "result"),
("disable_trigger", &["trigger_id"], "deleted"),
("get_mode", &[], "mode"),
("set_api_key", &["api_key"], "result"),
("clear_api_key", &[], "result"),
];
for (function, input_count, first_output) in expected {
for (function, required_inputs, first_output) in expected {
let schema = openhuman_core::openhuman::composio::schemas::schemas(function);
assert_eq!(schema.namespace, "composio");
assert_eq!(schema.function, function);
assert_eq!(schema.inputs.len(), input_count, "{function}");
let input_names: Vec<&str> = schema.inputs.iter().map(|f| f.name).collect();
for required in required_inputs {
assert!(
input_names.contains(required),
"{function} must declare input `{required}` (got {input_names:?})"
);
}
assert_eq!(schema.outputs[0].name, first_output, "{function}");
assert!(!schema.description.is_empty());
}