mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
test: Phase 2 — drop duplicate/vacuous tests, consolidate connectors, de-overfit (#4494)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -32,12 +32,27 @@ pub(crate) fn socket_path() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("com_openhuman_app_deeplink_{uid}.sock"))
|
||||
}
|
||||
|
||||
/// Filter `openhuman://` URLs out of an argv-style iterator. Split out from
|
||||
/// `extract_deep_link_urls` so the real filtering logic is unit-testable
|
||||
/// without mutating the process-global `std::env::args()` — mirrors the
|
||||
/// Windows sibling `collect_deep_link_urls_from_args`.
|
||||
pub(crate) fn collect_deep_link_urls_from_args<I, S>(args: I) -> Vec<String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: AsRef<str>,
|
||||
{
|
||||
args.into_iter()
|
||||
.skip(1)
|
||||
.filter_map(|arg| {
|
||||
let arg = arg.as_ref();
|
||||
arg.starts_with("openhuman://").then(|| arg.to_string())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Collect any `openhuman://` URLs from the process argv.
|
||||
pub(crate) fn extract_deep_link_urls() -> Vec<String> {
|
||||
std::env::args()
|
||||
.skip(1)
|
||||
.filter(|a| a.starts_with("openhuman://"))
|
||||
.collect()
|
||||
collect_deep_link_urls_from_args(std::env::args())
|
||||
}
|
||||
|
||||
/// Result of `try_forward_deep_links`.
|
||||
@@ -328,22 +343,20 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn extract_deep_link_urls_filters_correctly() {
|
||||
// We can't mutate std::env::args(), so test the filtering logic directly.
|
||||
let args = vec![
|
||||
"OpenHuman".to_string(),
|
||||
"openhuman://auth?token=abc".to_string(),
|
||||
"--some-flag".to_string(),
|
||||
"openhuman://other".to_string(),
|
||||
"https://example.com".to_string(),
|
||||
];
|
||||
let urls: Vec<String> = args
|
||||
.into_iter()
|
||||
.skip(1)
|
||||
.filter(|a| a.starts_with("openhuman://"))
|
||||
.collect();
|
||||
assert_eq!(urls.len(), 2);
|
||||
assert_eq!(urls[0], "openhuman://auth?token=abc");
|
||||
assert_eq!(urls[1], "openhuman://other");
|
||||
// Exercise the REAL production filter through the args-slice seam
|
||||
// (mirrors the Windows sibling test) instead of re-implementing the
|
||||
// predicate inline — so a regression in the filter actually fails here.
|
||||
let urls = collect_deep_link_urls_from_args([
|
||||
"OpenHuman",
|
||||
"openhuman://auth?token=abc",
|
||||
"--some-flag",
|
||||
"openhuman://other",
|
||||
"https://example.com",
|
||||
]);
|
||||
assert_eq!(
|
||||
urls,
|
||||
vec!["openhuman://auth?token=abc", "openhuman://other"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -384,22 +397,8 @@ mod tests {
|
||||
assert_eq!(got[0], "openhuman://auth?token=testtoken123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_primary_returns_appropriate_result() {
|
||||
// Remove socket file to guarantee no primary.
|
||||
std::env::remove_var("XDG_RUNTIME_DIR");
|
||||
let _ = std::fs::remove_file(socket_path());
|
||||
|
||||
// The "extract_deep_link_urls" function reads actual argv which has
|
||||
// no openhuman:// URLs during tests, so try_forward_deep_links()
|
||||
// returns NoUrls. We test the NoPrimary branch directly by
|
||||
// testing that connect to a missing socket fails.
|
||||
let non_existent = PathBuf::from("/tmp/openhuman_test_nonexistent_socket.sock");
|
||||
let _ = std::fs::remove_file(&non_existent);
|
||||
let result = UnixStream::connect(&non_existent);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Expected connection failure for missing socket"
|
||||
);
|
||||
}
|
||||
// NOTE: `no_primary_returns_appropriate_result` removed (plan.md §2.1) —
|
||||
// its own comment admitted it couldn't reach the production NoPrimary
|
||||
// branch and instead asserted that stdlib `UnixStream::connect` errors on
|
||||
// a bogus path, which verifies nothing about our code.
|
||||
}
|
||||
|
||||
@@ -6,13 +6,8 @@ use super::*;
|
||||
// spurious failures.
|
||||
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Test that is_daemon_mode correctly detects daemon flag variations
|
||||
#[test]
|
||||
fn is_daemon_mode_detects_daemon_flag() {
|
||||
// Note: This test relies on the current process args, so in test mode
|
||||
// it will typically return false. We verify the function is callable.
|
||||
let _result = is_daemon_mode();
|
||||
}
|
||||
// NOTE: `is_daemon_mode_detects_daemon_flag` removed (plan.md §2.1) — it
|
||||
// discarded the result with `let _` and asserted nothing.
|
||||
|
||||
/// Test core_rpc_url returns expected format
|
||||
#[test]
|
||||
@@ -58,34 +53,9 @@ fn overlay_parent_rpc_url_handles_empty() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests for setup_tray conditional compilation
|
||||
/// The PR adds two versions of setup_tray():
|
||||
/// 1. No-op for linux + cef: logs warning and returns Ok(())
|
||||
/// 2. Full implementation for other platforms
|
||||
///
|
||||
/// These tests verify the function signatures are correct and
|
||||
/// the compile-time cfg blocks are properly set up.
|
||||
|
||||
/// Verify setup_tray function exists and has correct signature
|
||||
/// This test passes if the code compiles, as the function signature
|
||||
/// is validated by the compiler.
|
||||
#[test]
|
||||
fn setup_tray_function_signature_compiles() {
|
||||
// This test exists to ensure the conditional compilation
|
||||
// of setup_tray is valid. The function is not actually called
|
||||
// here because it requires a full Tauri AppHandle.
|
||||
// The cfg attributes ensure only one version exists at compile time.
|
||||
}
|
||||
|
||||
/// Test that AppRuntime is defined for the current feature set
|
||||
#[test]
|
||||
fn app_runtime_type_exists() {
|
||||
// This test verifies AppRuntime is properly defined
|
||||
// based on the cef feature flag.
|
||||
// The type alias exists at module scope and is used throughout.
|
||||
fn _check_runtime<R: tauri::Runtime>() {}
|
||||
// _check_runtime::<AppRuntime>(); // Would require importing
|
||||
}
|
||||
// NOTE: `setup_tray_function_signature_compiles` and `app_runtime_type_exists`
|
||||
// removed (plan.md §2.1) — one had an empty body, the other's only real
|
||||
// assertion was commented out; both were compile-only no-ops.
|
||||
|
||||
#[test]
|
||||
fn no_app_update_available_result_is_quiet_unavailable() {
|
||||
@@ -97,20 +67,8 @@ fn no_app_update_available_result_is_quiet_unavailable() {
|
||||
assert!(info.body.is_none());
|
||||
}
|
||||
|
||||
/// Verify tray logging patterns exist (grep-friendly)
|
||||
#[test]
|
||||
fn tray_setup_logging_patterns_exist() {
|
||||
// These log patterns from the PR are grep-friendly:
|
||||
// "[tray] skipping tray setup on linux+cef: ..."
|
||||
// "[tray] setting up tray icon"
|
||||
// "[tray] tray icon ready"
|
||||
// "[tray] action=show_window ..."
|
||||
// "[tray] action=quit ..."
|
||||
// "[tray] failed to setup tray icon ..."
|
||||
// "[app] RunEvent::Ready — GTK initialized, setting up tray"
|
||||
//
|
||||
// This test passes if the code compiles with these log messages.
|
||||
}
|
||||
// NOTE: `tray_setup_logging_patterns_exist` removed (plan.md §2.1) — a
|
||||
// comments-only body that asserted nothing.
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// macos_os_version (issue #1012)
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ArtifactSnapshot } from '../../store/chatRuntimeSlice';
|
||||
import ArtifactCard from './ArtifactCard';
|
||||
|
||||
// Mock the artifact download service — the card only consumes the
|
||||
// two public functions, so a per-test override is enough.
|
||||
const saveArtifactViaDialogMock = vi.fn();
|
||||
const revealArtifactInFileManagerMock = vi.fn();
|
||||
vi.mock('../../services/artifactDownloadService', () => ({
|
||||
saveArtifactViaDialog: (...args: unknown[]) => saveArtifactViaDialogMock(...args),
|
||||
revealArtifactInFileManager: (...args: unknown[]) => revealArtifactInFileManagerMock(...args),
|
||||
}));
|
||||
|
||||
function snapshot(overrides: Partial<ArtifactSnapshot> = {}): ArtifactSnapshot {
|
||||
return {
|
||||
artifactId: 'a-1',
|
||||
kind: 'presentation',
|
||||
title: 'Quarterly Deck',
|
||||
status: 'ready',
|
||||
sizeBytes: 4096,
|
||||
path: 'a-1/deck.pptx',
|
||||
updatedAt: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
saveArtifactViaDialogMock.mockReset();
|
||||
revealArtifactInFileManagerMock.mockReset();
|
||||
});
|
||||
|
||||
describe('<ArtifactCard /> — in_progress state', () => {
|
||||
it('renders the title with the generating sub-label and no buttons', () => {
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({ status: 'in_progress', sizeBytes: undefined, path: undefined })}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Quarterly Deck')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Generating presentation/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /download/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reflects the artifact kind in the generating label', () => {
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({
|
||||
status: 'in_progress',
|
||||
kind: 'image',
|
||||
sizeBytes: undefined,
|
||||
path: undefined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Generating image/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('<ArtifactCard /> — ready state', () => {
|
||||
it('renders the Download button with the formatted size', () => {
|
||||
render(<ArtifactCard artifact={snapshot()} />);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Download' })).toBeEnabled();
|
||||
// formatFileSize on 4096 should land in the "4 KB"-ish range; we
|
||||
// just assert the size cell exists rather than asserting an exact
|
||||
// formatter output to avoid coupling to its implementation detail.
|
||||
const subLabel = screen.getByText(/Ready ·/);
|
||||
expect(subLabel.textContent ?? '').toMatch(/[KMG]?B/);
|
||||
});
|
||||
|
||||
it('omits the size suffix when sizeBytes is null', () => {
|
||||
render(<ArtifactCard artifact={snapshot({ sizeBytes: undefined })} />);
|
||||
|
||||
// Status text should be empty when sizeBytes is missing on a ready
|
||||
// artifact — the conditional collapses to ''.
|
||||
const dl = screen.getByRole('button', { name: 'Download' });
|
||||
expect(dl).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('drives the download → done flow and reveals on click', async () => {
|
||||
saveArtifactViaDialogMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
path: '/Users/me/Downloads/Quarterly Deck.pptx',
|
||||
});
|
||||
revealArtifactInFileManagerMock.mockResolvedValueOnce(true);
|
||||
|
||||
render(<ArtifactCard artifact={snapshot()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
|
||||
// Service called with (id, title, ext) where ext comes from the
|
||||
// title's extension when present, or kind fallback otherwise.
|
||||
await waitFor(() => expect(saveArtifactViaDialogMock).toHaveBeenCalledTimes(1));
|
||||
expect(saveArtifactViaDialogMock).toHaveBeenCalledWith('a-1', 'Quarterly Deck', 'pptx');
|
||||
|
||||
// Saved-to row appears with the reveal button.
|
||||
await screen.findByText(
|
||||
(_, el) => el?.textContent === 'Saved to /Users/me/Downloads/Quarterly Deck.pptx'
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show in folder' }));
|
||||
await waitFor(() => expect(revealArtifactInFileManagerMock).toHaveBeenCalledTimes(1));
|
||||
expect(revealArtifactInFileManagerMock).toHaveBeenCalledWith(
|
||||
'/Users/me/Downloads/Quarterly Deck.pptx'
|
||||
);
|
||||
|
||||
// Download button is gone now that state === 'done'.
|
||||
expect(screen.queryByRole('button', { name: 'Download' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to the kind-based extension when title has no dot', async () => {
|
||||
saveArtifactViaDialogMock.mockResolvedValueOnce({ ok: true, path: '/tmp/Doc.pdf' });
|
||||
render(
|
||||
<ArtifactCard artifact={snapshot({ artifactId: 'a-2', kind: 'document', title: 'Doc' })} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
await waitFor(() => expect(saveArtifactViaDialogMock).toHaveBeenCalledTimes(1));
|
||||
expect(saveArtifactViaDialogMock).toHaveBeenCalledWith('a-2', 'Doc', 'pdf');
|
||||
});
|
||||
|
||||
it('falls back to png for image kind without an extension', async () => {
|
||||
saveArtifactViaDialogMock.mockResolvedValueOnce({ ok: true, path: '/tmp/Pic.png' });
|
||||
render(
|
||||
<ArtifactCard artifact={snapshot({ artifactId: 'a-3', kind: 'image', title: 'Pic' })} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
await waitFor(() => expect(saveArtifactViaDialogMock).toHaveBeenCalledTimes(1));
|
||||
expect(saveArtifactViaDialogMock).toHaveBeenCalledWith('a-3', 'Pic', 'png');
|
||||
});
|
||||
|
||||
it('falls back to bin for the "other" kind without an extension', async () => {
|
||||
saveArtifactViaDialogMock.mockResolvedValueOnce({ ok: true, path: '/tmp/Blob.bin' });
|
||||
render(
|
||||
<ArtifactCard artifact={snapshot({ artifactId: 'a-4', kind: 'other', title: 'Blob' })} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
await waitFor(() => expect(saveArtifactViaDialogMock).toHaveBeenCalledTimes(1));
|
||||
expect(saveArtifactViaDialogMock).toHaveBeenCalledWith('a-4', 'Blob', 'bin');
|
||||
});
|
||||
|
||||
it('uses the existing extension on the title when present', async () => {
|
||||
saveArtifactViaDialogMock.mockResolvedValueOnce({ ok: true, path: '/tmp/notes.txt' });
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({ artifactId: 'a-5', kind: 'document', title: 'notes.txt' })}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
await waitFor(() => expect(saveArtifactViaDialogMock).toHaveBeenCalledTimes(1));
|
||||
expect(saveArtifactViaDialogMock).toHaveBeenCalledWith('a-5', 'notes.txt', 'txt');
|
||||
});
|
||||
|
||||
it('surfaces the service error message when download fails', async () => {
|
||||
saveArtifactViaDialogMock.mockResolvedValueOnce({ ok: false, error: 'disk full' });
|
||||
|
||||
render(<ArtifactCard artifact={snapshot()} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Download' }));
|
||||
|
||||
await screen.findByText((_, el) => el?.textContent === 'Download failed: disk full');
|
||||
// Download button stays so the user can retry.
|
||||
expect(screen.getByRole('button', { name: 'Download' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('does not call reveal when download.path is missing', async () => {
|
||||
// Synthetic edge: the success path always carries a path, but if the
|
||||
// user clicks reveal before a download ever happened the handler is
|
||||
// guarded.
|
||||
render(<ArtifactCard artifact={snapshot()} />);
|
||||
// No reveal button is rendered until state === 'done'.
|
||||
expect(screen.queryByRole('button', { name: 'Show in folder' })).not.toBeInTheDocument();
|
||||
expect(revealArtifactInFileManagerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('<ArtifactCard /> — failed state', () => {
|
||||
it('shows the producer error verbatim under the preview cap', () => {
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({
|
||||
status: 'failed',
|
||||
path: undefined,
|
||||
sizeBytes: undefined,
|
||||
error: 'producer crashed',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('producer crashed')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Generation failed/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /show more/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('truncates long errors with a Show more toggle', () => {
|
||||
const huge = 'x'.repeat(400);
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({
|
||||
status: 'failed',
|
||||
path: undefined,
|
||||
sizeBytes: undefined,
|
||||
error: huge,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
// Truncated body ends with the ellipsis suffix.
|
||||
const truncated = screen.getByText(/x…$/);
|
||||
expect(truncated).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show more' }));
|
||||
// After expand, the full string is rendered without the ellipsis.
|
||||
expect(screen.getByText(huge)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show less' }));
|
||||
// Re-collapses back to truncated form.
|
||||
expect(screen.getByText(/x…$/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Retry button when onRetry is provided', () => {
|
||||
const onRetry = vi.fn();
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({
|
||||
status: 'failed',
|
||||
path: undefined,
|
||||
sizeBytes: undefined,
|
||||
error: 'nope',
|
||||
})}
|
||||
onRetry={onRetry}
|
||||
/>
|
||||
);
|
||||
|
||||
const retry = screen.getByRole('button', { name: 'Retry' });
|
||||
fireEvent.click(retry);
|
||||
expect(onRetry).toHaveBeenCalledWith('a-1');
|
||||
});
|
||||
|
||||
it('omits the Retry button when onRetry is not provided', () => {
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({
|
||||
status: 'failed',
|
||||
path: undefined,
|
||||
sizeBytes: undefined,
|
||||
error: 'nope',
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the error block entirely when no error string is set', () => {
|
||||
render(
|
||||
<ArtifactCard
|
||||
artifact={snapshot({
|
||||
status: 'failed',
|
||||
path: undefined,
|
||||
sizeBytes: undefined,
|
||||
error: undefined,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/Generation failed/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Show more' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('<ArtifactCard /> — aria label', () => {
|
||||
it('exposes an aria-label with the artifact title', () => {
|
||||
render(<ArtifactCard artifact={snapshot({ title: 'Q3 Plan' })} />);
|
||||
expect(screen.getByRole('group', { name: /Q3 Plan/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -50,12 +50,21 @@ describe('ApprovalRequestCard', () => {
|
||||
const card = screen.getByRole('alertdialog', { name: 'Approval needed' });
|
||||
const command = screen.getByText('pip show yfinance');
|
||||
|
||||
// The real, behavioural invariant: neither the card nor the command chip
|
||||
// may use a fractional-opacity background utility (e.g.
|
||||
// `dark:bg-amber-950/40`), which would let the underlying thread text bleed
|
||||
// through the approval surface. Assert the *absence of any opacity suffix*
|
||||
// rather than a computed colour — jsdom does not apply Tailwind, so
|
||||
// getComputedStyle can't observe the background here (plan.md §3).
|
||||
const OPACITY_SUFFIX = /\bdark:bg-[^\s/]+\/\d+/;
|
||||
expect(card.className).not.toMatch(OPACITY_SUFFIX);
|
||||
expect(command.className).not.toMatch(OPACITY_SUFFIX);
|
||||
|
||||
// Deliberate, labeled visual-regression lock on the opaque surface tokens —
|
||||
// update these only on an intentional restyle of the approval card.
|
||||
expect(card).toHaveClass('bg-amber-50');
|
||||
expect(card).toHaveClass('dark:bg-amber-950');
|
||||
expect(card).not.toHaveClass('bg-amber/5');
|
||||
expect(card.className).not.toMatch(/\bdark:bg-[^\s/]+\/\d+/);
|
||||
expect(command).toHaveClass('dark:bg-surface-canvas');
|
||||
expect(command.className).not.toMatch(/\bdark:bg-[^\s/]+\/\d+/);
|
||||
});
|
||||
|
||||
it('does not nudge the user to reply yes/no (buttons are the input path)', () => {
|
||||
|
||||
@@ -232,10 +232,12 @@ describe('ArtifactCard', () => {
|
||||
// ─── kind variants (icon paths) ─────────────────────────────────────────
|
||||
|
||||
it.each(['presentation', 'document', 'image', 'other'] as const)(
|
||||
'renders the in-progress spinner for kind=%s without crashing',
|
||||
'renders the in-progress spinner with the kind-specific label for kind=%s',
|
||||
kind => {
|
||||
render(<ArtifactCard artifact={inProgress({ kind })} />);
|
||||
expect(screen.getByText(/Generating /)).toBeInTheDocument();
|
||||
// The generating sub-label reflects the artifact kind (e.g. "Generating
|
||||
// image") — folded in from the former sibling ArtifactCard.test.tsx.
|
||||
expect(screen.getByText(new RegExp(`Generating ${kind}`, 'i'))).toBeInTheDocument();
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { screen } from '@testing-library/react';
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import { renderWithProviders } from '../../../../test/test-utils';
|
||||
import { useSettingsNavigation } from '../useSettingsNavigation';
|
||||
|
||||
/** Renders breadcrumb labels so we can assert on the hook output. */
|
||||
const BreadcrumbProbe = () => {
|
||||
const { breadcrumbs } = useSettingsNavigation();
|
||||
return <div data-testid="breadcrumbs">{breadcrumbs.map(b => b.label).join(' > ')}</div>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The two-pane settings restructure retired breadcrumb navigation — the sidebar
|
||||
* replaced the trail, so `breadcrumbs` is now always empty regardless of route.
|
||||
* The field is retained (always []) so the many panel call sites keep compiling.
|
||||
* Route resolution itself is covered in useSettingsNavigation.coverage.test.tsx.
|
||||
*/
|
||||
describe('useSettingsNavigation breadcrumbs (retired — always empty)', () => {
|
||||
const routes = [
|
||||
'/settings',
|
||||
'/settings/notifications',
|
||||
'/settings/tasks',
|
||||
'/settings/developer-options',
|
||||
'/settings/personality',
|
||||
'/settings/recovery-phrase',
|
||||
'/settings/wallet-balances',
|
||||
'/settings/notification-routing',
|
||||
];
|
||||
|
||||
test.each(routes)('breadcrumbs are empty for %s', route => {
|
||||
renderWithProviders(<BreadcrumbProbe />, { initialEntries: [route] });
|
||||
expect(screen.getByTestId('breadcrumbs')).toHaveTextContent('');
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import RecoveryPhrasePanel from './RecoveryPhrasePanel';
|
||||
|
||||
const navigateBackMock = vi.fn();
|
||||
|
||||
vi.mock('../hooks/useSettingsNavigation', () => ({
|
||||
useSettingsNavigation: () => ({ navigateBack: navigateBackMock, breadcrumbs: [] }),
|
||||
}));
|
||||
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) }));
|
||||
|
||||
vi.mock('../../../providers/CoreStateProvider', () => ({
|
||||
useCoreState: () => ({
|
||||
snapshot: { currentUser: { id: 'test-user', publicKey: 'test-pubkey' } },
|
||||
setEncryptionKey: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/SettingsHeader', () => ({
|
||||
default: ({ title, description }: { title: string; description?: string }) => (
|
||||
<div data-testid="settings-header">
|
||||
{title} - {description}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../../../features/wallet/setupLocalWalletFromMnemonic', () => ({
|
||||
persistLocalWalletFromMnemonic: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fetchWalletStatus to return unconfigured by default (no existing wallet).
|
||||
vi.mock('../../../services/walletApi', () => ({
|
||||
fetchWalletStatus: vi.fn(async () => ({
|
||||
configured: false,
|
||||
onboardingCompleted: false,
|
||||
consentGranted: false,
|
||||
secretStored: false,
|
||||
source: null,
|
||||
mnemonicWordCount: null,
|
||||
accounts: [],
|
||||
updatedAtMs: null,
|
||||
})),
|
||||
setupLocalWallet: vi.fn(async () => ({
|
||||
configured: true,
|
||||
onboardingCompleted: true,
|
||||
consentGranted: true,
|
||||
secretStored: true,
|
||||
source: 'generated',
|
||||
mnemonicWordCount: 12,
|
||||
accounts: [],
|
||||
updatedAtMs: Date.now(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('<RecoveryPhrasePanel />', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('initially hides the recovery phrase and reveals it when clicking the reveal button', async () => {
|
||||
render(<RecoveryPhrasePanel />);
|
||||
|
||||
// Wait for wallet status check to complete and enter generate mode
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByLabelText('mnemonic.revealPhrase')).toBeInTheDocument()
|
||||
);
|
||||
|
||||
const copyButton = screen.getByText('mnemonic.copyToClipboard').closest('button')!;
|
||||
expect(copyButton).toBeDisabled();
|
||||
|
||||
const revealButton = screen.getByLabelText('mnemonic.revealPhrase');
|
||||
expect(revealButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(revealButton);
|
||||
|
||||
expect(screen.queryByLabelText('mnemonic.revealPhrase')).not.toBeInTheDocument();
|
||||
expect(copyButton).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ const WalkthroughTooltip = ({
|
||||
{/* Progress bar — thin, smooth fill */}
|
||||
<div className="h-1 bg-surface-subtle">
|
||||
<div
|
||||
data-testid="walkthrough-progress-bar"
|
||||
className="h-full bg-gradient-to-r from-[#2F6EF4] to-[#5B9BF3] transition-all duration-500 ease-out rounded-r-full"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
|
||||
@@ -504,26 +504,23 @@ describe('WalkthroughTooltip', () => {
|
||||
});
|
||||
|
||||
it('renders progress bar', () => {
|
||||
const { container } = render(
|
||||
<WalkthroughTooltip {...makeTooltipProps({ index: 2, size: 9 })} />
|
||||
);
|
||||
render(<WalkthroughTooltip {...makeTooltipProps({ index: 2, size: 9 })} />);
|
||||
|
||||
// Gradient progress bar fills based on step progress (3/9 ≈ 33.33%)
|
||||
const bar = container.querySelector('div.bg-gradient-to-r');
|
||||
expect(bar).not.toBeNull();
|
||||
// width rounds to ~33.33% for step 3 of 9
|
||||
expect(bar?.getAttribute('style')).toMatch(/width:\s*33\.3/);
|
||||
// Query the progress bar by its stable test id rather than a presentational
|
||||
// Tailwind class (plan.md §3). The real signal is the computed fill width:
|
||||
// step 3 of 9 ≈ 33.33%.
|
||||
const bar = screen.getByTestId('walkthrough-progress-bar');
|
||||
expect(bar.getAttribute('style')).toMatch(/width:\s*33\.3/);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createWalkthroughSteps tests ──────────────────────────────────────────
|
||||
|
||||
describe('createWalkthroughSteps', () => {
|
||||
it('returns 13 steps', () => {
|
||||
const navigate = vi.fn();
|
||||
const steps = createWalkthroughSteps(navigate);
|
||||
expect(steps).toHaveLength(13);
|
||||
});
|
||||
// NOTE: the brittle `returns 13 steps` magic-count test was removed
|
||||
// (plan.md §3) — it broke on any intentional add/remove of a walkthrough
|
||||
// step. The first-/last-step target tests below carry the real ordering
|
||||
// signal, and `all steps have a title and content` guards each entry.
|
||||
|
||||
it('first step targets home-card', () => {
|
||||
const navigate = vi.fn();
|
||||
|
||||
@@ -163,25 +163,26 @@ describe('useDaemonLifecycle', () => {
|
||||
const uid = freshUser('stable-effect');
|
||||
resetUser(uid);
|
||||
setAutoStartEnabled(uid, true);
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
// Observe the visibilitychange listener registration directly — it is
|
||||
// the real proxy for "the lifecycle effect ran exactly once and cleaned
|
||||
// up exactly once". (Previously this also pinned exact console.log
|
||||
// strings as an effect-rerun proxy; those are brittle to copy edits and
|
||||
// the listener counts already carry the signal — plan.md §3.)
|
||||
const addEventListenerSpy = vi.spyOn(document, 'addEventListener');
|
||||
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener');
|
||||
|
||||
try {
|
||||
const { unmount } = renderHook(() => useDaemonLifecycle(uid));
|
||||
|
||||
const lifecycleLogCount = (message: string) =>
|
||||
logSpy.mock.calls.filter(([logged]) => logged === message).length;
|
||||
const visibilityAddCount = () =>
|
||||
addEventListenerSpy.mock.calls.filter(([event]) => event === 'visibilitychange').length;
|
||||
const visibilityRemoveCount = () =>
|
||||
removeEventListenerSpy.mock.calls.filter(([event]) => event === 'visibilitychange')
|
||||
.length;
|
||||
|
||||
expect(lifecycleLogCount('[DaemonLifecycle] Setting up daemon lifecycle management')).toBe(
|
||||
1
|
||||
);
|
||||
// Effect ran once: one listener added, none removed yet.
|
||||
expect(visibilityAddCount()).toBe(1);
|
||||
expect(visibilityRemoveCount()).toBe(0);
|
||||
|
||||
act(() => {
|
||||
setDaemonStatus(uid, 'starting');
|
||||
@@ -191,23 +192,16 @@ describe('useDaemonLifecycle', () => {
|
||||
setIsRecovering(uid, false);
|
||||
});
|
||||
|
||||
expect(lifecycleLogCount('[DaemonLifecycle] Setting up daemon lifecycle management')).toBe(
|
||||
1
|
||||
);
|
||||
expect(lifecycleLogCount('[DaemonLifecycle] Cleaning up daemon lifecycle management')).toBe(
|
||||
0
|
||||
);
|
||||
// Daemon-state churn must NOT re-run the effect: still one add, no
|
||||
// teardown.
|
||||
expect(visibilityAddCount()).toBe(1);
|
||||
expect(visibilityRemoveCount()).toBe(0);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(lifecycleLogCount('[DaemonLifecycle] Cleaning up daemon lifecycle management')).toBe(
|
||||
1
|
||||
);
|
||||
// Unmount tears the single listener down exactly once.
|
||||
expect(visibilityRemoveCount()).toBe(1);
|
||||
} finally {
|
||||
logSpy.mockRestore();
|
||||
addEventListenerSpy.mockRestore();
|
||||
removeEventListenerSpy.mockRestore();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { GraphRelation } from '../../utils/tauriCommands/memory';
|
||||
import { connectionPathApi, loadGraph, loadNamespaces } from './connectionPathApi';
|
||||
import { loadGraph, loadNamespaces } from './connectionPathApi';
|
||||
|
||||
const mockGraphQuery = vi.fn();
|
||||
const mockListNamespaces = vi.fn();
|
||||
@@ -63,10 +63,3 @@ describe('connectionPathApi.loadNamespaces', () => {
|
||||
expect(await loadNamespaces()).toEqual(['work', 'personal']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('connectionPathApi object', () => {
|
||||
it('exposes the public surface', () => {
|
||||
expect(typeof connectionPathApi.loadGraph).toBe('function');
|
||||
expect(typeof connectionPathApi.loadNamespaces).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { computeEntityAssociations } from '../../lib/memory/entityAssociations';
|
||||
import type { GraphRelation } from '../../utils/tauriCommands/memory';
|
||||
import { entityAssociationsApi, loadAssociations, loadNamespaces } from './entityAssociationsApi';
|
||||
import { loadAssociations, loadNamespaces } from './entityAssociationsApi';
|
||||
|
||||
const mockGraphQuery = vi.fn();
|
||||
const mockListNamespaces = vi.fn();
|
||||
@@ -63,10 +63,3 @@ describe('entityAssociationsApi.loadNamespaces', () => {
|
||||
expect(await loadNamespaces()).toEqual(['work', 'personal']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('entityAssociationsApi object', () => {
|
||||
it('exposes the public surface', () => {
|
||||
expect(typeof entityAssociationsApi.loadAssociations).toBe('function');
|
||||
expect(typeof entityAssociationsApi.loadNamespaces).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { computeGraphCentrality } from '../../lib/memory/graphCentrality';
|
||||
import type { GraphRelation } from '../../utils/tauriCommands/memory';
|
||||
import { graphCentralityApi, loadCentrality, loadNamespaces } from './graphCentralityApi';
|
||||
import { loadCentrality, loadNamespaces } from './graphCentralityApi';
|
||||
|
||||
const mockGraphQuery = vi.fn();
|
||||
const mockListNamespaces = vi.fn();
|
||||
@@ -64,10 +64,3 @@ describe('graphCentralityApi.loadNamespaces', () => {
|
||||
expect(await loadNamespaces()).toEqual(['work', 'personal']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('graphCentralityApi object', () => {
|
||||
it('exposes the public surface', () => {
|
||||
expect(typeof graphCentralityApi.loadCentrality).toBe('function');
|
||||
expect(typeof graphCentralityApi.loadNamespaces).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { computeGraphCohesion } from '../../lib/memory/graphCohesion';
|
||||
import type { GraphRelation } from '../../utils/tauriCommands/memory';
|
||||
import { graphCohesionApi, loadCohesion, loadNamespaces } from './graphCohesionApi';
|
||||
import { loadCohesion, loadNamespaces } from './graphCohesionApi';
|
||||
|
||||
const mockGraphQuery = vi.fn();
|
||||
const mockListNamespaces = vi.fn();
|
||||
@@ -65,10 +65,3 @@ describe('graphCohesionApi.loadNamespaces', () => {
|
||||
expect(await loadNamespaces()).toEqual(['work', 'personal']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('graphCohesionApi object', () => {
|
||||
it('exposes the public surface', () => {
|
||||
expect(typeof graphCohesionApi.loadCohesion).toBe('function');
|
||||
expect(typeof graphCohesionApi.loadNamespaces).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { computeFreshness } from '../../lib/memory/memoryFreshness';
|
||||
import { NOW, rel } from '../../test/memoryRelationFactory';
|
||||
import { loadFreshness, loadNamespaces, memoryFreshnessApi } from './memoryFreshnessApi';
|
||||
import { loadFreshness, loadNamespaces } from './memoryFreshnessApi';
|
||||
|
||||
const mockGraphQuery = vi.fn();
|
||||
const mockListNamespaces = vi.fn();
|
||||
@@ -48,10 +48,3 @@ describe('memoryFreshnessApi.loadNamespaces', () => {
|
||||
expect(await loadNamespaces()).toEqual(['work', 'personal']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoryFreshnessApi object', () => {
|
||||
it('exposes the public surface', () => {
|
||||
expect(typeof memoryFreshnessApi.loadFreshness).toBe('function');
|
||||
expect(typeof memoryFreshnessApi.loadNamespaces).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { computeTimeline } from '../../lib/memory/memoryTimeline';
|
||||
import type { GraphRelation } from '../../utils/tauriCommands/memory';
|
||||
import { loadNamespaces, loadTimeline, memoryTimelineApi } from './memoryTimelineApi';
|
||||
import { loadNamespaces, loadTimeline } from './memoryTimelineApi';
|
||||
|
||||
const mockGraphQuery = vi.fn();
|
||||
const mockListNamespaces = vi.fn();
|
||||
@@ -65,10 +65,3 @@ describe('memoryTimelineApi.loadNamespaces', () => {
|
||||
expect(await loadNamespaces()).toEqual(['work', 'personal']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memoryTimelineApi object', () => {
|
||||
it('exposes the public surface', () => {
|
||||
expect(typeof memoryTimelineApi.loadTimeline).toBe('function');
|
||||
expect(typeof memoryTimelineApi.loadNamespaces).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { computeNamespaceOverview } from '../../lib/memory/namespaceOverview';
|
||||
import type { GraphRelation } from '../../utils/tauriCommands/memory';
|
||||
import { loadNamespaceOverview, namespaceOverviewApi } from './namespaceOverviewApi';
|
||||
import { loadNamespaceOverview } from './namespaceOverviewApi';
|
||||
|
||||
const mockGraphQuery = vi.fn();
|
||||
|
||||
@@ -49,9 +49,3 @@ describe('namespaceOverviewApi.loadNamespaceOverview', () => {
|
||||
await expect(loadNamespaceOverview()).rejects.toThrow('graph unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('namespaceOverviewApi object', () => {
|
||||
it('exposes the public surface', () => {
|
||||
expect(typeof namespaceOverviewApi.loadNamespaceOverview).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* Table-driven Composio connector contract (plan.md §2.2).
|
||||
*
|
||||
* The per-toolkit connector specs (Airtable, Asana, ClickUp, …) were 11
|
||||
* byte-identical WDIO files differing only in a handful of toolkit strings.
|
||||
* `runConnectorContract` factors the whole `describe` block out so each toolkit
|
||||
* is one `ConnectorContractConfig` row in the single contract spec. Toolkits
|
||||
* with bespoke UI/behaviour (Jira's subdomain field, Gmail's 400-on-fetch)
|
||||
* keep their own dedicated specs.
|
||||
*/
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
import { waitForApp } from './app-helpers';
|
||||
import {
|
||||
assertConnectorCardVisible,
|
||||
assertModalPhase,
|
||||
assertSessionNotNuked,
|
||||
injectComposioFault,
|
||||
openConnectorModal,
|
||||
seedComposioConnection,
|
||||
seedComposioToolkits,
|
||||
} from './composio-helpers';
|
||||
import { callOpenhumanRpc } from './core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from './deep-link-helpers';
|
||||
import { textExists, waitForText, waitForWebView, waitForWindowVisible } from './element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from './shared-flows';
|
||||
|
||||
export interface ConnectorContractConfig {
|
||||
/** Human-facing connector name as rendered in the UI (e.g. "Google Calendar"). */
|
||||
name: string;
|
||||
/** Composio toolkit slug (e.g. "googlecalendar"). */
|
||||
slug: string;
|
||||
/** Connection-id stem; the contract derives `${idBase}-1|-fail|-expired`. */
|
||||
idBase: string;
|
||||
/** A representative Composio action for the execute-routing case. */
|
||||
executeAction: string;
|
||||
/** Optional auth-deep-link token override (defaults to `e2e-connector-<slug>-token`). */
|
||||
authToken?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the shared Composio connector contract for one toolkit. Call once
|
||||
* per toolkit from the contract spec; each invocation is a self-contained
|
||||
* `describe` with its own mock lifecycle (mirrors the former per-file specs).
|
||||
*/
|
||||
export function runConnectorContract(config: ConnectorContractConfig): void {
|
||||
const { name, slug, idBase, executeAction } = config;
|
||||
const authToken = config.authToken ?? `e2e-connector-${slug}-token`;
|
||||
const activeId = `${idBase}-1`;
|
||||
const failId = `${idBase}-fail`;
|
||||
const expiredId = `${idBase}-expired`;
|
||||
const LOG = `[Connector:${name}]`;
|
||||
|
||||
describe(`${name} Composio connector flow`, () => {
|
||||
before(async function () {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
seedComposioToolkits([slug]);
|
||||
seedComposioConnection(slug, 'ACTIVE', activeId);
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass(authToken);
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await completeOnboardingIfVisible(LOG);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetMockBehavior();
|
||||
seedComposioToolkits([slug]);
|
||||
seedComposioConnection(slug, 'ACTIVE', activeId);
|
||||
});
|
||||
|
||||
it('card is visible and selectable', async function () {
|
||||
this.timeout(60_000);
|
||||
await assertConnectorCardVisible(name);
|
||||
console.log(`${LOG} PASS: card visible`);
|
||||
});
|
||||
|
||||
it('auth/connect flow succeeds with mocked backend', async function () {
|
||||
this.timeout(60_000);
|
||||
clearRequestLog();
|
||||
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: slug });
|
||||
expect(out.ok).toBe(true);
|
||||
const authReq = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/composio/authorize')
|
||||
);
|
||||
expect(authReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: auth/connect routed`);
|
||||
});
|
||||
|
||||
it('connected state persists after reconnect/reload', async function () {
|
||||
this.timeout(60_000);
|
||||
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
|
||||
expect(out.ok).toBe(true);
|
||||
const result = (out.result as { result?: unknown })?.result ?? out.result;
|
||||
const connections = (result as { connections?: unknown[] })?.connections ?? [];
|
||||
const hit = (connections as { toolkit?: string; status?: string }[]).find(
|
||||
c => c.toolkit?.toLowerCase() === slug
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.status).toBe('ACTIVE');
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
// Renamed from the misleading "composio_sync RPC routes to mock backend"
|
||||
// (plan.md §3): composio_sync short-circuits with no HTTP for connectors
|
||||
// without a native provider, so the real, verifiable contract is that the
|
||||
// call does not tear down the WebDriver session.
|
||||
it('composio_sync does not tear down the session', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: slug });
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: sync does not nuke session`);
|
||||
});
|
||||
|
||||
it('composio_execute routes a basic task', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: activeId,
|
||||
action: executeAction,
|
||||
params: {},
|
||||
});
|
||||
console.log(`${LOG} PASS: execute routed`);
|
||||
});
|
||||
|
||||
it('failed connection shows error state, not blank screen', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(slug, 'FAILED', failId);
|
||||
await navigateToSkills();
|
||||
await waitForText(name, 10_000);
|
||||
expect(await textExists(name)).toBe(true);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: failed state does not blank screen`);
|
||||
});
|
||||
|
||||
it('expired auth shows Reconnect button and does not log user out', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(slug, 'EXPIRED', expiredId);
|
||||
await navigateToSkills();
|
||||
await waitForText(name, 10_000);
|
||||
const modal = await openConnectorModal(name, 15_000, 'Auth expired');
|
||||
expect(modal).toBeTruthy();
|
||||
await assertModalPhase('expired', name);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: expired auth does not log user out`);
|
||||
});
|
||||
|
||||
it('unrelated 400 on composio route does not nuke session', async function () {
|
||||
this.timeout(60_000);
|
||||
injectComposioFault(400);
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: activeId,
|
||||
action: executeAction,
|
||||
params: {},
|
||||
});
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: unrelated 400 error does not nuke session`);
|
||||
});
|
||||
|
||||
it('disconnect flow removes connection', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(slug, 'ACTIVE', activeId);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_delete_connection', { connection_id: activeId });
|
||||
const deleteReq = getRequestLog().find(
|
||||
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
|
||||
);
|
||||
expect(deleteReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: disconnect routed DELETE`);
|
||||
await assertSessionNotNuked();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* E2E: Airtable (Composio) connector flow.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import {
|
||||
assertConnectorCardVisible,
|
||||
assertModalPhase,
|
||||
assertSessionNotNuked,
|
||||
injectComposioFault,
|
||||
openConnectorModal,
|
||||
seedComposioConnection,
|
||||
seedComposioToolkits,
|
||||
} from '../helpers/composio-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
const LOG = '[ConnectorAirtableE2E]';
|
||||
const CONNECTOR_NAME = 'Airtable';
|
||||
const TOOLKIT_SLUG = 'airtable';
|
||||
const AUTH_TOKEN = 'e2e-connector-airtable-token';
|
||||
|
||||
describe('Airtable Composio connector flow', () => {
|
||||
before(async function () {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-airtable-1');
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await completeOnboardingIfVisible(LOG);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetMockBehavior();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-airtable-1');
|
||||
});
|
||||
|
||||
it('card is visible and selectable', async function () {
|
||||
this.timeout(60_000);
|
||||
await assertConnectorCardVisible(CONNECTOR_NAME);
|
||||
console.log(`${LOG} PASS: card visible`);
|
||||
});
|
||||
|
||||
it('auth/connect flow succeeds with mocked backend', async function () {
|
||||
this.timeout(60_000);
|
||||
clearRequestLog();
|
||||
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
|
||||
expect(out.ok).toBe(true);
|
||||
const authReq = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/composio/authorize')
|
||||
);
|
||||
expect(authReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: auth/connect routed`);
|
||||
});
|
||||
|
||||
it('connected state persists after reconnect/reload', async function () {
|
||||
this.timeout(60_000);
|
||||
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
|
||||
expect(out.ok).toBe(true);
|
||||
const result = (out.result as { result?: unknown })?.result ?? out.result;
|
||||
const connections = (result as { connections?: unknown[] })?.connections ?? [];
|
||||
const hit = (connections as { toolkit?: string; status?: string }[]).find(
|
||||
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.status).toBe('ACTIVE');
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
// syncReq URL check removed — composio_sync does no HTTP for
|
||||
// connectors without a native provider (the RPC short-circuits). The
|
||||
// assertSessionNotNuked() below covers the real intent: the call
|
||||
// does not tear down the WebDriver session.
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: sync does not nuke session`);
|
||||
});
|
||||
|
||||
it('composio_execute routes a basic task', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-airtable-1',
|
||||
action: 'AIRTABLE_LIST_BASES',
|
||||
params: {},
|
||||
});
|
||||
// execReq URL check removed (see composio_sync comment above).
|
||||
console.log(`${LOG} PASS: execute routed`);
|
||||
});
|
||||
|
||||
it('failed connection shows error state, not blank screen', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-airtable-fail');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
expect(await textExists(CONNECTOR_NAME)).toBe(true);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: failed state does not blank screen`);
|
||||
});
|
||||
|
||||
it('expired auth shows Reconnect button and does not log user out', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-airtable-expired');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
|
||||
expect(modal).toBeTruthy();
|
||||
await assertModalPhase('expired', CONNECTOR_NAME);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: expired auth does not log user out`);
|
||||
});
|
||||
|
||||
it('unrelated 401 on composio route does not nuke session', async function () {
|
||||
this.timeout(60_000);
|
||||
injectComposioFault(400);
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-airtable-1',
|
||||
action: 'AIRTABLE_LIST_BASES',
|
||||
params: {},
|
||||
});
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: 401-class error does not nuke session`);
|
||||
});
|
||||
|
||||
it('disconnect flow removes connection', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-airtable-1');
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_delete_connection', {
|
||||
connection_id: 'c-airtable-1',
|
||||
});
|
||||
const deleteReq = getRequestLog().find(
|
||||
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
|
||||
);
|
||||
expect(deleteReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: disconnect routed DELETE`);
|
||||
await assertSessionNotNuked();
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* E2E: Asana (Composio) connector flow.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import {
|
||||
assertConnectorCardVisible,
|
||||
assertModalPhase,
|
||||
assertSessionNotNuked,
|
||||
injectComposioFault,
|
||||
openConnectorModal,
|
||||
seedComposioConnection,
|
||||
seedComposioToolkits,
|
||||
} from '../helpers/composio-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
const LOG = '[ConnectorAsanaE2E]';
|
||||
const CONNECTOR_NAME = 'Asana';
|
||||
const TOOLKIT_SLUG = 'asana';
|
||||
const AUTH_TOKEN = 'e2e-connector-asana-token';
|
||||
|
||||
describe('Asana Composio connector flow', () => {
|
||||
before(async function () {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-asana-1');
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await completeOnboardingIfVisible(LOG);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetMockBehavior();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-asana-1');
|
||||
});
|
||||
|
||||
it('card is visible and selectable', async function () {
|
||||
this.timeout(60_000);
|
||||
await assertConnectorCardVisible(CONNECTOR_NAME);
|
||||
console.log(`${LOG} PASS: card visible`);
|
||||
});
|
||||
|
||||
it('auth/connect flow succeeds with mocked backend', async function () {
|
||||
this.timeout(60_000);
|
||||
clearRequestLog();
|
||||
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
|
||||
expect(out.ok).toBe(true);
|
||||
const authReq = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/composio/authorize')
|
||||
);
|
||||
expect(authReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: auth/connect routed`);
|
||||
});
|
||||
|
||||
it('connected state persists after reconnect/reload', async function () {
|
||||
this.timeout(60_000);
|
||||
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
|
||||
expect(out.ok).toBe(true);
|
||||
const result = (out.result as { result?: unknown })?.result ?? out.result;
|
||||
const connections = (result as { connections?: unknown[] })?.connections ?? [];
|
||||
const hit = (connections as { toolkit?: string; status?: string }[]).find(
|
||||
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.status).toBe('ACTIVE');
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
// syncReq URL check removed — composio_sync does no HTTP for
|
||||
// connectors without a native provider (the RPC short-circuits). The
|
||||
// assertSessionNotNuked() below covers the real intent: the call
|
||||
// does not tear down the WebDriver session.
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: sync does not nuke session`);
|
||||
});
|
||||
|
||||
it('composio_execute routes a basic task', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-asana-1',
|
||||
action: 'ASANA_LIST_TASKS',
|
||||
params: {},
|
||||
});
|
||||
// execReq URL check removed (see composio_sync comment above).
|
||||
console.log(`${LOG} PASS: execute routed`);
|
||||
});
|
||||
|
||||
it('failed connection shows error state, not blank screen', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-asana-fail');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
expect(await textExists(CONNECTOR_NAME)).toBe(true);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: failed state does not blank screen`);
|
||||
});
|
||||
|
||||
it('expired auth shows Reconnect button and does not log user out', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-asana-expired');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
|
||||
expect(modal).toBeTruthy();
|
||||
await assertModalPhase('expired', CONNECTOR_NAME);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: expired auth does not log user out`);
|
||||
});
|
||||
|
||||
it('unrelated 401 on composio route does not nuke session', async function () {
|
||||
this.timeout(60_000);
|
||||
injectComposioFault(400);
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-asana-1',
|
||||
action: 'ASANA_LIST_TASKS',
|
||||
params: {},
|
||||
});
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: 401-class error does not nuke session`);
|
||||
});
|
||||
|
||||
it('disconnect flow removes connection', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-asana-1');
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_delete_connection', { connection_id: 'c-asana-1' });
|
||||
const deleteReq = getRequestLog().find(
|
||||
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
|
||||
);
|
||||
expect(deleteReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: disconnect routed DELETE`);
|
||||
await assertSessionNotNuked();
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* E2E: ClickUp (Composio) connector flow.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import {
|
||||
assertConnectorCardVisible,
|
||||
assertModalPhase,
|
||||
assertSessionNotNuked,
|
||||
injectComposioFault,
|
||||
openConnectorModal,
|
||||
seedComposioConnection,
|
||||
seedComposioToolkits,
|
||||
} from '../helpers/composio-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
const LOG = '[ConnectorClickUpE2E]';
|
||||
const CONNECTOR_NAME = 'ClickUp';
|
||||
const TOOLKIT_SLUG = 'clickup';
|
||||
const AUTH_TOKEN = 'e2e-connector-clickup-token';
|
||||
|
||||
describe('ClickUp Composio connector flow', () => {
|
||||
before(async function () {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-clickup-1');
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await completeOnboardingIfVisible(LOG);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetMockBehavior();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-clickup-1');
|
||||
});
|
||||
|
||||
it('card is visible and selectable', async function () {
|
||||
this.timeout(60_000);
|
||||
await assertConnectorCardVisible(CONNECTOR_NAME);
|
||||
console.log(`${LOG} PASS: card visible`);
|
||||
});
|
||||
|
||||
it('auth/connect flow succeeds with mocked backend', async function () {
|
||||
this.timeout(60_000);
|
||||
clearRequestLog();
|
||||
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
|
||||
expect(out.ok).toBe(true);
|
||||
const authReq = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/composio/authorize')
|
||||
);
|
||||
expect(authReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: auth/connect routed`);
|
||||
});
|
||||
|
||||
it('connected state persists after reconnect/reload', async function () {
|
||||
this.timeout(60_000);
|
||||
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
|
||||
expect(out.ok).toBe(true);
|
||||
const result = (out.result as { result?: unknown })?.result ?? out.result;
|
||||
const connections = (result as { connections?: unknown[] })?.connections ?? [];
|
||||
const hit = (connections as { toolkit?: string; status?: string }[]).find(
|
||||
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.status).toBe('ACTIVE');
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
// syncReq URL check removed — composio_sync does no HTTP for
|
||||
// connectors without a native provider (the RPC short-circuits). The
|
||||
// assertSessionNotNuked() below covers the real intent: the call
|
||||
// does not tear down the WebDriver session.
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: sync does not nuke session`);
|
||||
});
|
||||
|
||||
it('composio_execute routes a basic task', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-clickup-1',
|
||||
action: 'CLICKUP_LIST_TASKS',
|
||||
params: {},
|
||||
});
|
||||
// execReq URL check removed (see composio_sync comment above).
|
||||
console.log(`${LOG} PASS: execute routed`);
|
||||
});
|
||||
|
||||
it('failed connection shows error state, not blank screen', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-clickup-fail');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
expect(await textExists(CONNECTOR_NAME)).toBe(true);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: failed state does not blank screen`);
|
||||
});
|
||||
|
||||
it('expired auth shows Reconnect button and does not log user out', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-clickup-expired');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
|
||||
expect(modal).toBeTruthy();
|
||||
await assertModalPhase('expired', CONNECTOR_NAME);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: expired auth does not log user out`);
|
||||
});
|
||||
|
||||
it('unrelated 401 on composio route does not nuke session', async function () {
|
||||
this.timeout(60_000);
|
||||
injectComposioFault(400);
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-clickup-1',
|
||||
action: 'CLICKUP_LIST_TASKS',
|
||||
params: {},
|
||||
});
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: 401-class error does not nuke session`);
|
||||
});
|
||||
|
||||
it('disconnect flow removes connection', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-clickup-1');
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_delete_connection', {
|
||||
connection_id: 'c-clickup-1',
|
||||
});
|
||||
const deleteReq = getRequestLog().find(
|
||||
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
|
||||
);
|
||||
expect(deleteReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: disconnect routed DELETE`);
|
||||
await assertSessionNotNuked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* E2E: Composio connector contract — one table-driven spec covering the
|
||||
* toolkits that share the identical connect/sync/execute/disconnect flow
|
||||
* (plan.md §2.2). Collapses 11 byte-identical connector-*.spec.ts files.
|
||||
*
|
||||
* Bespoke connectors keep their own specs:
|
||||
* - connector-jira.spec.ts (subdomain-field UI)
|
||||
* - connector-gmail-composio.spec.ts (400-on-fetch-emails handling)
|
||||
*/
|
||||
import { type ConnectorContractConfig, runConnectorContract } from '../helpers/connector-contract';
|
||||
|
||||
const TOOLKITS: ConnectorContractConfig[] = [
|
||||
{
|
||||
name: 'Airtable',
|
||||
slug: 'airtable',
|
||||
idBase: 'c-airtable',
|
||||
executeAction: 'AIRTABLE_LIST_BASES',
|
||||
},
|
||||
{ name: 'Asana', slug: 'asana', idBase: 'c-asana', executeAction: 'ASANA_LIST_TASKS' },
|
||||
{ name: 'ClickUp', slug: 'clickup', idBase: 'c-clickup', executeAction: 'CLICKUP_LIST_TASKS' },
|
||||
{
|
||||
name: 'Confluence',
|
||||
slug: 'confluence',
|
||||
idBase: 'c-confluence',
|
||||
executeAction: 'CONFLUENCE_LIST_PAGES',
|
||||
},
|
||||
{
|
||||
name: 'Google Calendar',
|
||||
slug: 'googlecalendar',
|
||||
idBase: 'c-gcal',
|
||||
executeAction: 'GOOGLECALENDAR_LIST_EVENTS',
|
||||
},
|
||||
{
|
||||
name: 'Google Drive',
|
||||
slug: 'googledrive',
|
||||
idBase: 'c-gdrive',
|
||||
executeAction: 'GOOGLEDRIVE_LIST_FILES',
|
||||
},
|
||||
{
|
||||
name: 'Google Sheets',
|
||||
slug: 'googlesheets',
|
||||
idBase: 'c-gsheets',
|
||||
executeAction: 'GOOGLESHEETS_LIST_SPREADSHEETS',
|
||||
},
|
||||
{ name: 'Notion', slug: 'notion', idBase: 'c-notion', executeAction: 'NOTION_LIST_PAGES' },
|
||||
{ name: 'Slack', slug: 'slack', idBase: 'c-slack', executeAction: 'SLACK_LIST_CHANNELS' },
|
||||
{ name: 'Todoist', slug: 'todoist', idBase: 'c-todoist', executeAction: 'TODOIST_LIST_PROJECTS' },
|
||||
{
|
||||
name: 'YouTube',
|
||||
slug: 'youtube',
|
||||
idBase: 'c-youtube',
|
||||
executeAction: 'YOUTUBE_LIST_PLAYLISTS',
|
||||
},
|
||||
];
|
||||
|
||||
for (const toolkit of TOOLKITS) {
|
||||
runConnectorContract(toolkit);
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* E2E: Confluence (Composio) connector flow.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import {
|
||||
assertConnectorCardVisible,
|
||||
assertModalPhase,
|
||||
assertSessionNotNuked,
|
||||
injectComposioFault,
|
||||
openConnectorModal,
|
||||
seedComposioConnection,
|
||||
seedComposioToolkits,
|
||||
} from '../helpers/composio-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
const LOG = '[ConnectorConfluenceE2E]';
|
||||
const CONNECTOR_NAME = 'Confluence';
|
||||
const TOOLKIT_SLUG = 'confluence';
|
||||
const AUTH_TOKEN = 'e2e-connector-confluence-token';
|
||||
|
||||
describe('Confluence Composio connector flow', () => {
|
||||
before(async function () {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-confluence-1');
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await completeOnboardingIfVisible(LOG);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetMockBehavior();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-confluence-1');
|
||||
});
|
||||
|
||||
it('card is visible and selectable', async function () {
|
||||
this.timeout(60_000);
|
||||
await assertConnectorCardVisible(CONNECTOR_NAME);
|
||||
console.log(`${LOG} PASS: card visible`);
|
||||
});
|
||||
|
||||
it('auth/connect flow succeeds with mocked backend', async function () {
|
||||
this.timeout(60_000);
|
||||
clearRequestLog();
|
||||
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
|
||||
expect(out.ok).toBe(true);
|
||||
const authReq = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/composio/authorize')
|
||||
);
|
||||
expect(authReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: auth/connect routed`);
|
||||
});
|
||||
|
||||
it('connected state persists after reconnect/reload', async function () {
|
||||
this.timeout(60_000);
|
||||
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
|
||||
expect(out.ok).toBe(true);
|
||||
const result = (out.result as { result?: unknown })?.result ?? out.result;
|
||||
const connections = (result as { connections?: unknown[] })?.connections ?? [];
|
||||
const hit = (connections as { toolkit?: string; status?: string }[]).find(
|
||||
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.status).toBe('ACTIVE');
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
// syncReq URL check removed — composio_sync does no HTTP for
|
||||
// connectors without a native provider (the RPC short-circuits). The
|
||||
// assertSessionNotNuked() below covers the real intent: the call
|
||||
// does not tear down the WebDriver session.
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: sync does not nuke session`);
|
||||
});
|
||||
|
||||
it('composio_execute routes a basic task', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-confluence-1',
|
||||
action: 'CONFLUENCE_LIST_PAGES',
|
||||
params: {},
|
||||
});
|
||||
// execReq URL check removed (see composio_sync comment above).
|
||||
console.log(`${LOG} PASS: execute routed`);
|
||||
});
|
||||
|
||||
it('failed connection shows error state, not blank screen', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-confluence-fail');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
expect(await textExists(CONNECTOR_NAME)).toBe(true);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: failed state does not blank screen`);
|
||||
});
|
||||
|
||||
it('expired auth shows Reconnect button and does not log user out', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-confluence-expired');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
|
||||
expect(modal).toBeTruthy();
|
||||
await assertModalPhase('expired', CONNECTOR_NAME);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: expired auth does not log user out`);
|
||||
});
|
||||
|
||||
it('unrelated 401 on composio route does not nuke session', async function () {
|
||||
this.timeout(60_000);
|
||||
injectComposioFault(400);
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-confluence-1',
|
||||
action: 'CONFLUENCE_LIST_PAGES',
|
||||
params: {},
|
||||
});
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: 401-class error does not nuke session`);
|
||||
});
|
||||
|
||||
it('disconnect flow removes connection', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-confluence-1');
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_delete_connection', {
|
||||
connection_id: 'c-confluence-1',
|
||||
});
|
||||
const deleteReq = getRequestLog().find(
|
||||
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
|
||||
);
|
||||
expect(deleteReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: disconnect routed DELETE`);
|
||||
await assertSessionNotNuked();
|
||||
});
|
||||
});
|
||||
@@ -116,7 +116,7 @@ describe('Discord (Composio) connector flow', () => {
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
it('composio_sync does not tear down the session', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('GitHub Composio connector flow', () => {
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
it('composio_sync does not tear down the session', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('Gmail (Composio) connector flow', () => {
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
it('composio_sync does not tear down the session', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
/**
|
||||
* E2E: Google Calendar (Composio) connector flow.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import {
|
||||
assertConnectorCardVisible,
|
||||
assertModalPhase,
|
||||
assertSessionNotNuked,
|
||||
injectComposioFault,
|
||||
openConnectorModal,
|
||||
seedComposioConnection,
|
||||
seedComposioToolkits,
|
||||
} from '../helpers/composio-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
const LOG = '[ConnectorGoogleCalendarE2E]';
|
||||
const CONNECTOR_NAME = 'Google Calendar';
|
||||
const TOOLKIT_SLUG = 'googlecalendar';
|
||||
const AUTH_TOKEN = 'e2e-connector-googlecalendar-token';
|
||||
|
||||
describe('Google Calendar Composio connector flow', () => {
|
||||
before(async function () {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gcal-1');
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await completeOnboardingIfVisible(LOG);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetMockBehavior();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gcal-1');
|
||||
});
|
||||
|
||||
it('card is visible and selectable', async function () {
|
||||
this.timeout(60_000);
|
||||
await assertConnectorCardVisible(CONNECTOR_NAME);
|
||||
console.log(`${LOG} PASS: card visible`);
|
||||
});
|
||||
|
||||
it('auth/connect flow succeeds with mocked backend', async function () {
|
||||
this.timeout(60_000);
|
||||
clearRequestLog();
|
||||
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
|
||||
expect(out.ok).toBe(true);
|
||||
const authReq = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/agent-integrations/composio/authorize')
|
||||
);
|
||||
expect(authReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: auth/connect routed correctly`);
|
||||
});
|
||||
|
||||
it('connected state persists after reconnect/reload', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gcal-1');
|
||||
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
|
||||
expect(out.ok).toBe(true);
|
||||
const result = (out.result as { result?: unknown })?.result ?? out.result;
|
||||
const connections = (result as { connections?: unknown[] })?.connections ?? [];
|
||||
const hit = (connections as { toolkit?: string; status?: string }[]).find(
|
||||
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.status).toBe('ACTIVE');
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
// syncReq URL check removed — composio_sync does no HTTP for
|
||||
// connectors without a native provider (the RPC short-circuits). The
|
||||
// assertSessionNotNuked() below covers the real intent: the call
|
||||
// does not tear down the WebDriver session.
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: sync does not nuke session`);
|
||||
});
|
||||
|
||||
it('composio_execute routes a basic task', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-gcal-1',
|
||||
action: 'GOOGLECALENDAR_LIST_EVENTS',
|
||||
params: {},
|
||||
});
|
||||
// execReq URL check removed (see composio_sync comment above).
|
||||
console.log(`${LOG} PASS: execute routed`);
|
||||
});
|
||||
|
||||
it('failed connection shows error state, not blank screen', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-gcal-fail');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
expect(await textExists(CONNECTOR_NAME)).toBe(true);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: failed state does not blank screen`);
|
||||
});
|
||||
|
||||
it('expired auth shows Reconnect button and does not log user out', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-gcal-expired');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
|
||||
expect(modal).toBeTruthy();
|
||||
await assertModalPhase('expired', CONNECTOR_NAME);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: expired auth does not log user out`);
|
||||
});
|
||||
|
||||
it('unrelated 401 on composio route does not nuke session', async function () {
|
||||
this.timeout(60_000);
|
||||
injectComposioFault(400);
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-gcal-1',
|
||||
action: 'GOOGLECALENDAR_LIST_EVENTS',
|
||||
params: {},
|
||||
});
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: 401-class error does not nuke session`);
|
||||
});
|
||||
|
||||
it('disconnect flow removes connection', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gcal-1');
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_delete_connection', { connection_id: 'c-gcal-1' });
|
||||
const deleteReq = getRequestLog().find(
|
||||
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
|
||||
);
|
||||
expect(deleteReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: disconnect routed DELETE`);
|
||||
await assertSessionNotNuked();
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* E2E: Google Drive (Composio) connector flow.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import {
|
||||
assertConnectorCardVisible,
|
||||
assertModalPhase,
|
||||
assertSessionNotNuked,
|
||||
injectComposioFault,
|
||||
openConnectorModal,
|
||||
seedComposioConnection,
|
||||
seedComposioToolkits,
|
||||
} from '../helpers/composio-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
const LOG = '[ConnectorGoogleDriveE2E]';
|
||||
const CONNECTOR_NAME = 'Google Drive';
|
||||
const TOOLKIT_SLUG = 'googledrive';
|
||||
const AUTH_TOKEN = 'e2e-connector-googledrive-token';
|
||||
|
||||
describe('Google Drive Composio connector flow', () => {
|
||||
before(async function () {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gdrive-1');
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await completeOnboardingIfVisible(LOG);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetMockBehavior();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gdrive-1');
|
||||
});
|
||||
|
||||
it('card is visible and selectable', async function () {
|
||||
this.timeout(60_000);
|
||||
await assertConnectorCardVisible(CONNECTOR_NAME);
|
||||
console.log(`${LOG} PASS: card visible`);
|
||||
});
|
||||
|
||||
it('auth/connect flow succeeds with mocked backend', async function () {
|
||||
this.timeout(60_000);
|
||||
clearRequestLog();
|
||||
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
|
||||
expect(out.ok).toBe(true);
|
||||
const authReq = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/composio/authorize')
|
||||
);
|
||||
expect(authReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: auth/connect routed`);
|
||||
});
|
||||
|
||||
it('connected state persists after reconnect/reload', async function () {
|
||||
this.timeout(60_000);
|
||||
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
|
||||
expect(out.ok).toBe(true);
|
||||
const result = (out.result as { result?: unknown })?.result ?? out.result;
|
||||
const connections = (result as { connections?: unknown[] })?.connections ?? [];
|
||||
const hit = (connections as { toolkit?: string; status?: string }[]).find(
|
||||
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.status).toBe('ACTIVE');
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
// syncReq URL check removed — composio_sync does no HTTP for
|
||||
// connectors without a native provider (the RPC short-circuits). The
|
||||
// assertSessionNotNuked() below covers the real intent: the call
|
||||
// does not tear down the WebDriver session.
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: sync does not nuke session`);
|
||||
});
|
||||
|
||||
it('composio_execute routes a basic task', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-gdrive-1',
|
||||
action: 'GOOGLEDRIVE_LIST_FILES',
|
||||
params: {},
|
||||
});
|
||||
// execReq URL check removed (see composio_sync comment above).
|
||||
console.log(`${LOG} PASS: execute routed`);
|
||||
});
|
||||
|
||||
it('failed connection shows error state, not blank screen', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-gdrive-fail');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
expect(await textExists(CONNECTOR_NAME)).toBe(true);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: failed state does not blank screen`);
|
||||
});
|
||||
|
||||
it('expired auth shows Reconnect button and does not log user out', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-gdrive-expired');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
|
||||
expect(modal).toBeTruthy();
|
||||
await assertModalPhase('expired', CONNECTOR_NAME);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: expired auth does not log user out`);
|
||||
});
|
||||
|
||||
it('unrelated 401 on composio route does not nuke session', async function () {
|
||||
this.timeout(60_000);
|
||||
injectComposioFault(400);
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-gdrive-1',
|
||||
action: 'GOOGLEDRIVE_LIST_FILES',
|
||||
params: {},
|
||||
});
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: 401-class error does not nuke session`);
|
||||
});
|
||||
|
||||
it('disconnect flow removes connection', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gdrive-1');
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_delete_connection', { connection_id: 'c-gdrive-1' });
|
||||
const deleteReq = getRequestLog().find(
|
||||
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
|
||||
);
|
||||
expect(deleteReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: disconnect routed DELETE`);
|
||||
await assertSessionNotNuked();
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* E2E: Google Sheets (Composio) connector flow.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import {
|
||||
assertConnectorCardVisible,
|
||||
assertModalPhase,
|
||||
assertSessionNotNuked,
|
||||
injectComposioFault,
|
||||
openConnectorModal,
|
||||
seedComposioConnection,
|
||||
seedComposioToolkits,
|
||||
} from '../helpers/composio-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
const LOG = '[ConnectorGoogleSheetsE2E]';
|
||||
const CONNECTOR_NAME = 'Google Sheets';
|
||||
const TOOLKIT_SLUG = 'googlesheets';
|
||||
const AUTH_TOKEN = 'e2e-connector-googlesheets-token';
|
||||
|
||||
describe('Google Sheets Composio connector flow', () => {
|
||||
before(async function () {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gsheets-1');
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await completeOnboardingIfVisible(LOG);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetMockBehavior();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gsheets-1');
|
||||
});
|
||||
|
||||
it('card is visible and selectable', async function () {
|
||||
this.timeout(60_000);
|
||||
await assertConnectorCardVisible(CONNECTOR_NAME);
|
||||
console.log(`${LOG} PASS: card visible`);
|
||||
});
|
||||
|
||||
it('auth/connect flow succeeds with mocked backend', async function () {
|
||||
this.timeout(60_000);
|
||||
clearRequestLog();
|
||||
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
|
||||
expect(out.ok).toBe(true);
|
||||
const authReq = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/composio/authorize')
|
||||
);
|
||||
expect(authReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: auth/connect routed`);
|
||||
});
|
||||
|
||||
it('connected state persists after reconnect/reload', async function () {
|
||||
this.timeout(60_000);
|
||||
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
|
||||
expect(out.ok).toBe(true);
|
||||
const result = (out.result as { result?: unknown })?.result ?? out.result;
|
||||
const connections = (result as { connections?: unknown[] })?.connections ?? [];
|
||||
const hit = (connections as { toolkit?: string; status?: string }[]).find(
|
||||
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.status).toBe('ACTIVE');
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
// syncReq URL check removed — composio_sync does no HTTP for
|
||||
// connectors without a native provider (the RPC short-circuits). The
|
||||
// assertSessionNotNuked() below covers the real intent: the call
|
||||
// does not tear down the WebDriver session.
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: sync does not nuke session`);
|
||||
});
|
||||
|
||||
it('composio_execute routes a basic task', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-gsheets-1',
|
||||
action: 'GOOGLESHEETS_LIST_SPREADSHEETS',
|
||||
params: {},
|
||||
});
|
||||
// execReq URL check removed (see composio_sync comment above).
|
||||
console.log(`${LOG} PASS: execute routed`);
|
||||
});
|
||||
|
||||
it('failed connection shows error state, not blank screen', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-gsheets-fail');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
expect(await textExists(CONNECTOR_NAME)).toBe(true);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: failed state does not blank screen`);
|
||||
});
|
||||
|
||||
it('expired auth shows Reconnect button and does not log user out', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-gsheets-expired');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
|
||||
expect(modal).toBeTruthy();
|
||||
await assertModalPhase('expired', CONNECTOR_NAME);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: expired auth does not log user out`);
|
||||
});
|
||||
|
||||
it('unrelated 401 on composio route does not nuke session', async function () {
|
||||
this.timeout(60_000);
|
||||
injectComposioFault(400);
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-gsheets-1',
|
||||
action: 'GOOGLESHEETS_LIST_SPREADSHEETS',
|
||||
params: {},
|
||||
});
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: 401-class error does not nuke session`);
|
||||
});
|
||||
|
||||
it('disconnect flow removes connection', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-gsheets-1');
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_delete_connection', {
|
||||
connection_id: 'c-gsheets-1',
|
||||
});
|
||||
const deleteReq = getRequestLog().find(
|
||||
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
|
||||
);
|
||||
expect(deleteReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: disconnect routed DELETE`);
|
||||
await assertSessionNotNuked();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* E2E: Notion (Composio) connector flow.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import {
|
||||
assertConnectorCardVisible,
|
||||
assertModalPhase,
|
||||
assertSessionNotNuked,
|
||||
injectComposioFault,
|
||||
openConnectorModal,
|
||||
seedComposioConnection,
|
||||
seedComposioToolkits,
|
||||
} from '../helpers/composio-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
const LOG = '[ConnectorNotionE2E]';
|
||||
const CONNECTOR_NAME = 'Notion';
|
||||
const TOOLKIT_SLUG = 'notion';
|
||||
const AUTH_TOKEN = 'e2e-connector-notion-token';
|
||||
|
||||
describe('Notion Composio connector flow', () => {
|
||||
before(async function () {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-notion-1');
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await completeOnboardingIfVisible(LOG);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetMockBehavior();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-notion-1');
|
||||
});
|
||||
|
||||
it('card is visible and selectable', async function () {
|
||||
this.timeout(60_000);
|
||||
await assertConnectorCardVisible(CONNECTOR_NAME);
|
||||
console.log(`${LOG} PASS: card visible`);
|
||||
});
|
||||
|
||||
it('auth/connect flow succeeds with mocked backend', async function () {
|
||||
this.timeout(60_000);
|
||||
clearRequestLog();
|
||||
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
|
||||
expect(out.ok).toBe(true);
|
||||
const authReq = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/composio/authorize')
|
||||
);
|
||||
expect(authReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: auth/connect routed`);
|
||||
});
|
||||
|
||||
it('connected state persists after reconnect/reload', async function () {
|
||||
this.timeout(60_000);
|
||||
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
|
||||
expect(out.ok).toBe(true);
|
||||
const result = (out.result as { result?: unknown })?.result ?? out.result;
|
||||
const connections = (result as { connections?: unknown[] })?.connections ?? [];
|
||||
const hit = (connections as { toolkit?: string; status?: string }[]).find(
|
||||
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.status).toBe('ACTIVE');
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
// syncReq URL check removed — composio_sync does no HTTP for
|
||||
// connectors without a native provider (the RPC short-circuits). The
|
||||
// assertSessionNotNuked() below covers the real intent: the call
|
||||
// does not tear down the WebDriver session.
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: sync does not nuke session`);
|
||||
});
|
||||
|
||||
it('composio_execute routes a basic task', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-notion-1',
|
||||
action: 'NOTION_LIST_PAGES',
|
||||
params: {},
|
||||
});
|
||||
// execReq URL check removed (see composio_sync comment above).
|
||||
console.log(`${LOG} PASS: execute routed`);
|
||||
});
|
||||
|
||||
it('failed connection shows error state, not blank screen', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-notion-fail');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
expect(await textExists(CONNECTOR_NAME)).toBe(true);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: failed state does not blank screen`);
|
||||
});
|
||||
|
||||
it('expired auth shows Reconnect button and does not log user out', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-notion-expired');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
|
||||
expect(modal).toBeTruthy();
|
||||
await assertModalPhase('expired', CONNECTOR_NAME);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: expired auth does not log user out`);
|
||||
});
|
||||
|
||||
it('unrelated 401 on composio route does not nuke session', async function () {
|
||||
this.timeout(60_000);
|
||||
injectComposioFault(400);
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-notion-1',
|
||||
action: 'NOTION_LIST_PAGES',
|
||||
params: {},
|
||||
});
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: 401-class error does not nuke session`);
|
||||
});
|
||||
|
||||
it('disconnect flow removes connection', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-notion-1');
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_delete_connection', { connection_id: 'c-notion-1' });
|
||||
const deleteReq = getRequestLog().find(
|
||||
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
|
||||
);
|
||||
expect(deleteReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: disconnect routed DELETE`);
|
||||
await assertSessionNotNuked();
|
||||
});
|
||||
});
|
||||
@@ -1,162 +0,0 @@
|
||||
/**
|
||||
* E2E: Slack (Composio) connector flow.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import {
|
||||
assertConnectorCardVisible,
|
||||
assertModalPhase,
|
||||
assertSessionNotNuked,
|
||||
injectComposioFault,
|
||||
openConnectorModal,
|
||||
seedComposioConnection,
|
||||
seedComposioToolkits,
|
||||
} from '../helpers/composio-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
const LOG = '[ConnectorSlackComposioE2E]';
|
||||
const CONNECTOR_NAME = 'Slack';
|
||||
const TOOLKIT_SLUG = 'slack';
|
||||
const AUTH_TOKEN = 'e2e-connector-slack-composio-token';
|
||||
|
||||
describe('Slack (Composio) connector flow', () => {
|
||||
before(async function () {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-slack-1');
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await completeOnboardingIfVisible(LOG);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetMockBehavior();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-slack-1');
|
||||
});
|
||||
|
||||
it('card is visible and selectable', async function () {
|
||||
this.timeout(60_000);
|
||||
await assertConnectorCardVisible(CONNECTOR_NAME);
|
||||
console.log(`${LOG} PASS: card visible`);
|
||||
});
|
||||
|
||||
it('auth/connect flow succeeds with mocked backend', async function () {
|
||||
this.timeout(60_000);
|
||||
clearRequestLog();
|
||||
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
|
||||
expect(out.ok).toBe(true);
|
||||
const authReq = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/composio/authorize')
|
||||
);
|
||||
expect(authReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: auth/connect routed`);
|
||||
});
|
||||
|
||||
it('connected state persists after reconnect/reload', async function () {
|
||||
this.timeout(60_000);
|
||||
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
|
||||
expect(out.ok).toBe(true);
|
||||
const result = (out.result as { result?: unknown })?.result ?? out.result;
|
||||
const connections = (result as { connections?: unknown[] })?.connections ?? [];
|
||||
const hit = (connections as { toolkit?: string; status?: string }[]).find(
|
||||
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.status).toBe('ACTIVE');
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
// syncReq URL check removed — composio_sync does no HTTP for
|
||||
// connectors without a native provider (the RPC short-circuits). The
|
||||
// assertSessionNotNuked() below covers the real intent: the call
|
||||
// does not tear down the WebDriver session.
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: sync does not nuke session`);
|
||||
});
|
||||
|
||||
it('composio_execute routes a basic task', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-slack-1',
|
||||
action: 'SLACK_LIST_CHANNELS',
|
||||
params: {},
|
||||
});
|
||||
// execReq URL check removed (see composio_sync comment above).
|
||||
console.log(`${LOG} PASS: execute routed`);
|
||||
});
|
||||
|
||||
it('failed connection shows error state, not blank screen', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-slack-fail');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
expect(await textExists(CONNECTOR_NAME)).toBe(true);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: failed state does not blank screen`);
|
||||
});
|
||||
|
||||
it('expired auth shows Reconnect button and does not log user out', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-slack-expired');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
|
||||
expect(modal).toBeTruthy();
|
||||
await assertModalPhase('expired', CONNECTOR_NAME);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: expired auth does not log user out`);
|
||||
});
|
||||
|
||||
it('unrelated 401 on composio route does not nuke session', async function () {
|
||||
this.timeout(60_000);
|
||||
injectComposioFault(400);
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-slack-1',
|
||||
action: 'SLACK_LIST_CHANNELS',
|
||||
params: {},
|
||||
});
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: 401-class error does not nuke session`);
|
||||
});
|
||||
|
||||
it('disconnect flow removes connection', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-slack-1');
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_delete_connection', { connection_id: 'c-slack-1' });
|
||||
const deleteReq = getRequestLog().find(
|
||||
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
|
||||
);
|
||||
expect(deleteReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: disconnect routed DELETE`);
|
||||
await assertSessionNotNuked();
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* E2E: Todoist (Composio) connector flow.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import {
|
||||
assertConnectorCardVisible,
|
||||
assertModalPhase,
|
||||
assertSessionNotNuked,
|
||||
injectComposioFault,
|
||||
openConnectorModal,
|
||||
seedComposioConnection,
|
||||
seedComposioToolkits,
|
||||
} from '../helpers/composio-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
const LOG = '[ConnectorTodoistE2E]';
|
||||
const CONNECTOR_NAME = 'Todoist';
|
||||
const TOOLKIT_SLUG = 'todoist';
|
||||
const AUTH_TOKEN = 'e2e-connector-todoist-token';
|
||||
|
||||
describe('Todoist Composio connector flow', () => {
|
||||
before(async function () {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-todoist-1');
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await completeOnboardingIfVisible(LOG);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetMockBehavior();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-todoist-1');
|
||||
});
|
||||
|
||||
it('card is visible and selectable', async function () {
|
||||
this.timeout(60_000);
|
||||
await assertConnectorCardVisible(CONNECTOR_NAME);
|
||||
console.log(`${LOG} PASS: card visible`);
|
||||
});
|
||||
|
||||
it('auth/connect flow succeeds with mocked backend', async function () {
|
||||
this.timeout(60_000);
|
||||
clearRequestLog();
|
||||
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
|
||||
expect(out.ok).toBe(true);
|
||||
const authReq = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/composio/authorize')
|
||||
);
|
||||
expect(authReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: auth/connect routed`);
|
||||
});
|
||||
|
||||
it('connected state persists after reconnect/reload', async function () {
|
||||
this.timeout(60_000);
|
||||
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
|
||||
expect(out.ok).toBe(true);
|
||||
const result = (out.result as { result?: unknown })?.result ?? out.result;
|
||||
const connections = (result as { connections?: unknown[] })?.connections ?? [];
|
||||
const hit = (connections as { toolkit?: string; status?: string }[]).find(
|
||||
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.status).toBe('ACTIVE');
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
// syncReq URL check removed — composio_sync does no HTTP for
|
||||
// connectors without a native provider (the RPC short-circuits). The
|
||||
// assertSessionNotNuked() below covers the real intent: the call
|
||||
// does not tear down the WebDriver session.
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: sync does not nuke session`);
|
||||
});
|
||||
|
||||
it('composio_execute routes a basic task', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-todoist-1',
|
||||
action: 'TODOIST_LIST_PROJECTS',
|
||||
params: {},
|
||||
});
|
||||
// execReq URL check removed (see composio_sync comment above).
|
||||
console.log(`${LOG} PASS: execute routed`);
|
||||
});
|
||||
|
||||
it('failed connection shows error state, not blank screen', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-todoist-fail');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
expect(await textExists(CONNECTOR_NAME)).toBe(true);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: failed state does not blank screen`);
|
||||
});
|
||||
|
||||
it('expired auth shows Reconnect button and does not log user out', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-todoist-expired');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
|
||||
expect(modal).toBeTruthy();
|
||||
await assertModalPhase('expired', CONNECTOR_NAME);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: expired auth does not log user out`);
|
||||
});
|
||||
|
||||
it('unrelated 401 on composio route does not nuke session', async function () {
|
||||
this.timeout(60_000);
|
||||
injectComposioFault(400);
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-todoist-1',
|
||||
action: 'TODOIST_LIST_PROJECTS',
|
||||
params: {},
|
||||
});
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: 401-class error does not nuke session`);
|
||||
});
|
||||
|
||||
it('disconnect flow removes connection', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-todoist-1');
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_delete_connection', {
|
||||
connection_id: 'c-todoist-1',
|
||||
});
|
||||
const deleteReq = getRequestLog().find(
|
||||
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
|
||||
);
|
||||
expect(deleteReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: disconnect routed DELETE`);
|
||||
await assertSessionNotNuked();
|
||||
});
|
||||
});
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* E2E: YouTube (Composio) connector flow.
|
||||
*/
|
||||
import { waitForApp } from '../helpers/app-helpers';
|
||||
import {
|
||||
assertConnectorCardVisible,
|
||||
assertModalPhase,
|
||||
assertSessionNotNuked,
|
||||
injectComposioFault,
|
||||
openConnectorModal,
|
||||
seedComposioConnection,
|
||||
seedComposioToolkits,
|
||||
} from '../helpers/composio-helpers';
|
||||
import { callOpenhumanRpc } from '../helpers/core-rpc';
|
||||
import { triggerAuthDeepLinkBypass } from '../helpers/deep-link-helpers';
|
||||
import {
|
||||
textExists,
|
||||
waitForText,
|
||||
waitForWebView,
|
||||
waitForWindowVisible,
|
||||
} from '../helpers/element-helpers';
|
||||
import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows';
|
||||
import {
|
||||
clearRequestLog,
|
||||
getRequestLog,
|
||||
resetMockBehavior,
|
||||
startMockServer,
|
||||
stopMockServer,
|
||||
} from '../mock-server';
|
||||
|
||||
const LOG = '[ConnectorYouTubeE2E]';
|
||||
const CONNECTOR_NAME = 'YouTube';
|
||||
const TOOLKIT_SLUG = 'youtube';
|
||||
const AUTH_TOKEN = 'e2e-connector-youtube-token';
|
||||
|
||||
describe('YouTube Composio connector flow', () => {
|
||||
before(async function () {
|
||||
this.timeout(90_000);
|
||||
await startMockServer();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-youtube-1');
|
||||
await waitForApp();
|
||||
clearRequestLog();
|
||||
await triggerAuthDeepLinkBypass(AUTH_TOKEN);
|
||||
await waitForWindowVisible(25_000);
|
||||
await waitForWebView(15_000);
|
||||
await completeOnboardingIfVisible(LOG);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await stopMockServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetMockBehavior();
|
||||
seedComposioToolkits([TOOLKIT_SLUG]);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-youtube-1');
|
||||
});
|
||||
|
||||
it('card is visible and selectable', async function () {
|
||||
this.timeout(60_000);
|
||||
await assertConnectorCardVisible(CONNECTOR_NAME);
|
||||
console.log(`${LOG} PASS: card visible`);
|
||||
});
|
||||
|
||||
it('auth/connect flow succeeds with mocked backend', async function () {
|
||||
this.timeout(60_000);
|
||||
clearRequestLog();
|
||||
const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: TOOLKIT_SLUG });
|
||||
expect(out.ok).toBe(true);
|
||||
const authReq = getRequestLog().find(
|
||||
r => r.method === 'POST' && r.url.includes('/composio/authorize')
|
||||
);
|
||||
expect(authReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: auth/connect routed`);
|
||||
});
|
||||
|
||||
it('connected state persists after reconnect/reload', async function () {
|
||||
this.timeout(60_000);
|
||||
const out = await callOpenhumanRpc('openhuman.composio_list_connections', {});
|
||||
expect(out.ok).toBe(true);
|
||||
const result = (out.result as { result?: unknown })?.result ?? out.result;
|
||||
const connections = (result as { connections?: unknown[] })?.connections ?? [];
|
||||
const hit = (connections as { toolkit?: string; status?: string }[]).find(
|
||||
c => c.toolkit?.toLowerCase() === TOOLKIT_SLUG
|
||||
);
|
||||
expect(hit).toBeDefined();
|
||||
expect(hit?.status).toBe('ACTIVE');
|
||||
console.log(`${LOG} PASS: connected state persists`);
|
||||
});
|
||||
|
||||
it('composio_sync RPC routes to mock backend', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_sync', { toolkit: TOOLKIT_SLUG });
|
||||
// syncReq URL check removed — composio_sync does no HTTP for
|
||||
// connectors without a native provider (the RPC short-circuits). The
|
||||
// assertSessionNotNuked() below covers the real intent: the call
|
||||
// does not tear down the WebDriver session.
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: sync does not nuke session`);
|
||||
});
|
||||
|
||||
it('composio_execute routes a basic task', async function () {
|
||||
this.timeout(30_000);
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-youtube-1',
|
||||
action: 'YOUTUBE_LIST_PLAYLISTS',
|
||||
params: {},
|
||||
});
|
||||
// execReq URL check removed (see composio_sync comment above).
|
||||
console.log(`${LOG} PASS: execute routed`);
|
||||
});
|
||||
|
||||
it('failed connection shows error state, not blank screen', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'FAILED', 'c-youtube-fail');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
expect(await textExists(CONNECTOR_NAME)).toBe(true);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: failed state does not blank screen`);
|
||||
});
|
||||
|
||||
it('expired auth shows Reconnect button and does not log user out', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'EXPIRED', 'c-youtube-expired');
|
||||
await navigateToSkills();
|
||||
await waitForText(CONNECTOR_NAME, 10_000);
|
||||
const modal = await openConnectorModal(CONNECTOR_NAME, 15_000, 'Auth expired');
|
||||
expect(modal).toBeTruthy();
|
||||
await assertModalPhase('expired', CONNECTOR_NAME);
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: expired auth does not log user out`);
|
||||
});
|
||||
|
||||
it('unrelated 401 on composio route does not nuke session', async function () {
|
||||
this.timeout(60_000);
|
||||
injectComposioFault(400);
|
||||
await callOpenhumanRpc('openhuman.composio_execute', {
|
||||
connection_id: 'c-youtube-1',
|
||||
action: 'YOUTUBE_LIST_PLAYLISTS',
|
||||
params: {},
|
||||
});
|
||||
await assertSessionNotNuked();
|
||||
console.log(`${LOG} PASS: 401-class error does not nuke session`);
|
||||
});
|
||||
|
||||
it('disconnect flow removes connection', async function () {
|
||||
this.timeout(60_000);
|
||||
seedComposioConnection(TOOLKIT_SLUG, 'ACTIVE', 'c-youtube-1');
|
||||
clearRequestLog();
|
||||
await callOpenhumanRpc('openhuman.composio_delete_connection', {
|
||||
connection_id: 'c-youtube-1',
|
||||
});
|
||||
const deleteReq = getRequestLog().find(
|
||||
r => r.method === 'DELETE' && r.url.includes('/composio/connections/')
|
||||
);
|
||||
expect(deleteReq).toBeDefined();
|
||||
console.log(`${LOG} PASS: disconnect routed DELETE`);
|
||||
await assertSessionNotNuked();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1019,6 +1019,29 @@ pub fn all_http_method_schemas() -> Vec<HttpMethodSchemaDefinition> {
|
||||
methods
|
||||
}
|
||||
|
||||
/// Shared test helper: assert a domain's controller-schema list and
|
||||
/// registered-controller list stay in lockstep, and that a known function is
|
||||
/// present. Replaces the brittle `assert_eq!(schemas().len(), N)` magic-number
|
||||
/// pattern repeated across ~15 domains (plan.md §3/§6) — a legitimate new
|
||||
/// controller no longer breaks the count, but a schema/handler desync or a
|
||||
/// dropped op still fails.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn assert_schema_controller_parity(
|
||||
schemas: &[ControllerSchema],
|
||||
controllers: &[RegisteredController],
|
||||
known_function: &str,
|
||||
) {
|
||||
assert_eq!(
|
||||
schemas.len(),
|
||||
controllers.len(),
|
||||
"schema/controller registration lists must stay in lockstep",
|
||||
);
|
||||
assert!(
|
||||
schemas.iter().any(|s| s.function == known_function),
|
||||
"expected a `{known_function}` controller schema to be registered",
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "all_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -31,7 +31,6 @@ use crate::openhuman::tool_timeout::parse_tool_timeout_secs;
|
||||
use crate::openhuman::tools::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Shared test doubles
|
||||
@@ -327,59 +326,3 @@ fn current_datetime_line_matches_iso8601_date_and_utc_offset_pattern() {
|
||||
"stamp must contain an IANA zone (slashed) or UTC fallback: {payload}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn datetime_section_is_static_grounding_rule_not_a_volatile_timestamp() {
|
||||
use crate::openhuman::agent::prompts::{DateTimeSection, PromptContext, PromptSection};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
static EMPTY_FILTER: LazyLock<HashSet<String>> = LazyLock::new(HashSet::new);
|
||||
static EMPTY_TOOLS: &[crate::openhuman::agent::prompts::PromptTool<'static>] = &[];
|
||||
static EMPTY_INTEGRATIONS: &[crate::openhuman::context::prompt::ConnectedIntegration] = &[];
|
||||
|
||||
let ctx = PromptContext {
|
||||
workspace_dir: Path::new("/tmp"),
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: EMPTY_TOOLS,
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: crate::openhuman::agent::prompts::LearnedContextData::default(),
|
||||
visible_tool_names: &EMPTY_FILTER,
|
||||
tool_call_format: crate::openhuman::context::prompt::ToolCallFormat::PFormat,
|
||||
connected_integrations: EMPTY_INTEGRATIONS,
|
||||
connected_identities_md: String::new(),
|
||||
include_profile: false,
|
||||
include_memory_md: false,
|
||||
curated_snapshot: None,
|
||||
user_identity: None,
|
||||
personality_soul_md: None,
|
||||
personality_memory_md: None,
|
||||
personality_roster: vec![],
|
||||
};
|
||||
|
||||
let rendered = DateTimeSection.build(&ctx).unwrap();
|
||||
let payload = rendered
|
||||
.strip_prefix("## Current Date & Time\n\n")
|
||||
.expect("DateTimeSection must start with the heading");
|
||||
|
||||
// The section is a static rule: it must carry the greeting-grounding
|
||||
// guidance and point at the per-turn line, but NOT bake in a date — a
|
||||
// concrete YYYY-MM-DD here would re-freeze the volatile clock into the
|
||||
// cached prefix (the #3602 regression this guards against).
|
||||
assert!(
|
||||
payload.contains("match the actual local hour") && payload.contains("Current Date & Time:"),
|
||||
"section must carry the grounding rule pointing at the per-turn stamp: {payload}"
|
||||
);
|
||||
// Byte-stability is the real no-volatile-timestamp invariant (a static
|
||||
// literal like "11 PM" in the rule is fine; a baked `Local::now()` is
|
||||
// not): two renders a moment apart must be identical, or the cached
|
||||
// prefix would churn every second.
|
||||
let again = DateTimeSection.build(&ctx).unwrap();
|
||||
assert_eq!(
|
||||
rendered, again,
|
||||
"datetime section must be byte-stable (no volatile timestamp baked in)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -153,17 +153,18 @@ fn grounding_contract_requires_exact_numeric_evidence() {
|
||||
.build(&ctx)
|
||||
.unwrap();
|
||||
|
||||
assert!(rendered.contains("Preserve numeric evidence exactly"));
|
||||
assert!(rendered.contains(
|
||||
"numbers, counts, sizes, dates, timestamps, durations, currencies, percentages, quotas, and ids"
|
||||
));
|
||||
assert!(rendered.contains(
|
||||
"copy the exact value from the observed tool result, user message, or cited memory"
|
||||
));
|
||||
assert!(rendered.contains("Do not round, convert units, rewrite relative times"));
|
||||
assert!(rendered.contains(
|
||||
"If sources disagree, name the discrepancy instead of choosing a plausible value"
|
||||
));
|
||||
// WORDING LOCK (deliberate, plan.md §3): pin ONE representative clause of
|
||||
// the numeric-evidence grounding rule so a copy edit that silently drops
|
||||
// the "preserve numbers exactly" guidance trips review — rather than five
|
||||
// verbatim prose substrings that break on any harmless rewording. The
|
||||
// *structural* guarantee (the grounding contract is appended on every build
|
||||
// path) is covered behaviourally by
|
||||
// grounding_contract_appended_to_every_build_path. Update this string only
|
||||
// on a deliberate rewrite of GROUNDING_BODY.
|
||||
assert!(
|
||||
rendered.contains("Preserve numeric evidence exactly"),
|
||||
"numeric-evidence grounding clause missing from the built prompt"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -204,13 +205,27 @@ fn identity_section_creates_missing_workspace_files() {
|
||||
"expected workspace file to be created: {file}"
|
||||
);
|
||||
}
|
||||
// Seeded SOUL.md must equal the checked-in template verbatim (plan.md §3):
|
||||
// compare against the embedded template rather than pinning brand-voice
|
||||
// prose here — a missing file is seeded straight from
|
||||
// default_workspace_file_content, which is this same `include_str!`.
|
||||
let soul = std::fs::read_to_string(workspace.join("SOUL.md")).unwrap();
|
||||
assert!(
|
||||
soul.starts_with("# OpenHuman"),
|
||||
"SOUL.md should be seeded from src/openhuman/agent/prompts/SOUL.md"
|
||||
assert_eq!(
|
||||
soul,
|
||||
include_str!("SOUL.md"),
|
||||
"seeded SOUL.md must be the checked-in template verbatim"
|
||||
);
|
||||
// #3604: the brand-voice guardrail must ship in the seeded soul so the
|
||||
// agent defends the product constructively instead of validating FUD.
|
||||
|
||||
let _ = std::fs::remove_dir_all(workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn soul_template_carries_brand_voice_guardrail() {
|
||||
// BRAND-VOICE LOCK (#3604, plan.md §3): a narrow, deliberately-labeled
|
||||
// wording pin on the *source* SOUL.md template — the constructive-defense
|
||||
// guardrail must survive edits so the agent defends the product instead of
|
||||
// validating FUD. Update only on an intentional brand-voice change.
|
||||
let soul = include_str!("SOUL.md");
|
||||
assert!(
|
||||
soul.contains("## When OpenHuman is criticized"),
|
||||
"SOUL.md must carry the brand-voice section (#3604)"
|
||||
@@ -219,8 +234,6 @@ fn identity_section_creates_missing_workspace_files() {
|
||||
soul.contains("Don't validate FUD"),
|
||||
"SOUL.md brand-voice section must keep the do-not-validate-FUD directive (#3604)"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(workspace);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -497,150 +497,48 @@ fn whatsapp_parse_status_update_ignored() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_parse_audio_message_skipped() {
|
||||
fn whatsapp_parse_non_text_media_message_types_are_skipped() {
|
||||
// Every non-text message type hits the same `type != "text" -> continue`
|
||||
// branch in parse_webhook_payload. Table-driven, one representative case
|
||||
// per media type (collapsed from 7 byte-identical tests, plan.md §2.1).
|
||||
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||
let payload = serde_json::json!({
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"value": {
|
||||
"messages": [{
|
||||
let cases = [
|
||||
(
|
||||
"audio",
|
||||
serde_json::json!({ "id": "audio123", "mime_type": "audio/ogg" }),
|
||||
),
|
||||
("video", serde_json::json!({ "id": "video123" })),
|
||||
(
|
||||
"document",
|
||||
serde_json::json!({ "id": "doc123", "filename": "file.pdf" }),
|
||||
),
|
||||
("sticker", serde_json::json!({ "id": "sticker123" })),
|
||||
(
|
||||
"location",
|
||||
serde_json::json!({ "latitude": 40.7128, "longitude": -74.0060 }),
|
||||
),
|
||||
(
|
||||
"contacts",
|
||||
serde_json::json!([{ "name": { "formatted_name": "John" } }]),
|
||||
),
|
||||
(
|
||||
"reaction",
|
||||
serde_json::json!({ "message_id": "wamid.xxx", "emoji": "\u{1F44D}" }),
|
||||
),
|
||||
];
|
||||
for (kind, sub) in cases {
|
||||
let mut message = serde_json::json!({
|
||||
"from": "111",
|
||||
"timestamp": "1",
|
||||
"type": "audio",
|
||||
"audio": { "id": "audio123", "mime_type": "audio/ogg" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
"type": kind,
|
||||
});
|
||||
let msgs = ch.parse_webhook_payload(&payload);
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_parse_video_message_skipped() {
|
||||
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||
message[kind] = sub;
|
||||
let payload = serde_json::json!({
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"value": {
|
||||
"messages": [{
|
||||
"from": "111",
|
||||
"timestamp": "1",
|
||||
"type": "video",
|
||||
"video": { "id": "video123" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
"entry": [{ "changes": [{ "value": { "messages": [message] } }] }]
|
||||
});
|
||||
let msgs = ch.parse_webhook_payload(&payload);
|
||||
assert!(msgs.is_empty());
|
||||
assert!(msgs.is_empty(), "{kind} message must be skipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_parse_document_message_skipped() {
|
||||
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||
let payload = serde_json::json!({
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"value": {
|
||||
"messages": [{
|
||||
"from": "111",
|
||||
"timestamp": "1",
|
||||
"type": "document",
|
||||
"document": { "id": "doc123", "filename": "file.pdf" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
let msgs = ch.parse_webhook_payload(&payload);
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_parse_sticker_message_skipped() {
|
||||
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||
let payload = serde_json::json!({
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"value": {
|
||||
"messages": [{
|
||||
"from": "111",
|
||||
"timestamp": "1",
|
||||
"type": "sticker",
|
||||
"sticker": { "id": "sticker123" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
let msgs = ch.parse_webhook_payload(&payload);
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_parse_location_message_skipped() {
|
||||
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||
let payload = serde_json::json!({
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"value": {
|
||||
"messages": [{
|
||||
"from": "111",
|
||||
"timestamp": "1",
|
||||
"type": "location",
|
||||
"location": { "latitude": 40.7128, "longitude": -74.0060 }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
let msgs = ch.parse_webhook_payload(&payload);
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_parse_contacts_message_skipped() {
|
||||
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||
let payload = serde_json::json!({
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"value": {
|
||||
"messages": [{
|
||||
"from": "111",
|
||||
"timestamp": "1",
|
||||
"type": "contacts",
|
||||
"contacts": [{ "name": { "formatted_name": "John" } }]
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
let msgs = ch.parse_webhook_payload(&payload);
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whatsapp_parse_reaction_message_skipped() {
|
||||
let ch = WhatsAppChannel::new("tok".into(), "123".into(), "ver".into(), vec!["*".into()]);
|
||||
let payload = serde_json::json!({
|
||||
"entry": [{
|
||||
"changes": [{
|
||||
"value": {
|
||||
"messages": [{
|
||||
"from": "111",
|
||||
"timestamp": "1",
|
||||
"type": "reaction",
|
||||
"reaction": { "message_id": "wamid.xxx", "emoji": "👍" }
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}]
|
||||
});
|
||||
let msgs = ch.parse_webhook_payload(&payload);
|
||||
assert!(msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ────────────────────────────
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user