From 760e5cbbdac56c960402859540c37f4d2b6a70fe Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Sat, 4 Jul 2026 04:01:57 -0700 Subject: [PATCH] =?UTF-8?q?test:=20Phase=202=20=E2=80=94=20drop=20duplicat?= =?UTF-8?q?e/vacuous=20tests,=20consolidate=20connectors,=20de-overfit=20(?= =?UTF-8?q?#4494)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/scripts/e2e-run-all-flows.sh | 15 +- app/src-tauri/src/core_process_tests.rs | 28 +- app/src-tauri/src/deep_link_ipc.rs | 75 +++-- app/src-tauri/src/lib_tests.rs | 56 +--- app/src/components/chat/ArtifactCard.test.tsx | 281 ------------------ .../__tests__/ApprovalRequestCard.test.tsx | 15 +- .../chat/__tests__/ArtifactCard.test.tsx | 6 +- .../__tests__/useSettingsNavigation.test.tsx | 35 --- .../panels/RecoveryPhrasePanel.test.tsx | 81 ----- .../walkthrough/WalkthroughTooltip.tsx | 1 + .../__tests__/AppWalkthrough.test.tsx | 23 +- .../__tests__/useDaemonLifecycle.test.ts | 26 +- .../services/api/connectionPathApi.test.ts | 9 +- .../api/entityAssociationsApi.test.ts | 9 +- .../services/api/graphCentralityApi.test.ts | 9 +- app/src/services/api/graphCohesionApi.test.ts | 9 +- .../services/api/memoryFreshnessApi.test.ts | 9 +- .../services/api/memoryTimelineApi.test.ts | 9 +- .../services/api/namespaceOverviewApi.test.ts | 8 +- app/test/e2e/helpers/connector-contract.ts | 185 ++++++++++++ app/test/e2e/specs/connector-airtable.spec.ts | 164 ---------- app/test/e2e/specs/connector-asana.spec.ts | 162 ---------- app/test/e2e/specs/connector-clickup.spec.ts | 164 ---------- .../specs/connector-composio-contract.spec.ts | 58 ++++ .../e2e/specs/connector-confluence.spec.ts | 164 ---------- .../specs/connector-discord-composio.spec.ts | 2 +- app/test/e2e/specs/connector-github.spec.ts | 2 +- .../specs/connector-gmail-composio.spec.ts | 2 +- .../specs/connector-google-calendar.spec.ts | 163 ---------- .../e2e/specs/connector-google-drive.spec.ts | 162 ---------- .../e2e/specs/connector-google-sheets.spec.ts | 164 ---------- app/test/e2e/specs/connector-jira.spec.ts | 2 +- app/test/e2e/specs/connector-notion.spec.ts | 162 ---------- .../specs/connector-slack-composio.spec.ts | 162 ---------- app/test/e2e/specs/connector-todoist.spec.ts | 164 ---------- app/test/e2e/specs/connector-youtube.spec.ts | 164 ---------- app/test/e2e/specs/smoke.spec.ts | 35 +-- plan.md | 28 +- src/core/all.rs | 23 ++ .../agent/harness/harness_gap_tests.rs | 57 ---- src/openhuman/agent/prompts/mod_tests.rs | 49 +-- .../channels/providers/whatsapp_tests.rs | 184 +++--------- src/openhuman/provider_surfaces/schemas.rs | 15 +- src/openhuman/routing/factory.rs | 45 +-- src/openhuman/routing/telemetry.rs | 50 ---- src/openhuman/threads/ops_tests.rs | 144 +-------- src/openhuman/threads/title.rs | 8 +- tests/composio_raw_coverage_e2e.rs | 61 ++-- 48 files changed, 536 insertions(+), 2913 deletions(-) delete mode 100644 app/src/components/chat/ArtifactCard.test.tsx delete mode 100644 app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx delete mode 100644 app/src/components/settings/panels/RecoveryPhrasePanel.test.tsx create mode 100644 app/test/e2e/helpers/connector-contract.ts delete mode 100644 app/test/e2e/specs/connector-airtable.spec.ts delete mode 100644 app/test/e2e/specs/connector-asana.spec.ts delete mode 100644 app/test/e2e/specs/connector-clickup.spec.ts create mode 100644 app/test/e2e/specs/connector-composio-contract.spec.ts delete mode 100644 app/test/e2e/specs/connector-confluence.spec.ts delete mode 100644 app/test/e2e/specs/connector-google-calendar.spec.ts delete mode 100644 app/test/e2e/specs/connector-google-drive.spec.ts delete mode 100644 app/test/e2e/specs/connector-google-sheets.spec.ts delete mode 100644 app/test/e2e/specs/connector-notion.spec.ts delete mode 100644 app/test/e2e/specs/connector-slack-composio.spec.ts delete mode 100644 app/test/e2e/specs/connector-todoist.spec.ts delete mode 100644 app/test/e2e/specs/connector-youtube.spec.ts diff --git a/app/scripts/e2e-run-all-flows.sh b/app/scripts/e2e-run-all-flows.sh index 92e51ec81..9c1e676e5 100755 --- a/app/scripts/e2e-run-all-flows.sh +++ b/app/scripts/e2e-run-all-flows.sh @@ -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 diff --git a/app/src-tauri/src/core_process_tests.rs b/app/src-tauri/src/core_process_tests.rs index 954a3f84c..a505788ad 100644 --- a/app/src-tauri/src/core_process_tests.rs +++ b/app/src-tauri/src/core_process_tests.rs @@ -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" diff --git a/app/src-tauri/src/deep_link_ipc.rs b/app/src-tauri/src/deep_link_ipc.rs index f7c6e8756..42fe98b86 100644 --- a/app/src-tauri/src/deep_link_ipc.rs +++ b/app/src-tauri/src/deep_link_ipc.rs @@ -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(args: I) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + 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 { - 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 = 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. } diff --git a/app/src-tauri/src/lib_tests.rs b/app/src-tauri/src/lib_tests.rs index cd52d3adb..0b8862efa 100644 --- a/app/src-tauri/src/lib_tests.rs +++ b/app/src-tauri/src/lib_tests.rs @@ -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() {} - // _check_runtime::(); // 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) diff --git a/app/src/components/chat/ArtifactCard.test.tsx b/app/src/components/chat/ArtifactCard.test.tsx deleted file mode 100644 index bb9abe8a2..000000000 --- a/app/src/components/chat/ArtifactCard.test.tsx +++ /dev/null @@ -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 { - 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(' — in_progress state', () => { - it('renders the title with the generating sub-label and no buttons', () => { - render( - - ); - - 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( - - ); - - expect(screen.getByText(/Generating image/i)).toBeInTheDocument(); - }); -}); - -describe(' — ready state', () => { - it('renders the Download button with the formatted size', () => { - render(); - - 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(); - - // 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(); - - 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( - - ); - - 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( - - ); - - 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( - - ); - - 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( - - ); - - 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(); - 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(); - // No reveal button is rendered until state === 'done'. - expect(screen.queryByRole('button', { name: 'Show in folder' })).not.toBeInTheDocument(); - expect(revealArtifactInFileManagerMock).not.toHaveBeenCalled(); - }); -}); - -describe(' — failed state', () => { - it('shows the producer error verbatim under the preview cap', () => { - render( - - ); - - 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( - - ); - - // 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( - - ); - - 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( - - ); - expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument(); - }); - - it('omits the error block entirely when no error string is set', () => { - render( - - ); - expect(screen.getByText(/Generation failed/i)).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument(); - }); -}); - -describe(' — aria label', () => { - it('exposes an aria-label with the artifact title', () => { - render(); - expect(screen.getByRole('group', { name: /Q3 Plan/ })).toBeInTheDocument(); - }); -}); diff --git a/app/src/components/chat/__tests__/ApprovalRequestCard.test.tsx b/app/src/components/chat/__tests__/ApprovalRequestCard.test.tsx index 1ff51caec..c1f36137c 100644 --- a/app/src/components/chat/__tests__/ApprovalRequestCard.test.tsx +++ b/app/src/components/chat/__tests__/ApprovalRequestCard.test.tsx @@ -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)', () => { diff --git a/app/src/components/chat/__tests__/ArtifactCard.test.tsx b/app/src/components/chat/__tests__/ArtifactCard.test.tsx index c0f6b471a..b4c550c3b 100644 --- a/app/src/components/chat/__tests__/ArtifactCard.test.tsx +++ b/app/src/components/chat/__tests__/ArtifactCard.test.tsx @@ -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(); - 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(); } ); diff --git a/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx b/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx deleted file mode 100644 index 973c1cda0..000000000 --- a/app/src/components/settings/hooks/__tests__/useSettingsNavigation.test.tsx +++ /dev/null @@ -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
{breadcrumbs.map(b => b.label).join(' > ')}
; -}; - -/** - * 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(, { initialEntries: [route] }); - expect(screen.getByTestId('breadcrumbs')).toHaveTextContent(''); - }); -}); diff --git a/app/src/components/settings/panels/RecoveryPhrasePanel.test.tsx b/app/src/components/settings/panels/RecoveryPhrasePanel.test.tsx deleted file mode 100644 index 9ca0c01a0..000000000 --- a/app/src/components/settings/panels/RecoveryPhrasePanel.test.tsx +++ /dev/null @@ -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 }) => ( -
- {title} - {description} -
- ), -})); - -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('', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('initially hides the recovery phrase and reveals it when clicking the reveal button', async () => { - render(); - - // 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(); - }); -}); diff --git a/app/src/components/walkthrough/WalkthroughTooltip.tsx b/app/src/components/walkthrough/WalkthroughTooltip.tsx index bf5e9ff5e..d00c7646e 100644 --- a/app/src/components/walkthrough/WalkthroughTooltip.tsx +++ b/app/src/components/walkthrough/WalkthroughTooltip.tsx @@ -37,6 +37,7 @@ const WalkthroughTooltip = ({ {/* Progress bar — thin, smooth fill */}
diff --git a/app/src/components/walkthrough/__tests__/AppWalkthrough.test.tsx b/app/src/components/walkthrough/__tests__/AppWalkthrough.test.tsx index 1f28a87ab..26f205226 100644 --- a/app/src/components/walkthrough/__tests__/AppWalkthrough.test.tsx +++ b/app/src/components/walkthrough/__tests__/AppWalkthrough.test.tsx @@ -504,26 +504,23 @@ describe('WalkthroughTooltip', () => { }); it('renders progress bar', () => { - const { container } = render( - - ); + render(); - // 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(); diff --git a/app/src/hooks/__tests__/useDaemonLifecycle.test.ts b/app/src/hooks/__tests__/useDaemonLifecycle.test.ts index a7ba40c55..826b6b4a3 100644 --- a/app/src/hooks/__tests__/useDaemonLifecycle.test.ts +++ b/app/src/hooks/__tests__/useDaemonLifecycle.test.ts @@ -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(); } diff --git a/app/src/services/api/connectionPathApi.test.ts b/app/src/services/api/connectionPathApi.test.ts index 5277b63bb..f051ce45d 100644 --- a/app/src/services/api/connectionPathApi.test.ts +++ b/app/src/services/api/connectionPathApi.test.ts @@ -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'); - }); -}); diff --git a/app/src/services/api/entityAssociationsApi.test.ts b/app/src/services/api/entityAssociationsApi.test.ts index 579331417..d8e8daf0e 100644 --- a/app/src/services/api/entityAssociationsApi.test.ts +++ b/app/src/services/api/entityAssociationsApi.test.ts @@ -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'); - }); -}); diff --git a/app/src/services/api/graphCentralityApi.test.ts b/app/src/services/api/graphCentralityApi.test.ts index 71335d41f..86bc0e479 100644 --- a/app/src/services/api/graphCentralityApi.test.ts +++ b/app/src/services/api/graphCentralityApi.test.ts @@ -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'); - }); -}); diff --git a/app/src/services/api/graphCohesionApi.test.ts b/app/src/services/api/graphCohesionApi.test.ts index 0356702fe..71b26f2ce 100644 --- a/app/src/services/api/graphCohesionApi.test.ts +++ b/app/src/services/api/graphCohesionApi.test.ts @@ -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'); - }); -}); diff --git a/app/src/services/api/memoryFreshnessApi.test.ts b/app/src/services/api/memoryFreshnessApi.test.ts index 2e300ce9f..295c393ee 100644 --- a/app/src/services/api/memoryFreshnessApi.test.ts +++ b/app/src/services/api/memoryFreshnessApi.test.ts @@ -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'); - }); -}); diff --git a/app/src/services/api/memoryTimelineApi.test.ts b/app/src/services/api/memoryTimelineApi.test.ts index 2dbde3750..6f691371e 100644 --- a/app/src/services/api/memoryTimelineApi.test.ts +++ b/app/src/services/api/memoryTimelineApi.test.ts @@ -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'); - }); -}); diff --git a/app/src/services/api/namespaceOverviewApi.test.ts b/app/src/services/api/namespaceOverviewApi.test.ts index a2310585d..fdad38f83 100644 --- a/app/src/services/api/namespaceOverviewApi.test.ts +++ b/app/src/services/api/namespaceOverviewApi.test.ts @@ -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'); - }); -}); diff --git a/app/test/e2e/helpers/connector-contract.ts b/app/test/e2e/helpers/connector-contract.ts new file mode 100644 index 000000000..7fc2097d9 --- /dev/null +++ b/app/test/e2e/helpers/connector-contract.ts @@ -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--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(); + }); + }); +} diff --git a/app/test/e2e/specs/connector-airtable.spec.ts b/app/test/e2e/specs/connector-airtable.spec.ts deleted file mode 100644 index 276aad603..000000000 --- a/app/test/e2e/specs/connector-airtable.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/test/e2e/specs/connector-asana.spec.ts b/app/test/e2e/specs/connector-asana.spec.ts deleted file mode 100644 index 39ceea12d..000000000 --- a/app/test/e2e/specs/connector-asana.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/test/e2e/specs/connector-clickup.spec.ts b/app/test/e2e/specs/connector-clickup.spec.ts deleted file mode 100644 index 412b8ece7..000000000 --- a/app/test/e2e/specs/connector-clickup.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/test/e2e/specs/connector-composio-contract.spec.ts b/app/test/e2e/specs/connector-composio-contract.spec.ts new file mode 100644 index 000000000..89282135a --- /dev/null +++ b/app/test/e2e/specs/connector-composio-contract.spec.ts @@ -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); +} diff --git a/app/test/e2e/specs/connector-confluence.spec.ts b/app/test/e2e/specs/connector-confluence.spec.ts deleted file mode 100644 index 39d2b5aa7..000000000 --- a/app/test/e2e/specs/connector-confluence.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/test/e2e/specs/connector-discord-composio.spec.ts b/app/test/e2e/specs/connector-discord-composio.spec.ts index e4e9c5bef..2fe75602c 100644 --- a/app/test/e2e/specs/connector-discord-composio.spec.ts +++ b/app/test/e2e/specs/connector-discord-composio.spec.ts @@ -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 }); diff --git a/app/test/e2e/specs/connector-github.spec.ts b/app/test/e2e/specs/connector-github.spec.ts index c59bd5a13..561ab7fd7 100644 --- a/app/test/e2e/specs/connector-github.spec.ts +++ b/app/test/e2e/specs/connector-github.spec.ts @@ -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(); diff --git a/app/test/e2e/specs/connector-gmail-composio.spec.ts b/app/test/e2e/specs/connector-gmail-composio.spec.ts index 8e3bef724..3403d38e4 100644 --- a/app/test/e2e/specs/connector-gmail-composio.spec.ts +++ b/app/test/e2e/specs/connector-gmail-composio.spec.ts @@ -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 }); diff --git a/app/test/e2e/specs/connector-google-calendar.spec.ts b/app/test/e2e/specs/connector-google-calendar.spec.ts deleted file mode 100644 index dd33112f6..000000000 --- a/app/test/e2e/specs/connector-google-calendar.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/test/e2e/specs/connector-google-drive.spec.ts b/app/test/e2e/specs/connector-google-drive.spec.ts deleted file mode 100644 index 9d3cf96c0..000000000 --- a/app/test/e2e/specs/connector-google-drive.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/test/e2e/specs/connector-google-sheets.spec.ts b/app/test/e2e/specs/connector-google-sheets.spec.ts deleted file mode 100644 index 3f1095e7e..000000000 --- a/app/test/e2e/specs/connector-google-sheets.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/test/e2e/specs/connector-jira.spec.ts b/app/test/e2e/specs/connector-jira.spec.ts index 03b5453fc..34461edf6 100644 --- a/app/test/e2e/specs/connector-jira.spec.ts +++ b/app/test/e2e/specs/connector-jira.spec.ts @@ -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 }); diff --git a/app/test/e2e/specs/connector-notion.spec.ts b/app/test/e2e/specs/connector-notion.spec.ts deleted file mode 100644 index 06aedcea5..000000000 --- a/app/test/e2e/specs/connector-notion.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/test/e2e/specs/connector-slack-composio.spec.ts b/app/test/e2e/specs/connector-slack-composio.spec.ts deleted file mode 100644 index 6152ff3ae..000000000 --- a/app/test/e2e/specs/connector-slack-composio.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/test/e2e/specs/connector-todoist.spec.ts b/app/test/e2e/specs/connector-todoist.spec.ts deleted file mode 100644 index 4b81e3181..000000000 --- a/app/test/e2e/specs/connector-todoist.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/test/e2e/specs/connector-youtube.spec.ts b/app/test/e2e/specs/connector-youtube.spec.ts deleted file mode 100644 index 18976983a..000000000 --- a/app/test/e2e/specs/connector-youtube.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/app/test/e2e/specs/smoke.spec.ts b/app/test/e2e/specs/smoke.spec.ts index b9ac3abdf..4bc5efea9 100644 --- a/app/test/e2e/specs/smoke.spec.ts +++ b/app/test/e2e/specs/smoke.spec.ts @@ -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. }); diff --git a/plan.md b/plan.md index 5fc0aa6de..57baedb68 100644 --- a/plan.md +++ b/plan.md @@ -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 (2–4 weeks, interleave with feature work)** - Approval×turn integration, TransportManager race, socket backoff, hostile webhook payloads, diff --git a/src/core/all.rs b/src/core/all.rs index 1ed2d06e6..9b4a7d07b 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -1019,6 +1019,29 @@ pub fn all_http_method_schemas() -> Vec { 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; diff --git a/src/openhuman/agent/harness/harness_gap_tests.rs b/src/openhuman/agent/harness/harness_gap_tests.rs index 9f26d57bc..668f3dbf6 100644 --- a/src/openhuman/agent/harness/harness_gap_tests.rs +++ b/src/openhuman/agent/harness/harness_gap_tests.rs @@ -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> = 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)" - ); -} diff --git a/src/openhuman/agent/prompts/mod_tests.rs b/src/openhuman/agent/prompts/mod_tests.rs index 57ae57d4c..e201b7275 100644 --- a/src/openhuman/agent/prompts/mod_tests.rs +++ b/src/openhuman/agent/prompts/mod_tests.rs @@ -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] diff --git a/src/openhuman/channels/providers/whatsapp_tests.rs b/src/openhuman/channels/providers/whatsapp_tests.rs index 4dba66285..ff2497e0c 100644 --- a/src/openhuman/channels/providers/whatsapp_tests.rs +++ b/src/openhuman/channels/providers/whatsapp_tests.rs @@ -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": [{ - "from": "111", - "timestamp": "1", - "type": "audio", - "audio": { "id": "audio123", "mime_type": "audio/ogg" } - }] - } - }] - }] - }); - 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()]); - let payload = serde_json::json!({ - "entry": [{ - "changes": [{ - "value": { - "messages": [{ - "from": "111", - "timestamp": "1", - "type": "video", - "video": { "id": "video123" } - }] - } - }] - }] - }); - let msgs = ch.parse_webhook_payload(&payload); - assert!(msgs.is_empty()); -} - -#[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()); + 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": kind, + }); + message[kind] = sub; + let payload = serde_json::json!({ + "entry": [{ "changes": [{ "value": { "messages": [message] } }] }] + }); + let msgs = ch.parse_webhook_payload(&payload); + assert!(msgs.is_empty(), "{kind} message must be skipped"); + } } #[test] diff --git a/src/openhuman/provider_surfaces/schemas.rs b/src/openhuman/provider_surfaces/schemas.rs index db4d0a3b8..9024d9edb 100644 --- a/src/openhuman/provider_surfaces/schemas.rs +++ b/src/openhuman/provider_surfaces/schemas.rs @@ -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] diff --git a/src/openhuman/routing/factory.rs b/src/openhuman/routing/factory.rs index 0335e82b6..d7a85f80d 100644 --- a/src/openhuman/routing/factory.rs +++ b/src/openhuman/routing/factory.rs @@ -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() { diff --git a/src/openhuman/routing/telemetry.rs b/src/openhuman/routing/telemetry.rs index b0b752356..76c3985b6 100644 --- a/src/openhuman/routing/telemetry.rs +++ b/src/openhuman/routing/telemetry.rs @@ -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); - } -} diff --git a/src/openhuman/threads/ops_tests.rs b/src/openhuman/threads/ops_tests.rs index eb99a7725..1c93aa451 100644 --- a/src/openhuman/threads/ops_tests.rs +++ b/src/openhuman/threads/ops_tests.rs @@ -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 ──────────────────────────── diff --git a/src/openhuman/threads/title.rs b/src/openhuman/threads/title.rs index 07b117da7..77ac60699 100644 --- a/src/openhuman/threads/title.rs +++ b/src/openhuman/threads/title.rs @@ -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 ──────────────────────────── diff --git a/tests/composio_raw_coverage_e2e.rs b/tests/composio_raw_coverage_e2e.rs index d5c21feb8..251a71259 100644 --- a/tests/composio_raw_coverage_e2e.rs +++ b/tests/composio_raw_coverage_e2e.rs @@ -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()); }